From 755929154971ee2fdb8789b4701b038473fe36af Mon Sep 17 00:00:00 2001 From: Paul Cioanca Date: Fri, 11 Oct 2024 14:26:47 +0300 Subject: [PATCH 001/255] feat: support for multiple pg versions (#29473) * feat: support multiple pg versions --------- Co-authored-by: Joshen Lim --- .../PostgresVersionSelector.tsx | 102 +++++ .../ProjectUpgradeAlert.tsx | 349 +++++++++++------- .../Infrastructure/InfrastructureInfo.tsx | 20 +- .../PausedState/ProjectPausedState.tsx | 163 +++++++- .../UpgradingState/UpgradingState.tsx | 3 + apps/studio/data/config/keys.ts | 8 + ...roject-creation-postgres-versions-query.ts | 62 ++++ ...project-unpause-postgres-versions-query.ts | 59 +++ .../project-upgrade-eligibility-query.ts | 1 + .../config/project-upgrade-status-query.ts | 14 +- .../data/projects/project-create-mutation.ts | 8 + .../data/projects/project-restore-mutation.ts | 18 +- .../data/projects/project-upgrade-mutation.ts | 15 +- apps/studio/next-env.d.ts | 2 +- apps/studio/pages/new/[slug].tsx | 121 +++--- 15 files changed, 728 insertions(+), 217 deletions(-) create mode 100644 apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx create mode 100644 apps/studio/data/config/project-creation-postgres-versions-query.ts create mode 100644 apps/studio/data/config/project-unpause-postgres-versions-query.ts diff --git a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx new file mode 100644 index 00000000000..0056c482d01 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx @@ -0,0 +1,102 @@ +import { useEffect } from 'react' +import { ControllerRenderProps, UseFormReturn } from 'react-hook-form' + +import { components } from 'api-types' +import { + ProjectCreationPostgresVersion, + useProjectCreationPostgresVersionsQuery, +} from 'data/config/project-creation-postgres-versions-query' +import type { CloudProvider } from 'shared-data' +import { + Badge, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +type ReleaseChannel = components['schemas']['ReleaseChannel'] +type PostgresEngine = components['schemas']['PostgresEngine'] + +interface PostgresVersionDetails { + postgresEngine: PostgresEngine | undefined + releaseChannel: ReleaseChannel | undefined +} + +interface PostgresVersionSelectorProps { + cloudProvider: CloudProvider + dbRegion: string + organizationSlug: string | undefined + field: ControllerRenderProps + form: UseFormReturn +} + +const formatValue = ({ postgres_engine, release_channel }: ProjectCreationPostgresVersion) => { + return `${postgres_engine}|${release_channel}` +} + +export const extractPostgresVersionDetails = (value: string): PostgresVersionDetails => { + if (!value) { + return { postgresEngine: undefined, releaseChannel: undefined } + } + + const [postgresEngine, releaseChannel] = value.split('|') + return { postgresEngine, releaseChannel } as PostgresVersionDetails +} + +export const PostgresVersionSelector = ({ + cloudProvider, + dbRegion, + organizationSlug, + field, + form, +}: PostgresVersionSelectorProps) => { + const { data, isLoading: isLoadingProjectVersions } = useProjectCreationPostgresVersionsQuery({ + cloudProvider, + dbRegion, + organizationSlug, + }) + + useEffect(() => { + const defaultValue = data?.available_versions?.[0] + ? formatValue(data.available_versions[0]) + : undefined + form.setValue('postgresVersionSelection', defaultValue) + }, [data, form]) + + return ( + + + + + + + + {(data?.available_versions || [])?.map((value) => { + const postgresVersion = value.version.split('supabase-postgres-')[1] + return ( + +
+ {postgresVersion} + {value.release_channel !== 'ga' && ( + + {value.release_channel} + + )} +
+
+ ) + })} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx index ed5daf18d15..ec6d0647d19 100644 --- a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx +++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx @@ -1,28 +1,62 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { useQueryClient } from '@tanstack/react-query' import { AlertCircle, AlertTriangle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' +import { z } from 'zod' import { useParams } from 'common' -import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query' -import { useProjectUpgradeMutation } from 'data/projects/project-upgrade-mutation' +import { + ProjectUpgradeTargetVersion, + useProjectUpgradeEligibilityQuery, +} from 'data/config/project-upgrade-eligibility-query' +import { ReleaseChannel, useProjectUpgradeMutation } from 'data/projects/project-upgrade-mutation' import { setProjectStatus } from 'data/projects/projects-query' import { useFlag } from 'hooks/ui/useFlag' import { PROJECT_STATUS } from 'lib/constants' +import { Admonition } from 'ui-patterns/admonition' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, + Badge, Button, - Form, - Listbox, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, Modal, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface PostgresVersionDetails { + postgresEngine: string + releaseChannel: ReleaseChannel +} + +const formatValue = ({ postgres_version, release_channel }: ProjectUpgradeTargetVersion) => { + return `${postgres_version}|${release_channel}` +} + +export const extractPostgresVersionDetails = (value: string): PostgresVersionDetails | null => { + if (!value) { + return null + } + + const [postgresEngine, releaseChannel] = value.split('|') + return { postgresEngine, releaseChannel } as PostgresVersionDetails +} const ProjectUpgradeAlert = () => { const router = useRouter() @@ -32,7 +66,6 @@ const ProjectUpgradeAlert = () => { const projectUpgradeDisabled = useFlag('disableProjectUpgrade') - const formId = 'project-upgrade-form' const { data } = useProjectUpgradeEligibilityQuery({ projectRef: ref }) const currentPgVersion = (data?.current_app_version ?? '').split('supabase-postgres-')[1] const latestPgVersion = (data?.latest_app_version ?? '').split('supabase-postgres-')[1] @@ -40,20 +73,48 @@ const ProjectUpgradeAlert = () => { const durationEstimateHours = data?.duration_estimate_hours || 1 const legacyAuthCustomRoles = data?.legacy_auth_custom_roles || [] - const initialValues = { version: data?.target_upgrade_versions?.[0]?.postgres_version ?? 0 } const { mutate: upgradeProject, isLoading: isUpgrading } = useProjectUpgradeMutation({ onSuccess: (res, variables) => { setProjectStatus(queryClient, variables.ref, PROJECT_STATUS.UPGRADING) toast.success('Upgrading project') - router.push(`/project/${variables.ref}?upgradeInitiated=true`) + router.push(`/project/${variables.ref}?upgradeInitiated=true&trackingId=${res.tracking_id}`) }, }) - const onConfirmUpgrade = async (values: any) => { + const onConfirmUpgrade = async (values: z.infer) => { if (!ref) return toast.error('Project ref not found') - upgradeProject({ ref, target_version: values.version }) + + const { postgresVersionSelection } = values + + const versionDetails = extractPostgresVersionDetails(postgresVersionSelection) + if (!versionDetails) return toast.error('Invalid Postgres version') + + upgradeProject({ + ref, + target_version: versionDetails.postgresEngine, + release_channel: versionDetails.releaseChannel, + }) } + const FormSchema = z.object({ + postgresVersionSelection: z.string(), + }) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + mode: 'onChange', + defaultValues: { + postgresVersionSelection: '', + }, + }) + + useEffect(() => { + const defaultValue = data?.target_upgrade_versions?.[0] + ? formatValue(data.target_upgrade_versions[0]) + : '' + form.setValue('postgresVersionSelection', defaultValue) + }, [data, form]) + return ( <> @@ -86,144 +147,146 @@ const ProjectUpgradeAlert = () => { setShowUpgradeModal(false)} - header={

Upgrade Postgres

} + header="Confirm to upgrade Postgres version" > -
- {({ values }: { values: any }) => { - const selectedVersion = (data?.target_upgrade_versions ?? []).find( - (x) => x.postgres_version === values.version - ) + + + + +
+

+ All services will be offline and you will not be able to downgrade back to + Postgres {currentPgVersion}. +

+ {(data?.potential_breaking_changes ?? []).length > 0 && ( + + + Breaking changes + +

+ Your project will be upgraded across major versions of Postgres. This may + involve breaking changes. +

- return ( - <> - -
-

All services are going offline.

-

- You will not be able to downgrade back to Postgres {currentPgVersion}. -

- - - - Your project will be offline for up to {durationEstimateHours} hour - {durationEstimateHours === 1 ? '' : 's'} - - -

- It is advised to upgrade at a time when there will be minimal impact for - your application. -

-
-
- {(data?.potential_breaking_changes ?? []).length > 0 && ( - - - Breaking changes - -

- Your project will be upgraded across major versions of Postgres. This - may involve breaking changes. -

- -
- -
-
-
- )} - {legacyAuthCustomRoles.length > 0 && ( - - - - Custom Postgres roles will not work automatically after upgrade - - -

You must run a series of commands after upgrading.

-

- This is because new Postgres versions use scram-sha-256 authentication - by default and do not support md5, as it has been deprecated. -

-
-

Run the following commands after the upgrade:

-
- - {legacyAuthCustomRoles.map((role) => ( -
- ALTER ROLE {role} WITH - PASSWORD 'newpassword'; -
- ))} -
-
-
-
- -
-
-
- )} - - {data?.target_upgrade_versions.map((version) => ( - - {version.postgres_version} ( - {version.app_version.split('supabase-postgres-')[1]}) - - ))} - -
-
- - - +
+ + + )} + {legacyAuthCustomRoles.length > 0 && ( + - Cancel - - -
- - ) - }} - + + + Custom Postgres roles will not work automatically after upgrade + + +

You must run a series of commands after upgrading.

+

+ This is because new Postgres versions use scram-sha-256 authentication by + default and do not support md5, as it has been deprecated. +

+
+

Run the following commands after the upgrade:

+
+ + {legacyAuthCustomRoles.map((role) => ( +
+ ALTER ROLE {role} WITH PASSWORD + 'newpassword'; +
+ ))} +
+
+
+
+ +
+
+
+ )} + ( + + + + + + + + + {(data?.target_upgrade_versions || [])?.map((value) => { + const postgresVersion = + value.app_version.split('supabase-postgres-')[1] + return ( + +
+ {postgresVersion} + {value.release_channel !== 'ga' && ( + + {value.release_channel} + + )} +
+
+ ) + })} +
+
+
+
+
+ )} + /> + + + + + + + + + ) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index b3b7e5932ba..7b460d9804f 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -42,9 +42,14 @@ const InfrastructureInfo = () => { projectRef: ref, }) const { data: databases } = useReadReplicasQuery({ projectRef: ref }) - const { current_app_version, latest_app_version } = data || {} + const { current_app_version, current_app_version_release_channel, latest_app_version } = + data || {} const isOnLatestVersion = current_app_version === latest_app_version const currentPgVersion = (current_app_version ?? '').split('supabase-postgres-')[1] + const isOnNonGenerallyAvailableReleaseChannel = + current_app_version_release_channel && current_app_version_release_channel !== 'ga' + ? current_app_version_release_channel + : undefined const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1] const isInactive = project?.status === 'INACTIVE' @@ -106,6 +111,19 @@ const InfrastructureInfo = () => { value={currentPgVersion} label="Postgres version" actions={[ + isOnNonGenerallyAvailableReleaseChannel && ( + + + + {isOnNonGenerallyAvailableReleaseChannel} + + + + This project uses a {isOnNonGenerallyAvailableReleaseChannel} database + version release + + + ), isOnLatestVersion && ( diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 9e5998bae80..ce40709d502 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -1,14 +1,23 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { ExternalLink, PauseCircle } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' +import { z } from 'zod' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { + PostgresEngine, + ProjectUnpausePostgresVersion, + ReleaseChannel, + useProjectUnpausePostgresVersionsQuery, +} from 'data/config/project-unpause-postgres-versions-query' import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query' import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useProjectRestoreMutation } from 'data/projects/project-restore-mutation' @@ -18,8 +27,24 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { PROJECT_STATUS } from 'lib/constants' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Modal } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Badge, + Button, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Modal, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { PauseDisabledState } from './PauseDisabledState' import { RestorePaidPlanProjectNotice } from '../RestorePaidPlanProjectNotice' @@ -29,12 +54,27 @@ export interface ProjectPausedStateProps { product?: string } +interface PostgresVersionDetails { + postgresEngine: PostgresEngine + releaseChannel: ReleaseChannel +} + +const formatValue = ({ postgres_engine, release_channel }: ProjectUnpausePostgresVersion) => { + return `${postgres_engine}|${release_channel}` +} + +export const extractPostgresVersionDetails = (value: string): PostgresVersionDetails => { + const [postgresEngine, releaseChannel] = value.split('|') + return { postgresEngine, releaseChannel } as PostgresVersionDetails +} + export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { const { ref } = useParams() const queryClient = useQueryClient() const { project } = useProjectContext() const selectedOrganization = useSelectedOrganization() const enforceNinetyDayUnpauseExpiry = useFlag('enforceNinetyDayUnpauseExpiry') + const projectVersionSelectionDisabled = useFlag('disableProjectVersionSelection') const orgSlug = selectedOrganization?.slug const { data: subscription } = useOrgSubscriptionQuery({ orgSlug }) @@ -64,11 +104,15 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { { enabled: isFreePlan } ) + const { data: availablePostgresVersions } = useProjectUnpausePostgresVersionsQuery({ + projectRef: project?.ref, + }) + const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 const [showConfirmRestore, setShowConfirmRestore] = useState(false) const [showFreeProjectLimitWarning, setShowFreeProjectLimitWarning] = useState(false) - const { mutate: restoreProject } = useProjectRestoreMutation({ + const { mutate: restoreProject, isLoading: isRestoring } = useProjectRestoreMutation({ onSuccess: (_, variables) => { setProjectStatus(queryClient, variables.ref, PROJECT_STATUS.RESTORING) toast.success('Restoring project') @@ -87,13 +131,45 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { else setShowConfirmRestore(true) } - const onConfirmRestore = () => { + const onConfirmRestore = async (values: z.infer) => { if (!project) { return toast.error('Unable to restore: project is required') } - restoreProject({ ref: project.ref }) + + if (projectVersionSelectionDisabled) { + restoreProject({ ref: project.ref }) + } else { + const { postgresVersionSelection } = values + + const postgresVersionDetails = extractPostgresVersionDetails(postgresVersionSelection) + + restoreProject({ + ref: project.ref, + releaseChannel: postgresVersionDetails.releaseChannel, + postgresEngine: postgresVersionDetails.postgresEngine, + }) + } } + const FormSchema = z.object({ + postgresVersionSelection: z.string(), + }) + + const form = useForm>({ + resolver: zodResolver(FormSchema), + mode: 'onChange', + defaultValues: { + postgresVersionSelection: '', + }, + }) + + useEffect(() => { + const defaultValue = availablePostgresVersions?.available_versions[0] + ? formatValue(availablePostgresVersions?.available_versions[0]) + : '' + form.setValue('postgresVersionSelection', defaultValue) + }, [availablePostgresVersions, form]) + return ( <>
@@ -224,15 +300,78 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => {
- setShowConfirmRestore(false)} - onSelectConfirm={onConfirmRestore} - /> + onCancel={() => setShowConfirmRestore(false)} + header={'Restore this project'} + > + +
+ {!projectVersionSelectionDisabled && ( + +
+ ( + + + + + + + + + {(availablePostgresVersions?.available_versions || [])?.map( + (value) => { + const postgresVersion = + value.version.split('supabase-postgres-')[1] + return ( + +
+ {postgresVersion} + {value.release_channel !== 'ga' && ( + + {value.release_channel} + + )} +
+
+ ) + } + )} +
+
+
+
+
+ )} + /> +
+
+ )} + + + + +
+
+ { const { ref } = useParams() + const queryParams = useSearchParams() const queryClient = useQueryClient() const { project } = useProjectContext() const [loading, setLoading] = useState(false) @@ -33,6 +35,7 @@ const UpgradingState = () => { { projectRef: ref, projectStatus: project?.status, + trackingId: queryParams.get('trackingId'), }, { enabled: IS_PLATFORM, diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index c0b37ff5210..6f4b61da217 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -15,4 +15,12 @@ export const configKeys = { ['projects', projectRef, 'disk-attributes'] as const, diskUtilization: (projectRef: string | undefined) => ['projects', projectRef, 'disk-utilization'] as const, + projectCreationPostgresVersions: ( + organizationSlug: string | undefined, + cloudProvider: string, + dbRegion: string + ) => + ['projects', organizationSlug, cloudProvider, dbRegion, 'available-creation-versions'] as const, + projectUnpausePostgresVersions: (projectRef: string | undefined) => + ['projects', projectRef, 'available-unpause-versions'] as const, } diff --git a/apps/studio/data/config/project-creation-postgres-versions-query.ts b/apps/studio/data/config/project-creation-postgres-versions-query.ts new file mode 100644 index 00000000000..a54d42beb5e --- /dev/null +++ b/apps/studio/data/config/project-creation-postgres-versions-query.ts @@ -0,0 +1,62 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { components } from 'api-types' +import { handleError, post } from 'data/fetchers' +import { CloudProvider } from 'shared-data' +import type { ResponseError } from 'types' +import { configKeys } from './keys' + +export type ProjectCreationPostgresVersionsVariables = { + cloudProvider: CloudProvider + dbRegion: string + organizationSlug: string | undefined +} + +export type ProjectCreationPostgresVersion = components['schemas']['ProjectCreationVersionInfo'] + +export type ProjectCreationPostgresVersionsResponse = { + available_versions: ProjectCreationPostgresVersion[] +} + +export async function getPostgresCreationVersions( + { cloudProvider, dbRegion, organizationSlug }: ProjectCreationPostgresVersionsVariables, + signal?: AbortSignal +) { + if (!organizationSlug) throw new Error('organizationSlug is required') + + const { data, error } = await post('/platform/organizations/{slug}/available-versions', { + params: { path: { slug: organizationSlug } }, + body: { provider: cloudProvider, region: dbRegion }, + signal, + }) + + if (error) handleError(error) + return data as ProjectCreationPostgresVersionsResponse +} + +export type ProjectCreationPostgresVersionData = Awaited< + ReturnType +> +export type ProjectCreationPostgresVersionError = ResponseError + +export const useProjectCreationPostgresVersionsQuery = ( + { cloudProvider, dbRegion, organizationSlug }: ProjectCreationPostgresVersionsVariables, + { + enabled = true, + ...options + }: UseQueryOptions< + ProjectCreationPostgresVersionData, + ProjectCreationPostgresVersionError, + TData + > = {} +) => { + return useQuery( + configKeys.projectCreationPostgresVersions(organizationSlug, cloudProvider, dbRegion), + ({ signal }) => + getPostgresCreationVersions({ organizationSlug, cloudProvider, dbRegion }, signal), + { + enabled: enabled && typeof organizationSlug !== 'undefined', + ...options, + } + ) +} diff --git a/apps/studio/data/config/project-unpause-postgres-versions-query.ts b/apps/studio/data/config/project-unpause-postgres-versions-query.ts new file mode 100644 index 00000000000..e569fa09f3b --- /dev/null +++ b/apps/studio/data/config/project-unpause-postgres-versions-query.ts @@ -0,0 +1,59 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { get, handleError } from 'data/fetchers' +import { configKeys } from './keys' +import { components } from 'api-types' +import { ResponseError } from 'types' + +export type ProjectUnpausePostgresVersionsVariables = { + projectRef?: string +} + +export type ProjectUnpausePostgresVersion = components['schemas']['ProjectUnpauseVersionInfo'] +export type ReleaseChannel = components['schemas']['ReleaseChannel'] +export type PostgresEngine = components['schemas']['PostgresEngine'] + +export type ProjectUnpausePostgresVersionsResponse = { + available_versions: ProjectUnpausePostgresVersion[] +} + +export async function getPostgresUnpauseVersions( + { projectRef }: ProjectUnpausePostgresVersionsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/projects/{ref}/restore/versions', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + return data as ProjectUnpausePostgresVersionsResponse +} + +export type ProjectUnpausePostgresVersionData = Awaited< + ReturnType +> +export type ProjectUnpausePostgresVersionError = ResponseError + +export const useProjectUnpausePostgresVersionsQuery = ( + { projectRef }: ProjectUnpausePostgresVersionsVariables, + { + enabled = true, + ...options + }: UseQueryOptions< + ProjectUnpausePostgresVersionData, + ProjectUnpausePostgresVersionError, + TData + > = {} +) => { + return useQuery( + configKeys.projectUnpausePostgresVersions(projectRef), + ({ signal }) => getPostgresUnpauseVersions({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) +} diff --git a/apps/studio/data/config/project-upgrade-eligibility-query.ts b/apps/studio/data/config/project-upgrade-eligibility-query.ts index 34410e665bc..0da7ddc7440 100644 --- a/apps/studio/data/config/project-upgrade-eligibility-query.ts +++ b/apps/studio/data/config/project-upgrade-eligibility-query.ts @@ -8,6 +8,7 @@ import type { ResponseError } from 'types' import { configKeys } from './keys' import { components } from 'api-types' +export type ProjectUpgradeTargetVersion = components['schemas']['ProjectVersion'] export type ProjectUpgradeEligibilityVariables = { projectRef?: string } export type ProjectUpgradeEligibilityResponse = components['schemas']['ProjectUpgradeEligibilityResponse'] diff --git a/apps/studio/data/config/project-upgrade-status-query.ts b/apps/studio/data/config/project-upgrade-status-query.ts index fcaba8cba71..a4880d81502 100644 --- a/apps/studio/data/config/project-upgrade-status-query.ts +++ b/apps/studio/data/config/project-upgrade-status-query.ts @@ -7,16 +7,22 @@ import { configKeys } from './keys' export type ProjectUpgradingStatusVariables = { projectRef?: string projectStatus?: string + trackingId?: string | null } export async function getProjectUpgradingStatus( - { projectRef }: ProjectUpgradingStatusVariables, + { projectRef, trackingId }: ProjectUpgradingStatusVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') + const queryParams: Record = {} + if (trackingId) { + queryParams['tracking_id'] = trackingId + } + const { data, error } = await get(`/v1/projects/{ref}/upgrade/status`, { - params: { path: { ref: projectRef } }, + params: { path: { ref: projectRef }, query: queryParams }, signal, }) if (error) handleError(error) @@ -28,7 +34,7 @@ export type ProjectUpgradingStatusData = Awaited( - { projectRef, projectStatus }: ProjectUpgradingStatusVariables, + { projectRef, projectStatus, trackingId }: ProjectUpgradingStatusVariables, { enabled = true, ...options @@ -38,7 +44,7 @@ export const useProjectUpgradingStatusQuery = ( configKeys.upgradeStatus(projectRef), - ({ signal }) => getProjectUpgradingStatus({ projectRef }, signal), + ({ signal }) => getProjectUpgradingStatus({ projectRef, trackingId }, signal), { enabled: enabled && typeof projectRef !== 'undefined', refetchInterval(data) { diff --git a/apps/studio/data/projects/project-create-mutation.ts b/apps/studio/data/projects/project-create-mutation.ts index 5e9a11d8d3d..5859bfd5033 100644 --- a/apps/studio/data/projects/project-create-mutation.ts +++ b/apps/studio/data/projects/project-create-mutation.ts @@ -18,6 +18,8 @@ const WHITELIST_ERRORS = [ ] export type DbInstanceSize = components['schemas']['DesiredInstanceSize'] +export type ReleaseChannel = components['schemas']['ReleaseChannel'] +export type PostgresEngine = components['schemas']['PostgresEngine'] export type ProjectCreateVariables = { name: string @@ -32,6 +34,8 @@ export type ProjectCreateVariables = { dbInstanceSize?: DbInstanceSize dataApiExposedSchemas?: string[] dataApiUseApiSchema?: boolean + postgresEngine?: PostgresEngine + releaseChannel?: ReleaseChannel } export async function createProject({ @@ -46,6 +50,8 @@ export async function createProject({ dbInstanceSize, dataApiExposedSchemas, dataApiUseApiSchema, + postgresEngine, + releaseChannel, }: ProjectCreateVariables) { const body: components['schemas']['CreateProjectBody'] = { cloud_provider: cloudProvider, @@ -61,6 +67,8 @@ export async function createProject({ desired_instance_size: dbInstanceSize, data_api_exposed_schemas: dataApiExposedSchemas, data_api_use_api_schema: dataApiUseApiSchema, + postgres_engine: postgresEngine, + release_channel: releaseChannel, } const { data, error } = await post(`/platform/projects`, { diff --git a/apps/studio/data/projects/project-restore-mutation.ts b/apps/studio/data/projects/project-restore-mutation.ts index 5747e34b332..6b74010e400 100644 --- a/apps/studio/data/projects/project-restore-mutation.ts +++ b/apps/studio/data/projects/project-restore-mutation.ts @@ -3,16 +3,28 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' +import { components } from 'api-types' + +export type ReleaseChannel = components['schemas']['ReleaseChannel'] +export type PostgresEngine = components['schemas']['PostgresEngine'] export type ProjectRestoreVariables = { ref: string + postgresEngine?: PostgresEngine + releaseChannel?: ReleaseChannel } -export async function restoreProject({ ref }: ProjectRestoreVariables) { +export async function restoreProject({ + ref, + postgresEngine, + releaseChannel, +}: ProjectRestoreVariables) { const { data, error } = await post('/platform/projects/{ref}/restore', { params: { path: { ref } }, - // @ts-ignore - body: {}, + body: { + postgres_engine: postgresEngine, + release_channel: releaseChannel, + }, }) if (error) handleError(error) return data diff --git a/apps/studio/data/projects/project-upgrade-mutation.ts b/apps/studio/data/projects/project-upgrade-mutation.ts index 0cf93bb57a7..483e94fbc61 100644 --- a/apps/studio/data/projects/project-upgrade-mutation.ts +++ b/apps/studio/data/projects/project-upgrade-mutation.ts @@ -3,17 +3,24 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' +import { components } from 'api-types' + +export type ReleaseChannel = components['schemas']['ReleaseChannel'] export type ProjectUpgradeVariables = { ref: string - target_version: number + target_version: string + release_channel: ReleaseChannel } -export async function upgradeProject({ ref, target_version }: ProjectUpgradeVariables) { +export async function upgradeProject({ + ref, + target_version, + release_channel, +}: ProjectUpgradeVariables) { const { data, error } = await post('/v1/projects/{ref}/upgrade', { params: { path: { ref } }, - // @ts-expect-error release_channel param is optional on API end, but not typed properly from generated types - body: { target_version: target_version.toString() }, + body: { target_version: target_version.toString(), release_channel }, }) if (error) handleError(error) return data diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts index a4a7b3f5cfa..4f11a03dc6c 100644 --- a/apps/studio/next-env.d.ts +++ b/apps/studio/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 7399098ecf5..cf59f410810 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -17,6 +17,10 @@ import { FreeProjectLimitWarning, NotOrganizationOwnerWarning, } from 'components/interfaces/Organization/NewProject' +import { + PostgresVersionSelector, + extractPostgresVersionDetails, +} from 'components/interfaces/ProjectCreation/PostgresVersionSelector' import { RegionSelector } from 'components/interfaces/ProjectCreation/RegionSelector' import { WizardLayoutWithoutAuth } from 'components/layouts/WizardLayout' import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident' @@ -194,6 +198,7 @@ const Wizard: NextPageWithLayout = () => { const { slug, projectName } = useParams() const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') + const projectVersionSelectionDisabled = useFlag('disableProjectVersionSelection') const cloudProviderEnabled = useFlag('enableFlyCloudProvider') const { data: membersExceededLimit, isLoading: isLoadingFreeProjectLimitCheck } = useFreeProjectLimitCheckQuery({ slug }) @@ -301,6 +306,7 @@ const Wizard: NextPageWithLayout = () => { instanceSize: z.string(), dataApi: z.boolean(), useApiSchema: z.boolean(), + postgresVersionSelection: z.string(), }) .superRefine(({ dbPassStrength }, refinementContext) => { if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) { @@ -326,6 +332,7 @@ const Wizard: NextPageWithLayout = () => { instanceSize: sizes[0], dataApi: true, useApiSchema: false, + postgresVersionSelection: '', }, }) @@ -368,8 +375,12 @@ const Wizard: NextPageWithLayout = () => { instanceSize, dataApi, useApiSchema, + postgresVersionSelection, } = values + const { postgresEngine, releaseChannel } = + extractPostgresVersionDetails(postgresVersionSelection) + const data: ProjectCreateVariables = { cloudProvider: cloudProvider, organizationId: currentOrg.id, @@ -383,6 +394,8 @@ const Wizard: NextPageWithLayout = () => { orgSubscription?.plan.id === 'free' ? undefined : (instanceSize as DesiredInstanceSize), dataApiExposedSchemas: !dataApi ? [] : undefined, dataApiUseApiSchema: !dataApi ? false : useApiSchema, + postgresEngine: postgresEngine, + releaseChannel: releaseChannel, } if (postgresVersion) { if (!postgresVersion.match(/1[2-9]\..*/)) { @@ -546,30 +559,6 @@ const Wizard: NextPageWithLayout = () => { /> - {showNonProdFields && ( - - ( - - - - - - )} - /> - - )} - {cloudProviderEnabled && showNonProdFields && ( { +
Compute Size -
+

Compute Add-Ons

+ +
+

Compute Billing

+ +
+
} @@ -698,26 +698,7 @@ const Wizard: NextPageWithLayout = () => { )} /> - - Compute Billing -
- -
-

Docs

- -
- -
-
- } - layout="horizontal" - > +
Additional Monthly Compute Costs
@@ -782,7 +763,7 @@ const Wizard: NextPageWithLayout = () => { - +
Compute Credits @@ -813,7 +794,7 @@ const Wizard: NextPageWithLayout = () => {
-
+

Compute is charged usage-based whenever your billing cycle resets. Given compute charges are hourly, your invoice will @@ -913,6 +894,48 @@ const Wizard: NextPageWithLayout = () => { /> + {!projectVersionSelectionDisabled && ( + + ( + + )} + /> + + )} + + {showNonProdFields && ( + + ( + + + + + + )} + /> + + )} + From 79f51f30a133d730cfe9880b12ac66140fc0e055 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:28:00 +0200 Subject: [PATCH 002/255] add info box to let users know they can send an email to support (#29780) * add info box to let users know they can send an email to support * make better * move projects to tooltip * truncate longer proj names * fix things properly * rm always open * bit more padding * add mailto link * Add font-mono to project reference span * more padding, fix bg hover --- apps/studio/components/ui/CopyButton.tsx | 8 ++- apps/studio/pages/support/new.tsx | 73 +++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx index 926f9ce100e..e421901a61e 100644 --- a/apps/studio/components/ui/CopyButton.tsx +++ b/apps/studio/components/ui/CopyButton.tsx @@ -2,7 +2,7 @@ import { Check, Clipboard } from 'lucide-react' import { useEffect, useState } from 'react' import { copyToClipboard } from 'lib/helpers' -import { Button, ButtonProps } from 'ui' +import { Button, ButtonProps, cn } from 'ui' export interface CopyButtonProps extends ButtonProps { text: string @@ -35,6 +35,12 @@ const CopyButton = ({ onClick?.(e) }} {...props} + className={cn( + { + 'px-1': iconOnly, + }, + props.className + )} icon={ showCopied ? : props.icon ?? } diff --git a/apps/studio/pages/support/new.tsx b/apps/studio/pages/support/new.tsx index 1d933164c76..695d51e8e8e 100644 --- a/apps/studio/pages/support/new.tsx +++ b/apps/studio/pages/support/new.tsx @@ -1,4 +1,4 @@ -import { Loader2, Wrench } from 'lucide-react' +import { ClipboardIcon, Loader2, Wrench } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import SVG from 'react-inlinesvg' @@ -10,12 +10,19 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { withAuth } from 'hooks/misc/withAuth' import { BASE_PATH } from 'lib/constants' import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' +import InformationBox from 'components/ui/InformationBox' +import { toast } from 'sonner' +import { useRouter } from 'next/router' +import { Tooltip, TooltipContent, TooltipTrigger } from '@ui/components/shadcn/ui/tooltip' +import CopyButton from 'components/ui/CopyButton' const SupportPage = () => { const [sentCategory, setSentCategory] = useState() const [selectedProject, setSelectedProject] = useState('no-project') const { data, isLoading } = usePlatformStatusQuery() const isHealthy = data?.isHealthy + const router = useRouter() + const { ref } = router.query const { data: projectsData, isLoading: isLoadingProjects } = useProjectsQuery() @@ -89,6 +96,70 @@ const SupportPage = () => { /> )}

+ + +

+ Email us directly at{' '} + + support@supabase.com + + { + toast.success('Copied to clipboard') + }} + /> +

+

+ Please, make sure to{' '} + + + include your project ID + + +

    +
  • + Project name + ID +
  • + {projectsData?.map((project) => ( +
  • + +
  • + ))} +
+ + {' '} + and as much information as possible. +

+
+ } + defaultVisibility={true} + hideCollapse={true} + />
From 9b217a104ef3bf38db8861d6d191c41eff486dc0 Mon Sep 17 00:00:00 2001 From: Paul Cioanca Date: Fri, 11 Oct 2024 14:50:28 +0300 Subject: [PATCH 003/255] fix: button text when unpausing project (#29838) --- .../layouts/ProjectLayout/PausedState/ProjectPausedState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index ce40709d502..9c2e528eb2a 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -366,7 +366,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { Cancel From c8ed6e4cc1d188a68d9007c7523d9db9730404b5 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:02:00 -0400 Subject: [PATCH 004/255] feat(db, docs): prep to sync troubleshooting entries to github (#29824) Add JSON schema validation for troubleshooting entries, and add columns for storing GitHub discussion metadata. Also some minor UI tweaks for troubleshooting display. --- apps/docs/app/guides/troubleshooting/page.tsx | 2 +- .../features/docs/Troubleshooting.page.tsx | 11 ++- .../docs/features/docs/Troubleshooting.ui.tsx | 2 +- ...41002215612_troubleshooting_validation.sql | 74 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20241002215612_troubleshooting_validation.sql diff --git a/apps/docs/app/guides/troubleshooting/page.tsx b/apps/docs/app/guides/troubleshooting/page.tsx index e5e96fe66df..587e84fa1ae 100644 --- a/apps/docs/app/guides/troubleshooting/page.tsx +++ b/apps/docs/app/guides/troubleshooting/page.tsx @@ -23,7 +23,7 @@ export default async function GlobalTroubleshootingPage() { const errors = await getAllTroubleshootingErrors() return ( - +

Troubleshooting

diff --git a/apps/docs/features/docs/Troubleshooting.page.tsx b/apps/docs/features/docs/Troubleshooting.page.tsx index a7032716e1a..5acc242328c 100644 --- a/apps/docs/features/docs/Troubleshooting.page.tsx +++ b/apps/docs/features/docs/Troubleshooting.page.tsx @@ -10,12 +10,19 @@ import { formatError, serializeTroubleshootingSearchParams } from './Troubleshoo export default function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) { return ( - +

{entry.data.title}

- {entry.data.updated_at &&

Last edited: {entry.data.updated_at.toLocaleString()}

} + {entry.data.updated_at && ( +

+ Last edited: {entry.data.updated_at.toLocaleString()} +

+ )}
diff --git a/apps/docs/features/docs/Troubleshooting.ui.tsx b/apps/docs/features/docs/Troubleshooting.ui.tsx index 9c7502f74f6..06ca618cff2 100644 --- a/apps/docs/features/docs/Troubleshooting.ui.tsx +++ b/apps/docs/features/docs/Troubleshooting.ui.tsx @@ -26,7 +26,7 @@ export function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry {...attributes} >
-
+
Date: Fri, 11 Oct 2024 17:38:52 +0200 Subject: [PATCH 005/255] mongodb blog post (#29795) * mongodb blog post * learn how * add form link --- ...odb-realm-and-device-sync-alternatives.mdx | 72 ++++++++++++++++++ .../mongodb-og-realtime.png | Bin 0 -> 15229 bytes apps/www/public/rss.xml | 9 ++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apps/www/_blog/2024-10-09-mongodb-realm-and-device-sync-alternatives.mdx create mode 100644 apps/www/public/images/blog/2024-10-09-mongodb-realm-alternatives/mongodb-og-realtime.png diff --git a/apps/www/_blog/2024-10-09-mongodb-realm-and-device-sync-alternatives.mdx b/apps/www/_blog/2024-10-09-mongodb-realm-and-device-sync-alternatives.mdx new file mode 100644 index 00000000000..d1610fb0795 --- /dev/null +++ b/apps/www/_blog/2024-10-09-mongodb-realm-and-device-sync-alternatives.mdx @@ -0,0 +1,72 @@ +--- +title: 'MongoDB Realm & Device Sync Alternatives - Supabase' +description: Learn how Supabase can help you transition from MongoDB Realm and Device Sync. +author: craig_cannon +image: 2024-10-09-mongodb-realm-alternatives/mongodb-og-realtime.png +thumb: 2024-10-09-mongodb-realm-alternatives/mongodb-og-realtime.png +categories: + - developers +tags: + - mobile + - local-first + - realtime +date: '2024-10-09' +toc_depth: 2 +--- + +With the recent announcement of [MongoDB Realm’s deprecation](https://www.mongodb.com/community/forums/t/device-sync-and-edge-server-are-deprecated/296035), developers are looking for alternatives that can support offline-first and real-time sync capabilities in their apps. This post explores some of the tools and integrations available within Supabase that can help you transitioning from MongoDB Realm. + +--- + +## 1. Legend-State: A Local-First State Management Solution + +Legend-State is a library to make offline-first state management easy and reliable. It ensures seamless data synchronization by adopting a **local-first approach**, allowing users to work offline without losing data when they reconnect. + +- **Guide**: [Legend-State Guide](https://supabase.com/blog/local-first-expo-legend-state) +- **Video**: [Community Video](https://supabase.link/local-first-expo-legend-state-yt) +- **Demo**: [Demo Repository](https://supabase.link/local-first-expo-gh) + +With Legend-State, developers can integrate [**real-time data sync**](https://supabase.com/realtime) while maintaining performance and consistency during network outages—something previously handled by MongoDB Realm. + +--- + +## 2. WatermelonDB: A High-Performance Offline Database + +For React Native apps, **WatermelonDB** is an excellent solution for handling large-scale offline data. This database ensures your app runs smoothly, thanks to its fast synchronization and ability to handle complex queries. + +- **Guide**: [WatermelonDB for React Native](https://supabase.com/blog/react-native-offline-first-watermelon-db) +- **Community Video**: [WatermelonDB Integration](https://youtu.be/kUlt27KmHDc?si=zoezkb0vSXEqU7Wl) +- **Demo**: [Repository](https://github.com/bndkt/sharemystack/) + +--- + +## 3. PowerSync: Offline-First Supabase Integration + +PowerSync is another solution with **offline-first** capabilities. It adds real-time synchronization while handling data conflicts intelligently. + +- **Guide**: [Offline-First with PowerSync](https://www.powersync.com/blog/bringing-offline-first-to-supabase) +- **Community Video**: [PowerSync Overview](https://youtu.be/xvvVGOyRgZg?si=QXpA37LWlrPa5-Fr) +- **Partner Gallery**: [Supabase & PowerSync](https://supabase.com/partners/integrations/powersync) + +--- + +## 4. Replicache: Real-Time Data Sync + +Replicache enhances Supabase’s real-time syncing capabilities, enabling **low-latency synchronization** of offline-first data. + +- **Demo**: [Replicache with Supabase](https://github.com/onsetsoftware/replicache-supabase-edge-function) +- **Next.js Starter**: [Replicache Starter](https://vercel.com/templates/next.js/replicache-starter) +- **Partner Gallery**: [Replicache & Supabase](https://supabase.com/partners/integrations/replicache) + +With Replicache, you can easily add **real-time syncing** to your Supabase applications, ensuring **high-performance data management** even when offline. + +--- + +## 5. ElectricSQL: Simplifying Sync for Offline-First Applications + +ElectricSQL adds automatic **sync** to any database, including Supabase. It's a powerful tool for building offline-first applications with automatic conflict handling and multi-user collaboration. + +- **Partner Gallery**: [ElectricSQL & Supabase](https://supabase.com/partners/integrations/electricsql) +- **Interview**: [Interview with ElectricSQL](https://youtu.be/2wlXszWz_Uc?si=KJZkMZyz_zbn8yyh) + +If you would like to investigate any of these solutions further, please fill out [this form](https://share.hsforms.com/1cFANf1dYTQi_YcCGQI_CHgbvo3m) to request a meeting with our Growth team. We’ll work with you to ensure your applications continue to run seamlessly. diff --git a/apps/www/public/images/blog/2024-10-09-mongodb-realm-alternatives/mongodb-og-realtime.png b/apps/www/public/images/blog/2024-10-09-mongodb-realm-alternatives/mongodb-og-realtime.png new file mode 100644 index 0000000000000000000000000000000000000000..d5a2482f7682b94f302fc1219444a9114e2413d5 GIT binary patch literal 15229 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 J6}BM#e*rfssX_n% literal 0 HcmV?d00001 diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 880a0f41454..abfa6cb2cf3 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -5,9 +5,16 @@ https://supabase.com Latest news from Supabase en - Mon, 07 Oct 2024 22:00:00 GMT + Tue, 08 Oct 2024 22:00:00 GMT + https://supabase.com/blog/mongodb-realm-and-device-sync-alternatives + MongoDB Realm & Device Sync Alternatives - Supabase + https://supabase.com/blog/mongodb-realm-and-device-sync-alternatives + Learn how Supabase can help you transition from MongoDB Realm and Device Sync. + Tue, 08 Oct 2024 22:00:00 GMT + + https://supabase.com/blog/offline-first-flutter-apps Building offline-first mobile apps with Supabase, Flutter and Brick https://supabase.com/blog/offline-first-flutter-apps From 1236406e881d6a3503245f2742fdd65a12fc0bc7 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:51:51 -0400 Subject: [PATCH 006/255] fix(docs): ref scroll (#29819) Fix various scrolling bugs with reference pages: - Take out smooth scrolling as it tends to lead to buggy scrolling position - Add overscroll-behavior: contain to the nav, otherwise overscrolling on the nav scrolls the main page, which causes the nav to jump around unexpectedly - Put aria-current calculation in useEffect as otherwise it doesn't trigger properly on first load --- .../docs/Reference.navigation.client.tsx | 64 ++++++++++++++----- apps/docs/features/docs/Reference.ui.tsx | 3 +- apps/docs/layouts/MainSkeleton.tsx | 1 + 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/apps/docs/features/docs/Reference.navigation.client.tsx b/apps/docs/features/docs/Reference.navigation.client.tsx index a9fd14775fd..4ef50c32ff9 100644 --- a/apps/docs/features/docs/Reference.navigation.client.tsx +++ b/apps/docs/features/docs/Reference.navigation.client.tsx @@ -11,9 +11,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { cn } from 'ui' -import { BASE_PATH } from '~/lib/constants' import type { AbbrevApiReferenceSection } from '~/features/docs/Reference.utils' import { isElementInViewport } from '~/features/ui/helpers.dom' +import { BASE_PATH } from '~/lib/constants' export const ReferenceContentInitiallyScrolledContext = createContext(false) @@ -27,27 +27,27 @@ export function ReferenceContentScrollHandler({ version: string isLatestVersion: boolean }>) { - const checkedPathnameOnLoad = useRef(false) const [initiallyScrolled, setInitiallyScrolled] = useState(false) const pathname = usePathname() useEffect(() => { - if (!checkedPathnameOnLoad.current) { + if (!initiallyScrolled) { const initialSelectedSection = pathname.replace( `/reference/${libPath}/${isLatestVersion ? '' : `${version}/`}`, '' ) if (initialSelectedSection) { const section = document.getElementById(initialSelectedSection) - section?.scrollIntoView() - section?.querySelector('h2')?.focus() + if (section) { + window.scrollTo(0, section.offsetTop - 60 /* space for header + padding */) + section.querySelector('h2')?.focus() + } } - checkedPathnameOnLoad.current = true setInitiallyScrolled(true) } - }, [pathname, libPath, version, isLatestVersion]) + }, [pathname, libPath, version, isLatestVersion, initiallyScrolled]) return ( @@ -60,14 +60,33 @@ export function ReferenceNavigationScrollHandler({ children, ...rest }: PropsWithChildren & HTMLAttributes) { + const parentRef = useRef() const ref = useRef() const initialScrollHappened = useContext(ReferenceContentInitiallyScrolledContext) + useEffect(() => { + if (!ref.current) return + + let scrollingParent: HTMLElement = ref.current + + while (scrollingParent && !(scrollingParent.scrollHeight > scrollingParent.clientHeight)) { + scrollingParent = scrollingParent.parentElement + } + + parentRef.current = scrollingParent + }, []) + const scrollActiveIntoView = useCallback(() => { const currentLink = ref.current?.querySelector('[aria-current=page]') as HTMLElement if (currentLink && !isElementInViewport(currentLink)) { - currentLink.scrollIntoView({ - block: 'center', + // Calculate the offset of the current link relative to scrollingParent + // and scroll the parent to the top of the link. + const offsetTop = currentLink.offsetTop + const parentOffsetTop = parentRef.current?.offsetTop ?? 0 + const scrollPosition = offsetTop - parentOffsetTop + + parentRef.current?.scrollTo({ + top: scrollPosition - 60 /* space for header + padding */, }) } }, []) @@ -122,11 +141,8 @@ function createReferenceSubsectionNavigator(href: string, sectionSlug?: string) evt.preventDefault() history.pushState({}, '', `${BASE_PATH}${href}`) - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const domElement = document.getElementById(sectionSlug) - domElement?.scrollIntoView({ - behavior: reduceMotion ? 'auto' : 'smooth', - }) + domElement?.scrollIntoView() domElement?.querySelector('h2')?.focus() } } @@ -141,8 +157,13 @@ export function RefInternalLink({ sectionSlug?: string children: React.ReactNode }) { + const onClick = useCallback( + (evt: MouseEvent) => createReferenceSubsectionNavigator(href, sectionSlug)(evt), + [href, sectionSlug] + ) + return ( - + {children} ) @@ -166,6 +187,18 @@ export function RefLink({ const isActive = pathname === href || (pathname === basePath && href.replace(basePath, '') === '/introduction') + useEffect(() => { + if (ref.current) { + ref.current.ariaCurrent = isActive ? 'page' : undefined + ref.current.className = getLinkStyles(isActive, className) + } + }, [isActive, className]) + + const onClick = useCallback( + (evt: MouseEvent) => createReferenceSubsectionNavigator(href, section.slug)(evt), + [href, section.slug] + ) + if (!('title' in section)) return null const isCompoundSection = !skipChildren && 'items' in section && section.items.length > 0 @@ -181,9 +214,8 @@ export function RefLink({ // prefetching just wastes bandwidth prefetch={false} href={href} - aria-current={isActive ? 'page' : false} className={getLinkStyles(isActive, className)} - onClick={createReferenceSubsectionNavigator(href, section.slug)} + onClick={onClick} > {section.title} diff --git a/apps/docs/features/docs/Reference.ui.tsx b/apps/docs/features/docs/Reference.ui.tsx index a27fc1a4013..93d4c18f588 100644 --- a/apps/docs/features/docs/Reference.ui.tsx +++ b/apps/docs/features/docs/Reference.ui.tsx @@ -100,7 +100,7 @@ export function StickyHeader({ title, monoFont = false, className }: StickyHeade

Date: Fri, 11 Oct 2024 18:15:35 +0200 Subject: [PATCH 007/255] chore: remove bruno from humans.txt (#29845) --- apps/docs/public/humans.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 5a5c22a7372..fa2f4f9dad2 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -13,7 +13,6 @@ Ariuna K Beng Eu Bo Lu Bobbie Soedirgo -Bruno Andrade Carel de Waal Chandana Anumula Charis Lam From 51fa38988ec9e75d85903bb95fac57d8850f4157 Mon Sep 17 00:00:00 2001 From: Jan Tennert Date: Fri, 11 Oct 2024 18:24:10 +0200 Subject: [PATCH 008/255] docs: Update Kotlin OTP link verification docs (#29839) Update OTP link verification section --- apps/docs/spec/supabase_kt_v3.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/spec/supabase_kt_v3.yml b/apps/docs/spec/supabase_kt_v3.yml index 25bafd81cc5..9689c733da2 100644 --- a/apps/docs/spec/supabase_kt_v3.yml +++ b/apps/docs/spec/supabase_kt_v3.yml @@ -30,14 +30,14 @@ functions: [supabase-kt](https://github.com/supabase-community/supabase-kt) provides several platform implementations for OAuth and OTP link verification. - **On Desktop platforms (JVM, MacOS\*, Linux)**, it uses a HTTP Callback Server to receive the session data from a successful OAuth login. The success page can be customized via `AuthConfig#httpCallbackConfig` \ + **On Desktop platforms (JVM, MacOS\*, Linux, Windows)**, it uses a HTTP Callback Server to receive the session data from a successful OAuth login. The success page can be customized via `AuthConfig#httpCallbackConfig` \ \* If no deeplinks are being used. *Note: OTP link verification such as sign ups are not supported on JVM. You may have to send a verification token rather than a url in your email. To send the token, rather than a redirect url, change `{{ .ConfirmationURL }}` in your sign up email to `{{ .Token }}`* **On Android, iOS & MacOS**, OAuth and OTP verification use deeplinks. Refer to the guide below on how to setup deeplinks. Alternatively you can use [Native Google Auth](/docs/guides/auth/social-login/auth-google?platform=android). \ **On JS**, it uses the website origin as the callback url. Session importing gets handled automatically. \ - **Windows, tvOS, watchOS & Linux** currently have no default implementation. Feel free to create a PR. + **tvOS & watchOS** currently have no default implementation. Feel free to create a PR. You always make your own implementation and use `auth.parseSessionFromFragment(fragment)` or `auth.parseSessionFromUrl(url)` to let [supabase-kt](https://github.com/supabase-community/supabase-kt) handle the parsing after receiving a callback. Then you can simply use `auth.importSession(session)`. From aedff21d81a384154bd0794a027c69d7c1aa6e17 Mon Sep 17 00:00:00 2001 From: Rodrigo Mansueli Date: Fri, 11 Oct 2024 13:50:09 -0300 Subject: [PATCH 009/255] feat: improve timeouts for webhooks (#29066) --- .../components/interfaces/Database/Hooks/EditHookPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx index 50ea982502c..7ba9cff0fbb 100644 --- a/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -92,7 +92,7 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '') ? 'supabase_function' : 'http_request', - timeout_ms: Number(selectedHook?.function_args?.[4] ?? 1000), + timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000), } useEffect(() => { @@ -165,8 +165,8 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) = } } - if (values.timeout_ms < 1000 || values.timeout_ms > 5000) { - errors['timeout_ms'] = 'Timeout should be between 1000ms and 5000ms' + if (values.timeout_ms < 1000 || values.timeout_ms > 10_000) { + errors['timeout_ms'] = 'Timeout should be between 1000ms and 10,000ms' } if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true) From d7bfa1078930d7400956832c00286784869e8ad2 Mon Sep 17 00:00:00 2001 From: t6t <100683882+t6tv8e@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:26:47 +0200 Subject: [PATCH 010/255] Docs // Typo fix // seeding-your-database.mdx (#29822) Typo fix // Update seeding-your-database.mdx --- .../content/guides/local-development/seeding-your-database.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/local-development/seeding-your-database.mdx b/apps/docs/content/guides/local-development/seeding-your-database.mdx index efb8e47ea26..ac10caeda56 100644 --- a/apps/docs/content/guides/local-development/seeding-your-database.mdx +++ b/apps/docs/content/guides/local-development/seeding-your-database.mdx @@ -50,7 +50,7 @@ sql_paths = ['./seeds/*.sql'] The CLI processes seed files in the order they are declared in the `sql_paths` array. If a glob pattern is used and matches multiple files, those files are sorted in lexicographic order to ensure consistent execution. Additionally: -- The base folder for the pattern matching is `supabase` so `./contries.sql` will search for `supabase/contries.sql` +- The base folder for the pattern matching is `supabase` so `./countries.sql` will search for `supabase/countries.sql` - Files matched by multiple patterns will be deduplicated to prevent redundant seeding. - If a pattern does not match any files, a warning will be logged to help you troubleshoot potential configuration issues. From bf5777abccc8b8644962385aa07b3764a6c5ed32 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Fri, 11 Oct 2024 20:54:30 +0200 Subject: [PATCH 011/255] fix: update curl snippet for invite user (#29851) fix: update snippet --- apps/studio/components/interfaces/Docs/Snippets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Docs/Snippets.ts b/apps/studio/components/interfaces/Docs/Snippets.ts index de2792c4692..d72a54af4ec 100644 --- a/apps/studio/components/interfaces/Docs/Snippets.ts +++ b/apps/studio/components/interfaces/Docs/Snippets.ts @@ -637,7 +637,7 @@ let { data, error } = await supabase.auth.verifyOtp({ code: ` curl -X POST '${endpoint}/auth/v1/invite' \\ -H "apikey: ${apiKey}" \\ --H "Authorization: Bearer USER_TOKEN" \\ +-H "Authorization: Bearer SERVICE_ROLE_KEY" \\ -H "Content-Type: application/json" \\ -d '{ "email": "someone@email.com" From f9cd14c2a808d0347865dd676b12806301e1a173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Sat, 12 Oct 2024 15:52:09 +0800 Subject: [PATCH 012/255] chore: remove disk exceeding global header (#29858) Temporarily remove until we have better usage insights - we still show overages on the usage page, just not the aggressive top level banner --- .../components/ui/OveragesBanner/OveragesBanner.utils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts b/apps/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts index 0a33a6342ce..c67a16f7958 100644 --- a/apps/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts +++ b/apps/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts @@ -5,8 +5,14 @@ export const getResourcesExceededLimitsOrg = (usageMetrics: OrgMetricsUsage[]): return usageMetrics .filter((usageMetric) => { - if (!usageMetric.capped || !usageMetric.available_in_plan || usageMetric.unlimited) + if ( + !usageMetric.capped || + !usageMetric.available_in_plan || + usageMetric.unlimited || + usageMetric.metric === 'DISK_IOPS_GP3' + ) { return false + } const freeUnits = usageMetric.pricing_free_units || 0 From 1ec43e69862f7d5e9f1f38c5753db8ba607af601 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 13 Oct 2024 21:03:38 -0500 Subject: [PATCH 013/255] Update webhook label to new maximum of 10000ms (#29861) * Update webhook label to new maximum of 10000ms * Match formatting of validation label --- .../components/interfaces/Database/Hooks/HTTPRequestFields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index 63bce025315..53103b49630 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -115,7 +115,7 @@ const HTTPRequestFields = ({ id="timeout_ms" name="timeout_ms" label="Timeout" - labelOptional="Between 1000ms to 5000ms" + labelOptional="Between 1000ms to 10,000ms" type="number" actions={

ms

} /> From 353d0546b66691f4eef146c3b1484393b482b594 Mon Sep 17 00:00:00 2001 From: Hunter Stevens Date: Sun, 13 Oct 2024 21:16:02 -0500 Subject: [PATCH 014/255] fix: typo in General.tsx (#29487) --- apps/studio/components/interfaces/Settings/General/General.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index 92082bf992d..21f5a6bd28f 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -214,7 +214,7 @@ const General = () => {
-

Project usage statistics has been moved

+

Project usage statistics have been moved

You may view your project's usage under your organization's settings

From d916261f05eec343245186e701bd45bd9bc0b294 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Mon, 14 Oct 2024 14:05:10 +0800 Subject: [PATCH 015/255] chore/ fix-www-blog-cat-build-issue (#29871) rename cats --- apps/www/_blog/2024-08-16-vec2pg.mdx | 2 +- .../2024-08-21-mozilla-llamafile-in-supabase-edge-functions.mdx | 2 +- apps/www/_blog/2024-08-29-in-browser-semantic-search-pglite.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/www/_blog/2024-08-16-vec2pg.mdx b/apps/www/_blog/2024-08-16-vec2pg.mdx index 011dd6c426b..d77c59c9424 100644 --- a/apps/www/_blog/2024-08-16-vec2pg.mdx +++ b/apps/www/_blog/2024-08-16-vec2pg.mdx @@ -13,7 +13,7 @@ tags: - launch-week - release-notes - tech - - ai + - AI date: '2024-08-16' toc_depth: 3 --- diff --git a/apps/www/_blog/2024-08-21-mozilla-llamafile-in-supabase-edge-functions.mdx b/apps/www/_blog/2024-08-21-mozilla-llamafile-in-supabase-edge-functions.mdx index 761d8817643..d0d7080571c 100644 --- a/apps/www/_blog/2024-08-21-mozilla-llamafile-in-supabase-edge-functions.mdx +++ b/apps/www/_blog/2024-08-21-mozilla-llamafile-in-supabase-edge-functions.mdx @@ -8,7 +8,7 @@ categories: - product tags: - functions - - ai + - AI date: '2024-08-21' toc_depth: 3 --- diff --git a/apps/www/_blog/2024-08-29-in-browser-semantic-search-pglite.mdx b/apps/www/_blog/2024-08-29-in-browser-semantic-search-pglite.mdx index 25bbb57ce64..fcdf867cab2 100644 --- a/apps/www/_blog/2024-08-29-in-browser-semantic-search-pglite.mdx +++ b/apps/www/_blog/2024-08-29-in-browser-semantic-search-pglite.mdx @@ -8,7 +8,7 @@ categories: - developers tags: - pglite - - ai + - AI date: '2024-08-29' toc_depth: 3 --- From b64999e2876ee88ca6ed2e39f87f7e2dc694774f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Mon, 14 Oct 2024 14:48:45 +0800 Subject: [PATCH 016/255] feat: new disk size usage section (#29862) --- .../BillingBreakdown.constants.ts | 4 +- .../BillingBreakdown/BillingMetric.tsx | 2 +- .../BillingSettings/Restriction.tsx | 6 +- .../Organization/Usage/Usage.constants.tsx | 152 +++++++----- .../interfaces/Organization/Usage/Usage.tsx | 19 +- .../Usage/UsageSection/AttributeUsage.tsx | 2 +- .../Usage/UsageSection/DiskUsage.tsx | 232 ++++++++++++++++++ .../Usage/UsageSection/UsageSection.tsx | 47 ++-- apps/studio/data/projects/keys.ts | 2 + apps/studio/data/projects/org-projects.ts | 43 ++++ packages/api-types/types/api.d.ts | 85 +++++++ 11 files changed, 499 insertions(+), 95 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx create mode 100644 apps/studio/data/projects/org-projects.ts diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.constants.ts index 5bd920c7e59..b4e6309f826 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.constants.ts @@ -95,10 +95,11 @@ export const BILLING_BREAKDOWN_METRICS: Metric[] = [ { key: PricingMetric.DISK_SIZE_GB_HOURS_GP3, name: 'Disk Size', + anchor: 'diskSize', units: 'absolute', unitName: 'GB-Hrs', category: 'Database', - tip: 'Each project gets provisioned with 8 GB of GP3 disk for free. When you get close to the disk size limit, we autoscale your disk by 1.5x. Each GB of provisioned disk size beyond 8 GB incurs a GB-Hr every hour. Each extra GB is billed at $0.125/month, prorated down to the hour.', + tip: 'Each project gets provisioned with 8 GB of GP3 disk for free. When you get close to the disk size limit, we autoscale your disk by 1.5x. Each GB of provisioned disk size beyond 8 GB incurs a GB-Hr every hour. Each extra GB is billed at $0.125/month ($0.000171/GB-Hr), prorated down to the hour.', docLink: { title: 'Read more', url: 'https://supabase.com/docs/guides/platform/org-based-billing#disk-size', @@ -107,6 +108,7 @@ export const BILLING_BREAKDOWN_METRICS: Metric[] = [ { key: PricingMetric.DISK_SIZE_GB_HOURS_IO2, name: 'Disk Size IO2', + anchor: 'diskSize', units: 'absolute', unitName: 'GB-Hrs', category: 'Database', diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx index 449012f73b6..476370e1f9f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx @@ -174,7 +174,7 @@ const BillingMetric = ({ {subscription.usage_billing_enabled === false && relativeToSubscription && (isApproachingLimit || isExceededLimit) && ( -
+

Exceeding your plans included usage will lead to restrictions to your project. Upgrade to a usage-based plan or disable the spend cap to avoid restrictions. diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index fa29bb8eaf5..4d5dd46d636 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -7,6 +7,7 @@ import { useOrgUsageQuery } from 'data/usage/org-usage-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { CriticalIcon, WarningIcon } from 'ui' +import { PricingMetric } from 'data/analytics/org-daily-stats-query' export const Restriction = () => { const org = useSelectedOrganization() @@ -18,7 +19,10 @@ export const Restriction = () => { const hasExceededAnyLimits = Boolean( usage?.usages.find( (metric) => - !metric.unlimited && metric.capped && metric.usage > (metric?.pricing_free_units ?? 0) + metric.metric !== PricingMetric.DISK_SIZE_GB_HOURS_GP3 && + !metric.unlimited && + metric.capped && + metric.usage > (metric?.pricing_free_units ?? 0) ) ) diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx index 3cfb4956142..aebafdd48a3 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.constants.tsx @@ -100,73 +100,93 @@ export const USAGE_CATEGORIES: (subscription?: OrgSubscription) => CategoryMeta[ name: 'Database & Storage Size', description: 'Amount of resources your project is consuming', attributes: [ - { - anchor: 'dbSize', - key: PricingMetric.DATABASE_SIZE, - attributes: [{ key: PricingMetric.DATABASE_SIZE.toLowerCase(), color: 'white' }], - name: 'Database size', - chartPrefix: 'Average', - unit: 'bytes', - description: - subscription?.usage_based_billing_project_addons === true - ? 'Database size refers to the monthly average database space usage, as reported by Postgres. Paid Plans use auto-scaling disks and are billed based on provisioned disk size, rather than database space used.' - : 'Database size refers to the monthly average database space usage, as reported by Postgres. Paid Plans use auto-scaling disks.\nBilling is based on the average daily database size used in GB throughout the billing period. Billing is independent of the provisioned disk size.', - links: [ - { - name: 'Documentation', - url: 'https://supabase.com/docs/guides/platform/database-size', + subscription?.plan.id === 'free' || subscription?.usage_based_billing_project_addons !== true + ? { + anchor: 'dbSize', + key: PricingMetric.DATABASE_SIZE, + attributes: [{ key: PricingMetric.DATABASE_SIZE.toLowerCase(), color: 'white' }], + name: 'Database size', + chartPrefix: 'Average', + unit: 'bytes', + description: + 'Database size refers to the monthly average database space usage, as reported by Postgres. Paid Plans use auto-scaling disks.\nBilling is based on the average daily database size used in GB throughout the billing period. Billing is independent of the provisioned disk size.', + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/database-size', + }, + ...(subscription?.usage_based_billing_project_addons === true + ? [ + { + name: 'Disk Management', + url: 'https://supabase.com/docs/guides/platform/database-size#disk-management', + }, + ] + : []), + ], + chartDescription: 'The data refreshes every 24 hours.', + additionalInfo: (usage?: OrgUsageResponse) => { + const usageMeta = usage?.usages.find((x) => x.metric === PricingMetric.DATABASE_SIZE) + const usageRatio = + typeof usageMeta !== 'number' + ? (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) + : 0 + const hasLimit = usageMeta && (usageMeta?.pricing_free_units ?? 0) > 0 + + const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD + const isExceededLimit = hasLimit && usageRatio >= 1 + const isCapped = usageMeta?.capped + + const onFreePlan = subscription?.plan?.name === 'Free' + + return ( +

+ {(isApproachingLimit || isExceededLimit) && isCapped && ( + +
+
+ When you reach your database size limit, your project can go into + read-only mode.{' '} + {onFreePlan + ? 'Please upgrade your Plan.' + : "Disable your spend cap to scale seamlessly, and pay for over-usage beyond your Plan's quota."} +
+
+
+ )} +
+ ) + }, + } + : { + anchor: 'diskSize', + key: 'diskSize', + attributes: [], + name: 'Disk size', + chartPrefix: 'Average', + unit: 'bytes', + description: + "Each Supabase project comes with a dedicated disk. Each project gets 8 GB of disk for free. Billing is based on the provisioned disk size. Disk automatically scales up when you get close to it's size.\nEach hour your project is using more than 8 GB of GP3 disk, it incurs the overages in GB-Hrs, i.e. a 16 GB disk incurs 8 GB-Hrs every hour. Extra disk size costs $0.125/GB/month ($0.000171/GB-Hr).", + links: [ + { + name: 'Documentation', + url: 'https://supabase.com/docs/guides/platform/org-based-billing#disk-size', + }, + { + name: 'Disk Management', + url: 'https://supabase.com/docs/guides/platform/database-size#disk-management', + }, + ], + chartDescription: '', }, - ...(subscription?.usage_based_billing_project_addons === true - ? [ - { - name: 'Disk Management', - url: 'https://supabase.com/docs/guides/platform/database-size#disk-management', - }, - ] - : []), - ], - chartDescription: 'The data refreshes every 24 hours.', - additionalInfo: (usage?: OrgUsageResponse) => { - const usageMeta = usage?.usages.find((x) => x.metric === PricingMetric.DATABASE_SIZE) - const usageRatio = - typeof usageMeta !== 'number' - ? (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) - : 0 - const hasLimit = usageMeta && (usageMeta?.pricing_free_units ?? 0) > 0 - - const isApproachingLimit = hasLimit && usageRatio >= USAGE_APPROACHING_THRESHOLD - const isExceededLimit = hasLimit && usageRatio >= 1 - const isCapped = usageMeta?.capped - - const onFreePlan = subscription?.plan?.name === 'Free' - - return ( -
- {(isApproachingLimit || isExceededLimit) && isCapped && ( - -
-
- When you reach your database size limit, your project can go into read-only - mode.{' '} - {onFreePlan - ? 'Please upgrade your Plan.' - : 'Disable your spend cap to scale seamlessly and pay for over-usage beyond your Plans quota.'} -
-
-
- )} -
- ) - }, - }, { anchor: 'storageSize', key: PricingMetric.STORAGE_SIZE, diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index c07f5abd380..f8b780933a0 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -57,20 +57,23 @@ const Usage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectRef, isSuccess]) - const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() - const billingCycleEnd = dayjs.unix(subscription?.current_period_end ?? 0).utc() + const billingCycleStart = useMemo(() => { + return dayjs.unix(subscription?.current_period_start ?? 0).utc() + }, [subscription]) + + const billingCycleEnd = useMemo(() => { + return dayjs.unix(subscription?.current_period_end ?? 0).utc() + }, [subscription]) const currentBillingCycleSelected = useMemo(() => { // Selected by default - if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true - - const { current_period_start, current_period_end } = subscription + if (!dateRange?.period_start || !dateRange?.period_end) return true return ( - dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) && - dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000)) + dayjs(dateRange.period_start.date).isSame(billingCycleStart) && + dayjs(dateRange.period_end.date).isSame(billingCycleEnd) ) - }, [dateRange, subscription]) + }, [dateRange, billingCycleStart, billingCycleEnd]) const startDate = useMemo(() => { // If end date is in future, set end date to now diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index aacb86d1c55..de7799d0f49 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -174,7 +174,7 @@ const AttributeUsage = ({ {usageMeta && (

- Included in {subscription?.plan?.name.toLowerCase()} plan + Included in {subscription?.plan?.name} Plan

{usageMeta.unlimited ? (

Unlimited

diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx new file mode 100644 index 00000000000..b88e217c13c --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -0,0 +1,232 @@ +import AlertError from 'components/ui/AlertError' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import type { OrgSubscription } from 'data/subscriptions/types' +import SectionContent from '../SectionContent' +import { CategoryAttribute } from '../Usage.constants' +import { useOrgProjectsQuery } from 'data/projects/org-projects' +import { PROJECT_STATUS } from 'lib/constants' +import { + Button, + Alert_Shadcn_, + CriticalIcon, + AlertTitle_Shadcn_, + AlertDescription_Shadcn_, +} from 'ui' +import MotionNumber from 'motion-number' +import Link from 'next/link' +import { useMemo } from 'react' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { OrgUsageResponse } from 'data/usage/org-usage-query' +import { PricingMetric } from 'data/analytics/org-daily-stats-query' +import Panel from 'components/ui/Panel' + +export interface DiskUsageProps { + slug: string + projectRef?: string + attribute: CategoryAttribute + subscription?: OrgSubscription + usage?: OrgUsageResponse + + currentBillingCycleSelected: boolean +} + +const DiskUsage = ({ + slug, + projectRef, + attribute, + subscription, + usage, + currentBillingCycleSelected, +}: DiskUsageProps) => { + const { + data: diskUsage, + isError, + isLoading, + isSuccess, + error, + } = useOrgProjectsQuery( + { + orgSlug: slug, + }, + { + enabled: currentBillingCycleSelected, + } + ) + + const hasProjectsExceedingDiskSize = useMemo(() => { + if (diskUsage) { + return diskUsage.projects.some((it) => + it.databases.some( + (db) => + db.type === 'READ_REPLICA' || (db.disk_volume_size_gb && db.disk_volume_size_gb > 8) + ) + ) + } else { + return false + } + }, [diskUsage]) + + const gp3UsageInPeriod = usage?.usages.find((it) => it.metric === PricingMetric.DISK_IOPS_GP3) + const io2UsageInPeriod = usage?.usages.find((it) => it.metric === PricingMetric.DISK_IOPS_IO2) + + const relevantProjects = useMemo(() => { + return diskUsage + ? diskUsage.projects + .filter((it) => !it.is_branch && it.status !== PROJECT_STATUS.INACTIVE) + .filter((it) => it.ref === projectRef || !projectRef) + : [] + }, [diskUsage, projectRef]) + + return ( +
+ + {isLoading && ( +
+ + + +
+ )} + {isError && } + {isSuccess && ( +
+ {currentBillingCycleSelected && + subscription?.usage_billing_enabled === false && + hasProjectsExceedingDiskSize && ( + + + Projects exceeding quota + + You have projects that are exceeding 8 GB of provisioned disk size, but do not + allow any overages with the Spend Cap on. Reduce the disk size or disable the + spend cap. + + + )} + +
+
+
+

{attribute.name} usage

+
+
+ +
+

+ Included in {subscription?.plan?.name} Plan +

+

8 GB GP3 disk per project

+
+ +
+

Overages in period

+

+ {gp3UsageInPeriod?.usage ?? 0} GP3 GB-Hrs + {io2UsageInPeriod?.usage ? ` / ${io2UsageInPeriod.usage} IO2 GB-Hrs` : ``} +

+
+
+ + {currentBillingCycleSelected ? ( +
+
+

Current disk size per project

+

+ Breakdown of disk per project. Head to your project's disk management section to + see database space used. +

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

No active projects

+

+ You don't have any active projects in this organization. +

+
+
+
+ )} + + {relevantProjects.map((project, idx) => { + const primaryDiskUsage = project.databases + .filter((it) => it.type === 'PRIMARY') + .reduce((acc, curr) => acc + (curr.disk_volume_size_gb ?? 8), 0) + const replicaDbs = project.databases.filter((it) => it.type !== 'PRIMARY') + const replicaDiskUsage = replicaDbs.reduce( + (acc, curr) => acc + (curr.disk_volume_size_gb ?? 8), + 0 + ) + + const totalDiskUsage = primaryDiskUsage + replicaDiskUsage + + return ( +
+
+ + {project.name} + + +
+
+
+ + + + {' '} + GB Disk provisioned + + +

{primaryDiskUsage} GB for Primary Database

+ {replicaDbs.length > 0 && ( + <> +

+ {replicaDiskUsage} GB for {replicaDbs.length} Read{' '} + {replicaDbs.length === 1 ? 'Replica' : 'Replicas'} +

+

+ Read replicas have their own disk and use 25% more disk to account + for WAL files. +

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

Data not available

+

+ Switch to current billing cycle to see current disk size per project. +

+
+
+
+ )} +
+ )} +
+
+ ) +} + +export default DiskUsage diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx index 21f118c093f..9c5b29e4707 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/UsageSection.tsx @@ -5,6 +5,7 @@ import { useOrgUsageQuery } from 'data/usage/org-usage-query' import SectionHeader from '../SectionHeader' import { CategoryMetaKey, USAGE_CATEGORIES } from '../Usage.constants' import AttributeUsage from './AttributeUsage' +import DiskUsage from './DiskUsage' export interface ChartMeta { [key: string]: { data: DataPoint[]; margin: number; isLoading: boolean } @@ -48,23 +49,35 @@ const UsageSection = ({ - {categoryMeta.attributes.map((attribute) => ( - x.metric === attribute.key)} - chartMeta={chartMeta} - subscription={subscription} - error={usageError} - isLoading={isLoadingUsage} - isError={isErrorUsage} - isSuccess={isSuccessUsage} - currentBillingCycleSelected={currentBillingCycleSelected} - /> - ))} + {categoryMeta.attributes.map((attribute) => + attribute.key === 'diskSize' ? ( + + ) : ( + x.metric === attribute.key)} + chartMeta={chartMeta} + subscription={subscription} + error={usageError} + isLoading={isLoadingUsage} + isError={isErrorUsage} + isSuccess={isSuccessUsage} + currentBillingCycleSelected={currentBillingCycleSelected} + /> + ) + )} ) } diff --git a/apps/studio/data/projects/keys.ts b/apps/studio/data/projects/keys.ts index e4be8b5a3d5..b56dbaf49e1 100644 --- a/apps/studio/data/projects/keys.ts +++ b/apps/studio/data/projects/keys.ts @@ -14,4 +14,6 @@ export const projectKeys = { ) => ['projects', 'transfer', projectRef, targetOrganizationSlug, 'preview'] as const, pauseStatus: (projectRef: string | undefined) => ['projects', projectRef, 'pause-status'] as const, + + orgProjects: (slug: string | undefined) => ['projects', 'org', slug] as const, } diff --git a/apps/studio/data/projects/org-projects.ts b/apps/studio/data/projects/org-projects.ts new file mode 100644 index 00000000000..f4284e9a45c --- /dev/null +++ b/apps/studio/data/projects/org-projects.ts @@ -0,0 +1,43 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { components } from 'data/api' +import { get, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { projectKeys } from './keys' + +export type OrgProjectsVariables = { + orgSlug?: string +} + +export type OrgProjectsResponse = components['schemas']['OrganizationProjectsResponse'] + +export async function getOrgProjects( + { orgSlug }: OrgProjectsVariables, + signal?: AbortSignal +): Promise { + if (!orgSlug) throw new Error('orgSlug is required') + const { data, error } = await get(`/platform/organizations/{slug}/projects`, { + params: { + path: { slug: orgSlug }, + }, + signal, + }) + if (error) handleError(error) + return data +} + +export type OrgProjectsData = Awaited> +export type OrgProjectsError = ResponseError + +export const useOrgProjectsQuery = ( + { orgSlug }: OrgProjectsVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + projectKeys.orgProjects(orgSlug), + ({ signal }) => getOrgProjects({ orgSlug }, signal), + { + enabled: enabled && typeof orgSlug !== 'undefined', + ...options, + } + ) diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 63adaeaa462..15d57c796ef 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -457,6 +457,10 @@ export interface paths { /** Sets up a payment method */ post: operations['SetupIntentOrgController_setUpPaymentMethod'] } + '/platform/organizations/{slug}/projects': { + /** Gets all projects for the given organization */ + get: operations['OrganizationProjectsController_getProjects'] + } '/platform/organizations/{slug}/roles': { /** Gets the given organization's roles */ get: operations['OrganizationRolesController_getAllRolesV2'] @@ -3116,6 +3120,21 @@ export interface components { | 'RESTARTING' | 'RESIZING' } + /** @enum {string} */ + DatabaseStatus: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTORING' + | 'UNKNOWN' + | 'UPGRADING' + | 'INIT_READ_REPLICA' + | 'INIT_READ_REPLICA_FAILED' + | 'RESTARTING' + | 'RESIZING' DatabaseStatusResponse: { identifier: string replicaInitializationStatus?: Record @@ -3135,6 +3154,8 @@ export interface components { | 'RESTARTING' | 'RESIZING' } + /** @enum {string} */ + DatabaseType: 'PRIMARY' | 'READ_REPLICA' DatabaseUpgradeStatus: { /** @enum {string} */ error?: @@ -4169,6 +4190,9 @@ export interface components { */ supabase_org_id: string } + OrganizationProjectsResponse: { + projects: components['schemas']['ProjectWithDatabases'][] + } OrganizationResponse: { billing_email: string | null id: number @@ -4669,6 +4693,19 @@ export interface components { release_channel: components['schemas']['ReleaseChannel'] version: string } + ProjectDatabase: { + cloud_provider: string + disk_last_modified_at?: string + disk_throughput_mbps?: number + /** @enum {string} */ + disk_type?: 'gp3' | 'io2' + disk_volume_size_gb?: number + identifier: string + infra_compute_size?: components['schemas']['DbInstanceSize'] + region: string + status: components['schemas']['DatabaseStatus'] + type: components['schemas']['DatabaseType'] + } ProjectDetailResponse: { cloud_provider: string connectionString: string @@ -4852,6 +4889,23 @@ export interface components { ssl_enforced: boolean status: string } + /** @enum {string} */ + ProjectStatus: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INACTIVE' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTARTING' + | 'UNKNOWN' + | 'UPGRADING' + | 'PAUSING' + | 'RESTORING' + | 'RESTORE_FAILED' + | 'PAUSE_FAILED' + | 'RESIZING' ProjectUnpauseVersionInfo: { postgres_engine: components['schemas']['PostgresEngine'] release_channel: components['schemas']['ReleaseChannel'] @@ -4876,6 +4930,14 @@ export interface components { postgres_version: components['schemas']['PostgresEngine'] release_channel: components['schemas']['ReleaseChannel'] } + ProjectWithDatabases: { + databases: components['schemas']['ProjectDatabase'][] + is_branch: boolean + name: string + ref: string + region: string + status: components['schemas']['ProjectStatus'] + } Provider: { created_at?: string domains?: components['schemas']['Domain'][] @@ -9195,6 +9257,29 @@ export interface operations { } } } + /** Gets all projects for the given organization */ + OrganizationProjectsController_getProjects: { + parameters: { + path: { + /** @description Organization slug */ + slug: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['OrganizationProjectsResponse'] + } + } + 403: { + content: never + } + /** @description Failed to retrieve projects */ + 500: { + content: never + } + } + } /** Gets the given organization's roles */ OrganizationRolesController_getAllRolesV2: { parameters: { From 42c8396c8005deb0cf84fe209373738a1b5b39f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Mon, 14 Oct 2024 15:51:04 +0800 Subject: [PATCH 017/255] fix: disk size overages (#29875) --- .../Organization/Usage/UsageSection/DiskUsage.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index b88e217c13c..7c449363541 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -66,8 +66,12 @@ const DiskUsage = ({ } }, [diskUsage]) - const gp3UsageInPeriod = usage?.usages.find((it) => it.metric === PricingMetric.DISK_IOPS_GP3) - const io2UsageInPeriod = usage?.usages.find((it) => it.metric === PricingMetric.DISK_IOPS_IO2) + const gp3UsageInPeriod = usage?.usages.find( + (it) => it.metric === PricingMetric.DISK_SIZE_GB_HOURS_GP3 + ) + const io2UsageInPeriod = usage?.usages.find( + (it) => it.metric === PricingMetric.DISK_SIZE_GB_HOURS_IO2 + ) const relevantProjects = useMemo(() => { return diskUsage @@ -121,8 +125,10 @@ const DiskUsage = ({

Overages in period

- {gp3UsageInPeriod?.usage ?? 0} GP3 GB-Hrs - {io2UsageInPeriod?.usage ? ` / ${io2UsageInPeriod.usage} IO2 GB-Hrs` : ``} + {(gp3UsageInPeriod?.usage ?? 0).toLocaleString()} GP3 GB-Hrs + {io2UsageInPeriod?.usage + ? ` / ${io2UsageInPeriod.usage.toLocaleString()} IO2 GB-Hrs` + : ``}

From f4ed44fc13c569b9319d99cea23caf0a0a6d013a Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:46:47 +0200 Subject: [PATCH 018/255] update collection form (#29791) * add component for collection form * use new comp in create collection * update updatecollection query * add comp to update collection dialog * add missing ttype --- .../DataWarehouse/CollectionForm.tsx | 152 ++++++++++++++++ .../CreateWarehouseCollection.tsx | 171 ++---------------- .../DataWarehouse/WarehouseMenuItem.tsx | 89 ++------- .../warehouse-collections-update-mutation.ts | 3 +- 4 files changed, 189 insertions(+), 226 deletions(-) create mode 100644 apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx diff --git a/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx new file mode 100644 index 00000000000..98543751db2 --- /dev/null +++ b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx @@ -0,0 +1,152 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +import { + Button, + FormControl_Shadcn_, + FormField_Shadcn_, + FormMessage_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + Modal, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { Admonition } from 'ui-patterns' +import { useRouter } from 'next/router' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' + +export interface CollectionFormValues { + name: string + retention_days: number +} + +interface CollectionFormProps { + initialValues?: Partial + onSubmit: (values: CollectionFormValues) => void + isLoading?: boolean + onCancelClick: () => void +} + +export const CollectionForm = ({ + initialValues, + onSubmit, + isLoading, + onCancelClick, +}: CollectionFormProps) => { + const { plan, isLoading: planLoading } = useCurrentOrgPlan() + const router = useRouter() + const currentOrg = useSelectedOrganization() + + function getMaxRetentionDays() { + if (planLoading) return 1 + if (plan?.id === 'free') return 3 + else return 90 + } + const maxRetentionDays = getMaxRetentionDays() + + const FormSchema = z.object({ + name: z.string().min(1), + retention_days: z.coerce.number().min(1).max(maxRetentionDays).multipleOf(1).positive().int(), + }) + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: initialValues?.name || '', + retention_days: initialValues?.retention_days || maxRetentionDays, + }, + }) + + const handleSubmit = form.handleSubmit(onSubmit) + + const formatDays = (days: number) => { + return days === 1 ? `1 day` : `${days} days` + } + + const retentionDescription = (days: number) => { + if (!Boolean(days) || days < 1) + return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}. + return ( + <> + Your logs will be removed after {formatDays(days)}.
+ Your plan allows for a maximum of {formatDays(maxRetentionDays)}. + + ) + } + + return ( + +
+ + ( + + + + + + )} + /> + ( + + +
+ + + days + +
+
+
+ )} + /> +
+ + {plan?.id === 'free' && ( + <> + + + Upgrade to the pro plan to increase retention to 90 days. + +
+ } + /> + + )} + + + + + + + + ) +} diff --git a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseCollection.tsx b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseCollection.tsx index 08e637a2dce..4b2126065e4 100644 --- a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseCollection.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseCollection.tsx @@ -1,25 +1,10 @@ -import { zodResolver } from '@hookform/resolvers/zod' import { useRouter } from 'next/router' -import { useEffect } from 'react' -import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import { z } from 'zod' import { useParams } from 'common' import { useCreateCollection } from 'data/analytics/warehouse-collections-create-mutation' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { - Button, - FormControl_Shadcn_, - FormField_Shadcn_, - FormMessage_Shadcn_, - Form_Shadcn_, - Input_Shadcn_, - Modal, -} from 'ui' -import { Admonition } from 'ui-patterns/admonition' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Modal } from 'ui' +import { CollectionForm } from './CollectionForm' export const CreateWarehouseCollectionModal = ({ open, @@ -30,8 +15,6 @@ export const CreateWarehouseCollectionModal = ({ }) => { const router = useRouter() const { ref } = useParams() - const { plan, isLoading: planLoading } = useCurrentOrgPlan() - const currentOrg = useSelectedOrganization() const { mutate: createCollection, isLoading } = useCreateCollection({ onSuccess: (data) => { @@ -44,64 +27,6 @@ export const CreateWarehouseCollectionModal = ({ }, }) - function getMaxRetentionDays() { - if (planLoading) return 1 - if (plan?.id === 'free') return 3 - else return 90 - } - const FormSchema = z.object({ - name: z.string().min(1), - retention_days: z.coerce - .number() - .min(1) - .max(getMaxRetentionDays()) - .multipleOf(1) - .positive() - .int(), - }) - - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - retention_days: getMaxRetentionDays(), - }, - }) - - useEffect(() => { - if (!open) { - form.reset() - } - }, [open, form]) - - const onSubmit = form.handleSubmit(async (vals) => { - if (!ref) { - toast.error('Project ref not found') - return - } - createCollection({ - projectRef: ref, - name: vals.name, - retention_days: vals.retention_days, - }) - }) - - const formatDays = (days: number) => { - if (days === 1) return `1 day` - return `${days} days` - } - const retentionDescription = (days: number) => { - const maxRetentionDays = getMaxRetentionDays() - if (planLoading) return '' - if (!Boolean(days) || days < 1) - return <>Your plan allows for a maximum of {formatDays(maxRetentionDays)}. - return ( - <> - Your logs will be removed after {formatDays(days)}.
- Your plan allows for a maximum of {formatDays(maxRetentionDays)}. - - ) - } - return ( - -
- -

- An event collection stores time-based data and related information in Supabase's - analytics system. You can use SQL to analyze this data without affecting the - performance of your main database operations. -

-
- ( - - - - - - )} - /> - ( - - -
- - - days - -
-
-
- )} - /> -
- -
- - {plan?.id === 'free' && ( - <> - - - Upgrade to the pro plan to increase retention to 90 days. - -
- } - /> - - )} - - - - - - + onOpenChange(false)} + isLoading={isLoading} + onSubmit={(vals) => { + if (!ref) { + return + } + createCollection({ + projectRef: ref, + name: vals.name, + retention_days: vals.retention_days, + }) + }} + /> ) } diff --git a/apps/studio/components/interfaces/DataWarehouse/WarehouseMenuItem.tsx b/apps/studio/components/interfaces/DataWarehouse/WarehouseMenuItem.tsx index 6f1548a3110..78977d77feb 100644 --- a/apps/studio/components/interfaces/DataWarehouse/WarehouseMenuItem.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/WarehouseMenuItem.tsx @@ -1,44 +1,32 @@ -import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Database, EditIcon, MoreHorizontal, TrashIcon } from 'lucide-react' -import Link from 'next/link' +import { Database, EditIcon, TrashIcon } from 'lucide-react' import { useRouter } from 'next/router' import { useState } from 'react' -import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import { z } from 'zod' -import { FormMessage } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' import { useDeleteCollectionMutation } from 'data/analytics/warehouse-collections-delete-mutation' import { useUpdateCollection } from 'data/analytics/warehouse-collections-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { - Button, - DropdownMenu, - DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - DropdownMenuTrigger, - FormControl_Shadcn_, - FormField_Shadcn_, - Form_Shadcn_, - Input_Shadcn_, Modal, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, - cn, } from 'ui' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { LogsSidebarItem } from '../Settings/Logs/SidebarV2/SidebarItem' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { CollectionForm } from './CollectionForm' type Props = { item: { id: number token: string name: string + retention_days: number } } @@ -71,16 +59,6 @@ export const WarehouseMenuItem = ({ item }: Props) => { const isLoading = updateCollection.isLoading || deleteCollection.isLoading - const UpdateFormSchema = z.object({ - name: z.string().min(1, { - message: 'Collection name is required', - }), - }) - - const updateForm = useForm>({ - resolver: zodResolver(UpdateFormSchema), - }) - return ( <> { }} > -
Rename collection
+
Update collection
{!canUpdateCollection && ( - You need additional permissions to rename collections + You need additional permissions to update collections )} @@ -140,48 +118,19 @@ export const WarehouseMenuItem = ({ item }: Props) => { hideFooter header={`Update collection "${item.name}"`} > - -
- updateCollection.mutate({ - projectRef, - collectionToken: item.token, - name: data.name, - }) - )} - > - - ( - - - - - - )} - /> - - -
- - -
- -
+ setShowUpdateDialog(false)} + isLoading={isLoading} + initialValues={{ name: item.name, retention_days: item.retention_days }} + onSubmit={({ name, retention_days }) => { + updateCollection.mutate({ + projectRef, + collectionToken: item.token, + name, + retention_days, + }) + }} + /> updateCollection( { projectRef: payload.projectRef, collectionToken: payload.collectionToken }, - { name: payload.name } + { name: payload.name, retention_days: payload.retention_days } ), { async onSuccess(data, variables, context) { From c083b3b4abdba8eabd097460c5b2d238373205ab Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Mon, 14 Oct 2024 13:17:11 +0200 Subject: [PATCH 019/255] fix: Fix the snippets loading in local environment (#29750) Add api routes to make the snippets v2 "work" as it did before. --- .../pages/api/projects/[ref]/content/count.ts | 23 ++++++ .../projects/[ref]/content/folders/[id].ts | 33 +++++++++ .../projects/[ref]/content/folders/index.ts | 66 +++++++++++++++++ .../[ref]/{content.ts => content/index.ts} | 19 +++-- .../api/projects/[ref]/content/item/[id].ts | 70 +++++++++++++++++++ 5 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 apps/studio/pages/api/projects/[ref]/content/count.ts create mode 100644 apps/studio/pages/api/projects/[ref]/content/folders/[id].ts create mode 100644 apps/studio/pages/api/projects/[ref]/content/folders/index.ts rename apps/studio/pages/api/projects/[ref]/{content.ts => content/index.ts} (81%) create mode 100644 apps/studio/pages/api/projects/[ref]/content/item/[id].ts diff --git a/apps/studio/pages/api/projects/[ref]/content/count.ts b/apps/studio/pages/api/projects/[ref]/content/count.ts new file mode 100644 index 00000000000..2cc917b30f5 --- /dev/null +++ b/apps/studio/pages/api/projects/[ref]/content/count.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' +import { extractResponse } from 'pages/api/constants' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +type ResponseData = extractResponse<'/platform/projects/{ref}/content/count', 'get'> + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + return res.status(200).json({ count: 1 }) +} diff --git a/apps/studio/pages/api/projects/[ref]/content/folders/[id].ts b/apps/studio/pages/api/projects/[ref]/content/folders/[id].ts new file mode 100644 index 00000000000..05b75dc2388 --- /dev/null +++ b/apps/studio/pages/api/projects/[ref]/content/folders/[id].ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' +import type { UserContent } from 'types' +import { extractResponse } from 'pages/api/constants' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + case 'PATCH': + return handlePatch(req, res) + default: + res.setHeader('Allow', ['GET', 'PATCH']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +type GetResponseData = extractResponse<'/platform/projects/{ref}/content/folders/{id}', 'get'> + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + return res.status(200).json({ data: { folders: [] } }) +} + +type PatchResponseData = extractResponse<'/platform/projects/{ref}/content/folders/{id}', 'patch'> + +const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + return res.status(200).json({} as never) +} diff --git a/apps/studio/pages/api/projects/[ref]/content/folders/index.ts b/apps/studio/pages/api/projects/[ref]/content/folders/index.ts new file mode 100644 index 00000000000..4325b550bc8 --- /dev/null +++ b/apps/studio/pages/api/projects/[ref]/content/folders/index.ts @@ -0,0 +1,66 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' +import type { UserContent } from 'types' +import { extractResponse } from 'pages/api/constants' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + case 'POST': + return handlePost(req, res) + case 'DELETE': + return handleDelete(req, res) + default: + res.setHeader('Allow', ['GET', 'POST', 'DELETE']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +type GetResponseData = extractResponse<'/platform/projects/{ref}/content/folders', 'get'> + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + const snippets = [ + { + id: '1', + owner_id: 1, + name: 'SQL Query', + description: '', + type: 'sql' as const, + visibility: 'user' as const, + content: { + content_id: '1.0', + sql: `select * from + (select version()) as version, + (select current_setting('server_version_num')) as version_number;`, + schema_version: '1', + favorite: false, + }, + inserted_at: '', + updated_at: '', + project_id: 0, + favorite: false, + }, + ] + return res.status(200).json({ + data: { + folders: [], + contents: snippets, + }, + }) +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + return res.status(200).json({}) +} + +const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + return res.status(200).json({}) +} diff --git a/apps/studio/pages/api/projects/[ref]/content.ts b/apps/studio/pages/api/projects/[ref]/content/index.ts similarity index 81% rename from apps/studio/pages/api/projects/[ref]/content.ts rename to apps/studio/pages/api/projects/[ref]/content/index.ts index 2fb7bd01c41..58f02f1e324 100644 --- a/apps/studio/pages/api/projects/[ref]/content.ts +++ b/apps/studio/pages/api/projects/[ref]/content/index.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from 'lib/api/apiWrapper' import type { UserContent } from 'types' +import { extractResponse } from 'pages/api/constants' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) @@ -22,16 +23,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } } -const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { +type GetResponseData = extractResponse<'/platform/projects/{ref}/content', 'get'> + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { // Platform specific endpoint - const snippets: UserContent[] = [ + const snippets = [ { id: '1', owner_id: 1, name: 'SQL Query', description: '', - type: 'sql', - visibility: 'user', + type: 'sql' as const, + visibility: 'user' as const, content: { content_id: '1.0', sql: `select * from @@ -39,7 +42,13 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { (select current_setting('server_version_num')) as version_number;`, schema_version: '1', favorite: false, - }, + } as any, + favorite: false, + inserted_at: '', + project_id: 0, + updated_at: '', + owner: {}, + updated_by: {}, }, ] return res.status(200).json({ data: snippets }) diff --git a/apps/studio/pages/api/projects/[ref]/content/item/[id].ts b/apps/studio/pages/api/projects/[ref]/content/item/[id].ts new file mode 100644 index 00000000000..f56c741f3cc --- /dev/null +++ b/apps/studio/pages/api/projects/[ref]/content/item/[id].ts @@ -0,0 +1,70 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' +import type { UserContent } from 'types' +import { extractResponse } from 'pages/api/constants' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGetAll(req, res) + case 'POST': + return handlePost(req, res) + case 'PATCH': + return handlePatch(req, res) + case 'PUT': + return handlePut(req, res) + default: + res.setHeader('Allow', ['GET', 'POST', 'PATCH', 'PUT']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +type ResponseData = extractResponse<'/platform/projects/{ref}/content/item/{id}', 'get'> + +const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + const snippet = { + id: '1', + owner_id: 1, + name: 'SQL Query', + description: '', + type: 'sql' as const, + visibility: 'user' as const, + content: { + content_id: '1.0', + sql: `select * from + (select version()) as version, + (select current_setting('server_version_num')) as version_number;`, + schema_version: '1', + favorite: false, + } as any, + favorite: false, + inserted_at: '', + project_id: 0, + updated_at: '', + } + + return res.status(200).json({ + ...snippet, + }) +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + return res.status(200).json({}) +} + +const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + return res.status(200).json({}) +} + +const handlePut = async (req: NextApiRequest, res: NextApiResponse) => { + // Platform specific endpoint + const snippet: UserContent = req.body + return res.status(200).json({ data: snippet }) +} From 47633695eae5e0df23b38d0592b2f119641355ea Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Mon, 14 Oct 2024 12:22:34 -0230 Subject: [PATCH 020/255] Chore/search user json (#29825) * Add search to raw json panel * Cleanup * Update apps/studio/components/interfaces/Auth/Users/UserPanel.tsx Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young --- .../interfaces/Auth/Users/UserPanel.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx index 724ce52de08..86cb1c4f8ff 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserPanel.tsx @@ -5,6 +5,7 @@ import { useState } from 'react' import { Button, cn, + Input_Shadcn_, ResizableHandle, ResizablePanel, Tabs_Shadcn_, @@ -24,6 +25,22 @@ export const PANEL_PADDING = 'px-5 py-5' export const UserPanel = ({ selectedUser, onClose }: UserPanelProps) => { const [view, setView] = useState<'overview' | 'raw' | 'logs'>('overview') + const [searchQuery, setSearchQuery] = useState('') + + const filteredProperties = selectedUser + ? Object.entries(selectedUser) + .filter( + ([key, value]) => + key.toLowerCase().includes(searchQuery.toLowerCase()) || + (typeof value === 'string' && value.toLowerCase().includes(searchQuery.toLowerCase())) + ) + .reduce((obj, [key, value]) => { + if (value !== undefined) { + obj[key as keyof User] = value as any + } + return obj + }, {} as Partial) + : {} return ( <> @@ -76,8 +93,26 @@ export const UserPanel = ({ selectedUser, onClose }: UserPanelProps) => { value="raw" className={cn('mt-0 flex-grow min-h-0 overflow-y-auto', PANEL_PADDING)} > +
+ setSearchQuery(e.target.value)} + className="mr-2" + /> + +
- {JSON.stringify(selectedUser, null, 2)} + {JSON.stringify(filteredProperties, null, 2)} From 968ee6584badaefeb9d52528e12aac9acb69c852 Mon Sep 17 00:00:00 2001 From: Ant Wilson Date: Tue, 15 Oct 2024 02:44:19 +0800 Subject: [PATCH 021/255] fix: consistency (#29873) --- apps/www/_customers/humata.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/_customers/humata.mdx b/apps/www/_customers/humata.mdx index ed7706598ba..c32145731e2 100644 --- a/apps/www/_customers/humata.mdx +++ b/apps/www/_customers/humata.mdx @@ -1,6 +1,6 @@ --- name: Humata -title: Humata Scales with Supabase - Achieving 4X Cost Savings and Enhanced Performance +title: 'Humata Scales with Supabase: Achieving 4X Cost Savings and Enhanced Performance' # Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories': meta_title: Humata Scales with Supabase - Achieving 4X Cost Savings and Enhanced Performance description: By partnering with Supabase, Humata achieved a 4X reduction in vector database costs, streamlined their development, and consolidated their data so they could scale seamlessly. From b6f1e1bd780d7c64fbd0ba27df241e2a73e33a96 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Tue, 15 Oct 2024 00:18:11 +0200 Subject: [PATCH 022/255] fix: change to return jsonb (#29887) --- apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx index d8945b23bb2..6a6973e0d77 100644 --- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -351,7 +351,7 @@ create table job_queue ( Create the hook ```sql -create or replace function send_email(event jsonb) returns void as $$ +create or replace function send_email(event jsonb) returns jsonb as $$ declare job_data jsonb; scheduled_time timestamp; @@ -373,6 +373,8 @@ begin insert into job_queue (job_data, priority, scheduled_at, max_retries) values (job_data, priority, scheduled_time, 2); + + return '{}'::jsonb; end; $$ language plpgsql; From 3941b0cc32492759ca476766f958f17b6fbe7722 Mon Sep 17 00:00:00 2001 From: divit <27228526+delgado3d@users.noreply.github.com> Date: Tue, 15 Oct 2024 07:47:53 +0530 Subject: [PATCH 023/255] fix: typo in upgrade page (#29882) --- .../interfaces/Settings/Infrastructure/InfrastructureInfo.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index 7b460d9804f..8453e3b577d 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -145,8 +145,8 @@ const InfrastructureInfo = () => { A new version of Postgres is available for your project - You will need to remove all read replicas first prior to upgrading your - Postgrest version to the latest available ({latestPgVersion}). + You will need to remove all read replicas prior to upgrading your Postgres + version to the latest available ({latestPgVersion}). )} From 9f02ee07b7b2c1ce878f62258eb27bdf55db1e37 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 15 Oct 2024 10:58:06 +0800 Subject: [PATCH 024/255] Add credentials include in all fetchers (#29834) --- apps/studio/data/fetchers.ts | 8 ++++++++ apps/studio/data/telemetry/send-event-mutation.ts | 6 +----- .../data/telemetry/send-groups-identify-mutation.ts | 5 +---- apps/studio/data/telemetry/send-groups-reset-mutation.ts | 5 +---- apps/studio/data/telemetry/send-identify-mutation.ts | 6 +----- apps/studio/data/telemetry/send-page-leave-mutation.ts | 5 +---- apps/studio/data/telemetry/send-page-mutation.ts | 6 +----- apps/studio/data/telemetry/send-reset-mutation.ts | 2 +- 8 files changed, 15 insertions(+), 28 deletions(-) diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 8176e9678b8..7835a462188 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -52,6 +52,7 @@ export const get: typeof _get = async (url, init) => { } return await _get(url, { + credentials: 'include', ...init, headers, }) @@ -67,6 +68,7 @@ export const post: typeof _post = async (url, init) => { } return await _post(url, { + credentials: 'include', ...init, headers, }) @@ -82,6 +84,7 @@ export const put: typeof _put = async (url, init) => { } return await _put(url, { + credentials: 'include', ...init, headers, }) @@ -97,6 +100,7 @@ export const patch: typeof _patch = async (url, init) => { } return await _patch(url, { + credentials: 'include', ...init, headers, }) @@ -112,6 +116,7 @@ export const del: typeof _del = async (url, init) => { } return await _del(url, { + credentials: 'include', ...init, headers, }) @@ -127,6 +132,7 @@ export const head: typeof _head = async (url, init) => { } return await _head(url, { + credentials: 'include', ...init, headers, }) @@ -136,6 +142,7 @@ export const trace: typeof _trace = async (url, init) => { const headers = await constructHeaders(init?.headers) return await _trace(url, { + credentials: 'include', ...init, headers, }) @@ -145,6 +152,7 @@ export const options: typeof _options = async (url, init) => { const headers = await constructHeaders(init?.headers) return await _options(url, { + credentials: 'include', ...init, headers, }) diff --git a/apps/studio/data/telemetry/send-event-mutation.ts b/apps/studio/data/telemetry/send-event-mutation.ts index 6fda99f2f3a..34add0c887d 100644 --- a/apps/studio/data/telemetry/send-event-mutation.ts +++ b/apps/studio/data/telemetry/send-event-mutation.ts @@ -28,11 +28,7 @@ export async function sendEvent(type: 'GA' | 'PH', body: SendEventPayload) { if (consent !== 'true') return const headers = type === 'PH' ? { Version: '2' } : undefined - const { data, error } = await post(`/platform/telemetry/event`, { - body, - headers, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/event`, { body, headers }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-groups-identify-mutation.ts b/apps/studio/data/telemetry/send-groups-identify-mutation.ts index 3d9130f18e7..1b3d241d4a4 100644 --- a/apps/studio/data/telemetry/send-groups-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-identify-mutation.ts @@ -7,10 +7,7 @@ import type { ResponseError } from 'types' export type SendGroupsIdentifyVariables = components['schemas']['TelemetryGroupsIdentityBody'] export async function sendGroupsIdentify(body: SendGroupsIdentifyVariables) { - const { data, error } = await post(`/platform/telemetry/groups/identify`, { - body, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/groups/identify`, { body }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-groups-reset-mutation.ts b/apps/studio/data/telemetry/send-groups-reset-mutation.ts index 0c6a157ae31..4d6e214d481 100644 --- a/apps/studio/data/telemetry/send-groups-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-reset-mutation.ts @@ -7,10 +7,7 @@ import type { ResponseError } from 'types' export type SendGroupsResetVariables = components['schemas']['TelemetryGroupsResetBody'] export async function sendGroupsReset(body: SendGroupsResetVariables) { - const { data, error } = await post(`/platform/telemetry/groups/reset`, { - body, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/groups/reset`, { body }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-identify-mutation.ts b/apps/studio/data/telemetry/send-identify-mutation.ts index b17b26ddda4..76d9eb5ab3d 100644 --- a/apps/studio/data/telemetry/send-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-identify-mutation.ts @@ -28,11 +28,7 @@ export async function sendIdentify(type: 'GA' | 'PH', body: SendIdentifyPayload) if (consent !== 'true') return const headers = type === 'PH' ? { Version: '2' } : undefined - const { data, error } = await post(`/platform/telemetry/identify`, { - body, - headers, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/identify`, { body, headers }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-page-leave-mutation.ts b/apps/studio/data/telemetry/send-page-leave-mutation.ts index 9eaa46fcdd4..fc458c2f449 100644 --- a/apps/studio/data/telemetry/send-page-leave-mutation.ts +++ b/apps/studio/data/telemetry/send-page-leave-mutation.ts @@ -8,10 +8,7 @@ import type { ResponseError } from 'types' type SendPageLeaveBody = components['schemas']['TelemetryPageLeaveBody'] export async function sendPageLeave(body: SendPageLeaveBody) { - const { data, error } = await post(`/platform/telemetry/page-leave`, { - body, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/page-leave`, { body }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-page-mutation.ts b/apps/studio/data/telemetry/send-page-mutation.ts index 5bf477abe06..4d357c1be2a 100644 --- a/apps/studio/data/telemetry/send-page-mutation.ts +++ b/apps/studio/data/telemetry/send-page-mutation.ts @@ -18,11 +18,7 @@ type SendPagePayload = any export async function sendPage(type: 'GA' | 'PH', body: SendPagePayload) { const headers = type === 'PH' ? { Version: '2' } : undefined - const { data, error } = await post(`/platform/telemetry/page`, { - body, - headers, - credentials: 'include', - }) + const { data, error } = await post(`/platform/telemetry/page`, { body, headers }) if (error) handleError(error) return data } diff --git a/apps/studio/data/telemetry/send-reset-mutation.ts b/apps/studio/data/telemetry/send-reset-mutation.ts index 43b376d2ce7..c1056d8bf8f 100644 --- a/apps/studio/data/telemetry/send-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-reset-mutation.ts @@ -4,7 +4,7 @@ import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' export async function sendReset() { - const { data, error } = await post(`/platform/telemetry/reset`, { credentials: 'include' }) + const { data, error } = await post(`/platform/telemetry/reset`, {}) if (error) handleError(error) return data } From a3e1fb2a0e40a5aedac13be6813f336a56a81770 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 15 Oct 2024 15:20:34 +0800 Subject: [PATCH 025/255] Update auth hooks to use new endpoint and add perms checking (#29870) * Update auth hooks to use new endpoint and add perms checking * Remove unnecessary import * Update perms checking for edge functions secrets --- .../interfaces/Auth/Hooks/AddHookDropdown.tsx | 9 +- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 167 ++++++----- .../interfaces/Auth/Hooks/HookCard.tsx | 10 +- .../interfaces/Auth/Hooks/HooksListing.tsx | 91 +++--- .../EdgeFunctionSecrets.tsx | 4 +- .../data/auth/auth-hooks-update-mutation.ts | 54 ++++ apps/studio/package.json | 2 +- package-lock.json | 8 +- packages/api-types/types/api.d.ts | 263 +++++++++++++++++- 9 files changed, 450 insertions(+), 158 deletions(-) create mode 100644 apps/studio/data/auth/auth-hooks-update-mutation.ts diff --git a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx index f2802eafb60..12e55e0c8b2 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx @@ -16,7 +16,6 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - SheetSection, } from 'ui' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, isValidHook } from './hooks.utils' @@ -38,7 +37,7 @@ export const AddHookDropdown = ({ { enabled: IS_PLATFORM } ) const { data: authConfig } = useAuthConfigQuery({ projectRef }) - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -57,7 +56,7 @@ export const AddHookDropdown = ({ const isTeamsOrEnterprisePlan = subscription?.plan.id === 'team' || subscription?.plan.id === 'enterprise' - if (!canUpdateConfig) { + if (!canUpdateAuthHook) { return ( - +
{nonEnterpriseHookOptions.map((h) => ( onSelectHook(h.title)}> @@ -87,7 +86,7 @@ export const AddHookDropdown = ({ ))}
- + {!isTeamsOrEnterprisePlan && ( diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index 5a25cd7643f..ca32ae51599 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -14,7 +14,7 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import FunctionSelector from 'components/ui/FunctionSelector' import SchemaSelector from 'components/ui/SchemaSelector' import { AuthConfigResponse } from 'data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' import { executeSql } from 'data/sql/execute-sql-query' import { useFlag } from 'hooks/ui/useFlag' import { @@ -42,10 +42,10 @@ import { extractMethod, getRevokePermissionStatements, isValidHook } from './hoo interface CreateHookSheetProps { visible: boolean - onClose: () => void - onDelete: () => void title: HOOK_DEFINITION_TITLE | null authConfig: AuthConfigResponse + onClose: () => void + onDelete: () => void } export function generateAuthHookSecret() { @@ -109,14 +109,13 @@ const FormSchema = z export const CreateHookSheet = ({ visible, - onClose, - onDelete, title, authConfig, + onClose, + onDelete, }: CreateHookSheetProps) => { const { ref: projectRef } = useParams() const { project } = useProjectContext() - const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() const httpsAuthHooksEnabled = useFlag('httpsAuthHooksEnabled') const definition = useMemo( @@ -155,6 +154,79 @@ export const CreateHookSheet = ({ }, }) + const values = form.watch() + + const statements = useMemo(() => { + let permissionChanges: string[] = [] + if (hook.method.type === 'postgres') { + if ( + hook.method.schema !== '' && + hook.method.functionName !== '' && + hook.method.functionName !== values.postgresValues.functionName + ) { + permissionChanges = getRevokePermissionStatements( + hook.method.schema, + hook.method.functionName + ) + } + } + + if (values.postgresValues.functionName !== '') { + permissionChanges = [ + ...permissionChanges, + `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, + `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, + `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, + ] + } + return permissionChanges + }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) + + const { mutate: updateAuthHooks, isLoading: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ + onSuccess: () => { + toast.success(`Successfully created ${values.hookType}.`) + if (statements.length > 0) { + executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: statements.join('\n'), + }) + } + onClose() + }, + onError: (error) => { + toast.error(`Failed to create hook: ${error.message}`) + }, + }) + + const onSubmit: SubmitHandler> = async (values) => { + if (!project) return console.error('Project is required') + const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) + + if (!definition) { + return + } + + const enabledLabel = definition.enabledKey + const uriLabel = definition.uriKey + const secretsLabel = definition.secretsKey + + let url = '' + if (values.selectedType === 'postgres') { + url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` + } else { + url = values.httpsValues.url + } + + const payload = { + [enabledLabel]: values.enabled, + [uriLabel]: url, + [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, + } + + updateAuthHooks({ projectRef: projectRef!, config: payload }) + } + useEffect(() => { if (visible) { if (definition) { @@ -194,81 +266,6 @@ export const CreateHookSheet = ({ } }, [authConfig, title, visible, definition]) - const values = form.watch() - - const statements = useMemo(() => { - let permissionChanges: string[] = [] - if (hook.method.type === 'postgres') { - if ( - hook.method.schema !== '' && - hook.method.functionName !== '' && - hook.method.functionName !== values.postgresValues.functionName - ) { - permissionChanges = getRevokePermissionStatements( - hook.method.schema, - hook.method.functionName - ) - } - } - - if (values.postgresValues.functionName !== '') { - permissionChanges = [ - ...permissionChanges, - `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, - `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, - `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, - ] - } - return permissionChanges - }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) - - const onSubmit: SubmitHandler> = async (values) => { - if (!project) return console.error('Project is required') - const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) - - if (!definition) { - return - } - - const enabledLabel = definition.enabledKey - const uriLabel = definition.uriKey - const secretsLabel = definition.secretsKey - - let url = '' - if (values.selectedType === 'postgres') { - url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` - } else { - url = values.httpsValues.url - } - - const payload = { - [enabledLabel]: values.enabled, - [uriLabel]: url, - [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, - } - - updateAuthConfig( - { projectRef: projectRef!, config: payload }, - { - onSuccess: () => { - toast.success(`Successfully created ${values.hookType}.`) - if (statements.length > 0) { - executeSql({ - projectRef, - connectionString: project.connectionString, - sql: statements.join('\n'), - }) - } - onClose() - }, - onError: (error) => { - toast.error(`Failed to create hook: ${error.message}`) - onClose() - }, - } - ) - } - return ( onClose()}> @@ -492,16 +489,16 @@ export const CreateHookSheet = ({
)} - diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index 38e78f349cc..9850a9a51da 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -1,18 +1,20 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Check, Webhook } from 'lucide-react' import { Badge, Input } from 'ui' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { copyToClipboard } from 'lib/helpers' import { Hook } from './hooks.constants' interface HookCardProps { hook: Hook - canUpdateConfig: boolean - onToggle: (enabled: boolean) => void onSelect: () => void } -export const HookCard = ({ hook, canUpdateConfig, onToggle, onSelect }: HookCardProps) => { +export const HookCard = ({ hook, onSelect }: HookCardProps) => { + const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') + return (
@@ -93,7 +95,7 @@ export const HookCard = ({ hook, canUpdateConfig, onToggle, onSelect }: HookCard
onSelect()} tooltip={{ content: { diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index 86e4149705d..37c469af4ac 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -1,23 +1,21 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' +import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { FormHeader } from 'components/ui/Forms/FormHeader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' +import { executeSql } from 'data/sql/execute-sql-query' +import { cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { AddHookDropdown } from './AddHookDropdown' import { CreateHookSheet } from './CreateHookSheet' import { HookCard } from './HookCard' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' -import { executeSql } from 'data/sql/execute-sql-query' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import CodeEditor from 'components/ui/CodeEditor/CodeEditor' -import { cn } from 'ui' export const HooksListing = () => { const { ref: projectRef } = useParams() @@ -27,8 +25,27 @@ export const HooksListing = () => { const [selectedHook, setSelectedHook] = useState(null) const [selectedHookForDeletion, setSelectedHookForDeletion] = useState(null) - const { mutateAsync: updateAuthConfig } = useAuthConfigUpdateMutation() - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const { mutate: updateAuthHooks, isLoading: isDeletingAuthHook } = useAuthHooksUpdateMutation({ + onSuccess: async () => { + if (!selectedHookForDeletion) return + + const { method } = selectedHookForDeletion + if (method.type === 'postgres') { + const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) + await executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: revokeStatements.join('\n'), + }) + } + toast.success(`${selectedHookForDeletion.title} has been deleted.`) + setSelectedHookForDeletion(null) + setSelectedHook(null) + }, + onError: (error) => { + toast.error(`Failed to delete hook: ${error.message}`) + }, + }) const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -78,16 +95,7 @@ export const HooksListing = () => { - updateAuthConfig({ - projectRef: projectRef!, - config: { [hook.enabledKey]: enabled }, - }) - } - onSelect={() => { - setSelectedHook(hook.title) - }} + onSelect={() => setSelectedHook(hook.title)} /> ) })} @@ -98,9 +106,7 @@ export const HooksListing = () => { visible={!!selectedHook} onDelete={() => { const hook = hooks.find((h) => h.title === selectedHook) - if (hook) { - setSelectedHookForDeletion(hook) - } + if (hook) setSelectedHookForDeletion(hook) }} onClose={() => setSelectedHook(null)} authConfig={authConfig!} @@ -110,16 +116,14 @@ export const HooksListing = () => { visible={!!selectedHookForDeletion} size="large" variant="destructive" - title="Confirm to delete" + loading={isDeletingAuthHook} + title={`Confirm to delete ${selectedHookForDeletion?.title}`} confirmLabel="Delete" confirmLabelLoading="Deleting" onCancel={() => setSelectedHookForDeletion(null)} - onConfirm={async () => { - if (!selectedHookForDeletion) { - return - } - - await updateAuthConfig({ + onConfirm={() => { + if (!selectedHookForDeletion) return + updateAuthHooks({ projectRef: projectRef!, config: { [selectedHookForDeletion.enabledKey]: false, @@ -127,35 +131,20 @@ export const HooksListing = () => { [selectedHookForDeletion.secretsKey]: null, }, }) - - const { method } = selectedHookForDeletion - - if (method.type === 'postgres') { - const revokeStatements = getRevokePermissionStatements( - method.schema, - method.functionName - ) - await executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: revokeStatements.join('\n'), - }) - } - toast.success(`${selectedHookForDeletion.title} has been deleted.`) - setSelectedHookForDeletion(null) - setSelectedHook(null) }} >
-

- {`Are you sure you want to delete the ${selectedHookForDeletion?.title}?`} +

+ Are you sure you want to delete the {selectedHookForDeletion?.title}?

{selectedHookForDeletion?.method.type === 'postgres' && ( <> -

- {`The following statements will be executed on the ${selectedHookForDeletion?.method.schema}.${selectedHookForDeletion?.method.functionName} function:`} +

+ The following statements will be executed on the{' '} + {selectedHookForDeletion?.method.schema}. + {selectedHookForDeletion?.method.functionName} function:

-
+
{ const [showCreateSecret, setShowCreateSecret] = useState(false) const [selectedSecret, setSelectedSecret] = useState() - const canReadSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*') - const canUpdateSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') + const canReadSecrets = useCheckPermissions(PermissionAction.SECRETS_READ, '*') + const canUpdateSecrets = useCheckPermissions(PermissionAction.SECRETS_WRITE, '*') const { data, error, isLoading, isSuccess, isError } = useSecretsQuery({ projectRef: projectRef, diff --git a/apps/studio/data/auth/auth-hooks-update-mutation.ts b/apps/studio/data/auth/auth-hooks-update-mutation.ts new file mode 100644 index 00000000000..8b8ebecffe6 --- /dev/null +++ b/apps/studio/data/auth/auth-hooks-update-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { components } from 'data/api' +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { authKeys } from './keys' + +export type AuthHooksUpdateVariables = { + projectRef: string + config: components['schemas']['UpdateGoTrueConfigHooksBody'] +} + +export async function updateAuthHooks({ projectRef, config }: AuthHooksUpdateVariables) { + const { data, error } = await patch('/platform/auth/{ref}/config/hooks', { + params: { path: { ref: projectRef } }, + body: config, + }) + + if (error) handleError(error) + return data +} + +type AuthHooksUpdateData = Awaited> + +export const useAuthHooksUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateAuthHooks(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(authKeys.authConfig(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update auth hooks: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 55ad9b69b3d..6131743e9b8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -40,7 +40,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.70", + "@supabase/shared-types": "0.1.73", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", diff --git a/package-lock.json b/package-lock.json index 884657ab1ac..82c222e368b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1826,7 +1826,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.70", + "@supabase/shared-types": "0.1.73", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", @@ -14739,9 +14739,9 @@ } }, "node_modules/@supabase/shared-types": { - "version": "0.1.70", - "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.70.tgz", - "integrity": "sha512-Z/JBbISlxMcOjFcomZdB5AGKrQdTy/dy3HQ8EpHx3xKz1yktbUDrzVcEFUKidRPrQu1GAt9JSW6qBsBlXHcyLA==" + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.73.tgz", + "integrity": "sha512-5BhGYrJ/ICBlIWdAZL3lRD2w6ZUAYvjpulvIkHfVKNcXpF3LpZ+2ONRDkm0B8dlWp+ZE1bm7DbFgJvoW+AT3XA==" }, "node_modules/@supabase/sql-to-rest": { "version": "0.1.6", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 15d57c796ef..3a85142fd88 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -101,6 +101,10 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } + '/platform/auth/{ref}/config/hooks': { + /** Updates GoTrue config hooks */ + patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] + } '/platform/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -122,11 +126,25 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/platform/auth/{ref}/users': { - /** Gets users */ + /** + * Gets users + * @deprecated + */ get: operations['UsersController_getUsers'] - /** Delete user with given ID */ + /** Creates user */ + post: operations['UsersController_createUser'] + /** + * Delete user with given ID + * @deprecated + */ delete: operations['UsersController_deleteUser'] } + '/platform/auth/{ref}/users/{id}': { + /** Delete user with given ID */ + delete: operations['UsersController_deleteUserById'] + /** Updates user with given ID */ + patch: operations['UsersController_updateUserById'] + } '/platform/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ delete: operations['FactorsController_deleteFactors'] @@ -1290,6 +1308,10 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } + '/v0/auth/{ref}/config/hooks': { + /** Updates GoTrue config hooks */ + patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] + } '/v0/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -1311,11 +1333,25 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/v0/auth/{ref}/users': { - /** Gets users */ + /** + * Gets users + * @deprecated + */ get: operations['UsersController_getUsers'] - /** Delete user with given ID */ + /** Creates user */ + post: operations['UsersController_createUser'] + /** + * Delete user with given ID + * @deprecated + */ delete: operations['UsersController_deleteUser'] } + '/v0/auth/{ref}/users/{id}': { + /** Delete user with given ID */ + delete: operations['UsersController_deleteUserById'] + /** Updates user with given ID */ + patch: operations['UsersController_updateUserById'] + } '/v0/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ delete: operations['FactorsController_deleteFactors'] @@ -3026,6 +3062,11 @@ export interface components { table: string table_id?: number } + CreateUserBody: { + email: string + email_confirm: boolean + password: string + } CreateUserContentFolderResponse: { id: string name: string @@ -3033,6 +3074,43 @@ export interface components { parent_id?: string | null project_id: number } + CreateUserReponse: { + aud?: string + banned_until?: string + confirmation_sent_at?: string + confirmation_token?: string + confirmed_at?: string + created_at?: string + deleted_at?: string + email?: string + email_change?: string + email_change_confirm_status?: number + email_change_sent_at?: string + email_change_token_current?: string + email_change_token_new?: string + email_confirmed_at?: string + encrypted_password?: string + id?: string + instance_id?: string + invited_at?: string + is_anonymous?: boolean + is_sso_user?: boolean + is_super_admin?: boolean + last_sign_in_at?: string + phone?: string + phone_change?: string + phone_change_sent_at?: string + phone_change_token?: string + phone_confirmed_at?: string + raw_app_meta_data?: Record + raw_user_meta_data?: Record + reauthentication_sent_at?: string + reauthentication_token?: string + recovery_sent_at?: string + recovery_token?: string + role?: string + updated_at?: string + } CreateVercelConnectionResponse: { env_sync_error?: components['schemas']['SyncVercelEnvError'] id: string @@ -6202,6 +6280,23 @@ export interface components { SMTP_USER?: string URI_ALLOW_LIST?: string } + UpdateGoTrueConfigHooksBody: { + HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean + HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string + HOOK_CUSTOM_ACCESS_TOKEN_URI?: string + HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED?: boolean + HOOK_MFA_VERIFICATION_ATTEMPT_SECRETS?: string + HOOK_MFA_VERIFICATION_ATTEMPT_URI?: string + HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED?: boolean + HOOK_PASSWORD_VERIFICATION_ATTEMPT_SECRETS?: string + HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI?: string + HOOK_SEND_EMAIL_ENABLED?: boolean + HOOK_SEND_EMAIL_SECRETS?: string + HOOK_SEND_EMAIL_URI?: string + HOOK_SEND_SMS_ENABLED?: boolean + HOOK_SEND_SMS_SECRETS?: string + HOOK_SEND_SMS_URI?: string + } UpdateMemberBody: { role_id: number } @@ -6420,6 +6515,46 @@ export interface components { table?: string table_id?: number } + UpdateUserBody: { + ban_duration?: string + } + UpdateUserReponse: { + aud?: string + banned_until?: string + confirmation_sent_at?: string + confirmation_token?: string + confirmed_at?: string + created_at?: string + deleted_at?: string + email?: string + email_change?: string + email_change_confirm_status?: number + email_change_sent_at?: string + email_change_token_current?: string + email_change_token_new?: string + email_confirmed_at?: string + encrypted_password?: string + id?: string + instance_id?: string + invited_at?: string + is_anonymous?: boolean + is_sso_user?: boolean + is_super_admin?: boolean + last_sign_in_at?: string + phone?: string + phone_change?: string + phone_change_sent_at?: string + phone_change_token?: string + phone_confirmed_at?: string + raw_app_meta_data?: Record + raw_user_meta_data?: Record + reauthentication_sent_at?: string + reauthentication_token?: string + recovery_sent_at?: string + recovery_token?: string + role?: string + updated_at?: string + } UpdateVercelConnectionsBody: { env_sync_targets?: ('production' | 'preview' | 'development')[] public_env_var_prefix?: string @@ -7140,6 +7275,34 @@ export interface operations { } } } + /** Updates GoTrue config hooks */ + GoTrueConfigController_updateGoTrueConfigHooks: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateGoTrueConfigHooksBody'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['GoTrueConfigResponse'] + } + } + 403: { + content: never + } + /** @description Failed to update GoTrue config hooks */ + 500: { + content: never + } + } + } /** Sends an invite to the given email */ AuthInviteController_sendInvite: { parameters: { @@ -7278,7 +7441,10 @@ export interface operations { } } } - /** Gets users */ + /** + * Gets users + * @deprecated + */ UsersController_getUsers: { parameters: { query: { @@ -7307,7 +7473,38 @@ export interface operations { } } } - /** Delete user with given ID */ + /** Creates user */ + UsersController_createUser: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateUserBody'] + } + } + responses: { + 201: { + content: { + 'application/json': components['schemas']['CreateUserReponse'] + } + } + 403: { + content: never + } + /** @description Failed to create user */ + 500: { + content: never + } + } + } + /** + * Delete user with given ID + * @deprecated + */ UsersController_deleteUser: { parameters: { path: { @@ -7335,6 +7532,57 @@ export interface operations { } } } + /** Delete user with given ID */ + UsersController_deleteUserById: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + responses: { + 200: { + content: never + } + 403: { + content: never + } + /** @description Failed to delete user */ + 500: { + content: never + } + } + } + /** Updates user with given ID */ + UsersController_updateUserById: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateUserBody'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['UpdateUserReponse'] + } + } + 403: { + content: never + } + /** @description Failed to update user with given ID */ + 500: { + content: never + } + } + } /** Delete all factors associated to a user */ FactorsController_deleteFactors: { parameters: { @@ -12175,6 +12423,9 @@ export interface operations { 'application/json': components['schemas']['UpdateSecretsResponse'] } } + 403: { + content: never + } /** @description Failed to update project's secrets config */ 500: { content: never From a2a94d1f7402d07dbfd43246b46b562be140f05c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 15 Oct 2024 15:51:23 +0800 Subject: [PATCH 026/255] Revert "Update auth hooks to use new endpoint and add perms checking" (#29893) Revert "Update auth hooks to use new endpoint and add perms checking (#29870)" This reverts commit a3e1fb2a0e40a5aedac13be6813f336a56a81770. --- .../interfaces/Auth/Hooks/AddHookDropdown.tsx | 9 +- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 167 +++++------ .../interfaces/Auth/Hooks/HookCard.tsx | 10 +- .../interfaces/Auth/Hooks/HooksListing.tsx | 91 +++--- .../EdgeFunctionSecrets.tsx | 4 +- .../data/auth/auth-hooks-update-mutation.ts | 54 ---- apps/studio/package.json | 2 +- package-lock.json | 8 +- packages/api-types/types/api.d.ts | 263 +----------------- 9 files changed, 158 insertions(+), 450 deletions(-) delete mode 100644 apps/studio/data/auth/auth-hooks-update-mutation.ts diff --git a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx index 12e55e0c8b2..f2802eafb60 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx @@ -16,6 +16,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, + SheetSection, } from 'ui' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, isValidHook } from './hooks.utils' @@ -37,7 +38,7 @@ export const AddHookDropdown = ({ { enabled: IS_PLATFORM } ) const { data: authConfig } = useAuthConfigQuery({ projectRef }) - const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -56,7 +57,7 @@ export const AddHookDropdown = ({ const isTeamsOrEnterprisePlan = subscription?.plan.id === 'team' || subscription?.plan.id === 'enterprise' - if (!canUpdateAuthHook) { + if (!canUpdateConfig) { return ( - +
{nonEnterpriseHookOptions.map((h) => ( onSelectHook(h.title)}> @@ -86,7 +87,7 @@ export const AddHookDropdown = ({ ))}
- + {!isTeamsOrEnterprisePlan && ( diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index ca32ae51599..5a25cd7643f 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -14,7 +14,7 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import FunctionSelector from 'components/ui/FunctionSelector' import SchemaSelector from 'components/ui/SchemaSelector' import { AuthConfigResponse } from 'data/auth/auth-config-query' -import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { executeSql } from 'data/sql/execute-sql-query' import { useFlag } from 'hooks/ui/useFlag' import { @@ -42,10 +42,10 @@ import { extractMethod, getRevokePermissionStatements, isValidHook } from './hoo interface CreateHookSheetProps { visible: boolean - title: HOOK_DEFINITION_TITLE | null - authConfig: AuthConfigResponse onClose: () => void onDelete: () => void + title: HOOK_DEFINITION_TITLE | null + authConfig: AuthConfigResponse } export function generateAuthHookSecret() { @@ -109,13 +109,14 @@ const FormSchema = z export const CreateHookSheet = ({ visible, - title, - authConfig, onClose, onDelete, + title, + authConfig, }: CreateHookSheetProps) => { const { ref: projectRef } = useParams() const { project } = useProjectContext() + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() const httpsAuthHooksEnabled = useFlag('httpsAuthHooksEnabled') const definition = useMemo( @@ -154,79 +155,6 @@ export const CreateHookSheet = ({ }, }) - const values = form.watch() - - const statements = useMemo(() => { - let permissionChanges: string[] = [] - if (hook.method.type === 'postgres') { - if ( - hook.method.schema !== '' && - hook.method.functionName !== '' && - hook.method.functionName !== values.postgresValues.functionName - ) { - permissionChanges = getRevokePermissionStatements( - hook.method.schema, - hook.method.functionName - ) - } - } - - if (values.postgresValues.functionName !== '') { - permissionChanges = [ - ...permissionChanges, - `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, - `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, - `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, - ] - } - return permissionChanges - }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) - - const { mutate: updateAuthHooks, isLoading: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ - onSuccess: () => { - toast.success(`Successfully created ${values.hookType}.`) - if (statements.length > 0) { - executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: statements.join('\n'), - }) - } - onClose() - }, - onError: (error) => { - toast.error(`Failed to create hook: ${error.message}`) - }, - }) - - const onSubmit: SubmitHandler> = async (values) => { - if (!project) return console.error('Project is required') - const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) - - if (!definition) { - return - } - - const enabledLabel = definition.enabledKey - const uriLabel = definition.uriKey - const secretsLabel = definition.secretsKey - - let url = '' - if (values.selectedType === 'postgres') { - url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` - } else { - url = values.httpsValues.url - } - - const payload = { - [enabledLabel]: values.enabled, - [uriLabel]: url, - [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, - } - - updateAuthHooks({ projectRef: projectRef!, config: payload }) - } - useEffect(() => { if (visible) { if (definition) { @@ -266,6 +194,81 @@ export const CreateHookSheet = ({ } }, [authConfig, title, visible, definition]) + const values = form.watch() + + const statements = useMemo(() => { + let permissionChanges: string[] = [] + if (hook.method.type === 'postgres') { + if ( + hook.method.schema !== '' && + hook.method.functionName !== '' && + hook.method.functionName !== values.postgresValues.functionName + ) { + permissionChanges = getRevokePermissionStatements( + hook.method.schema, + hook.method.functionName + ) + } + } + + if (values.postgresValues.functionName !== '') { + permissionChanges = [ + ...permissionChanges, + `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, + `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, + `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, + ] + } + return permissionChanges + }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) + + const onSubmit: SubmitHandler> = async (values) => { + if (!project) return console.error('Project is required') + const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) + + if (!definition) { + return + } + + const enabledLabel = definition.enabledKey + const uriLabel = definition.uriKey + const secretsLabel = definition.secretsKey + + let url = '' + if (values.selectedType === 'postgres') { + url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` + } else { + url = values.httpsValues.url + } + + const payload = { + [enabledLabel]: values.enabled, + [uriLabel]: url, + [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, + } + + updateAuthConfig( + { projectRef: projectRef!, config: payload }, + { + onSuccess: () => { + toast.success(`Successfully created ${values.hookType}.`) + if (statements.length > 0) { + executeSql({ + projectRef, + connectionString: project.connectionString, + sql: statements.join('\n'), + }) + } + onClose() + }, + onError: (error) => { + toast.error(`Failed to create hook: ${error.message}`) + onClose() + }, + } + ) + } + return ( onClose()}> @@ -489,16 +492,16 @@ export const CreateHookSheet = ({
)} - diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index 9850a9a51da..38e78f349cc 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -1,20 +1,18 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { Check, Webhook } from 'lucide-react' import { Badge, Input } from 'ui' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { copyToClipboard } from 'lib/helpers' import { Hook } from './hooks.constants' interface HookCardProps { hook: Hook + canUpdateConfig: boolean + onToggle: (enabled: boolean) => void onSelect: () => void } -export const HookCard = ({ hook, onSelect }: HookCardProps) => { - const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') - +export const HookCard = ({ hook, canUpdateConfig, onToggle, onSelect }: HookCardProps) => { return (
@@ -95,7 +93,7 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => {
onSelect()} tooltip={{ content: { diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index 37c469af4ac..86e4149705d 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -1,21 +1,23 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' -import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { FormHeader } from 'components/ui/Forms/FormHeader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' -import { executeSql } from 'data/sql/execute-sql-query' -import { cn } from 'ui' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { AddHookDropdown } from './AddHookDropdown' import { CreateHookSheet } from './CreateHookSheet' import { HookCard } from './HookCard' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' +import { executeSql } from 'data/sql/execute-sql-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import CodeEditor from 'components/ui/CodeEditor/CodeEditor' +import { cn } from 'ui' export const HooksListing = () => { const { ref: projectRef } = useParams() @@ -25,27 +27,8 @@ export const HooksListing = () => { const [selectedHook, setSelectedHook] = useState(null) const [selectedHookForDeletion, setSelectedHookForDeletion] = useState(null) - const { mutate: updateAuthHooks, isLoading: isDeletingAuthHook } = useAuthHooksUpdateMutation({ - onSuccess: async () => { - if (!selectedHookForDeletion) return - - const { method } = selectedHookForDeletion - if (method.type === 'postgres') { - const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) - await executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: revokeStatements.join('\n'), - }) - } - toast.success(`${selectedHookForDeletion.title} has been deleted.`) - setSelectedHookForDeletion(null) - setSelectedHook(null) - }, - onError: (error) => { - toast.error(`Failed to delete hook: ${error.message}`) - }, - }) + const { mutateAsync: updateAuthConfig } = useAuthConfigUpdateMutation() + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -95,7 +78,16 @@ export const HooksListing = () => { setSelectedHook(hook.title)} + canUpdateConfig={canUpdateConfig} + onToggle={(enabled) => + updateAuthConfig({ + projectRef: projectRef!, + config: { [hook.enabledKey]: enabled }, + }) + } + onSelect={() => { + setSelectedHook(hook.title) + }} /> ) })} @@ -106,7 +98,9 @@ export const HooksListing = () => { visible={!!selectedHook} onDelete={() => { const hook = hooks.find((h) => h.title === selectedHook) - if (hook) setSelectedHookForDeletion(hook) + if (hook) { + setSelectedHookForDeletion(hook) + } }} onClose={() => setSelectedHook(null)} authConfig={authConfig!} @@ -116,14 +110,16 @@ export const HooksListing = () => { visible={!!selectedHookForDeletion} size="large" variant="destructive" - loading={isDeletingAuthHook} - title={`Confirm to delete ${selectedHookForDeletion?.title}`} + title="Confirm to delete" confirmLabel="Delete" confirmLabelLoading="Deleting" onCancel={() => setSelectedHookForDeletion(null)} - onConfirm={() => { - if (!selectedHookForDeletion) return - updateAuthHooks({ + onConfirm={async () => { + if (!selectedHookForDeletion) { + return + } + + await updateAuthConfig({ projectRef: projectRef!, config: { [selectedHookForDeletion.enabledKey]: false, @@ -131,20 +127,35 @@ export const HooksListing = () => { [selectedHookForDeletion.secretsKey]: null, }, }) + + const { method } = selectedHookForDeletion + + if (method.type === 'postgres') { + const revokeStatements = getRevokePermissionStatements( + method.schema, + method.functionName + ) + await executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: revokeStatements.join('\n'), + }) + } + toast.success(`${selectedHookForDeletion.title} has been deleted.`) + setSelectedHookForDeletion(null) + setSelectedHook(null) }} >
-

- Are you sure you want to delete the {selectedHookForDeletion?.title}? +

+ {`Are you sure you want to delete the ${selectedHookForDeletion?.title}?`}

{selectedHookForDeletion?.method.type === 'postgres' && ( <> -

- The following statements will be executed on the{' '} - {selectedHookForDeletion?.method.schema}. - {selectedHookForDeletion?.method.functionName} function: +

+ {`The following statements will be executed on the ${selectedHookForDeletion?.method.schema}.${selectedHookForDeletion?.method.functionName} function:`}

-
+
{ const [showCreateSecret, setShowCreateSecret] = useState(false) const [selectedSecret, setSelectedSecret] = useState() - const canReadSecrets = useCheckPermissions(PermissionAction.SECRETS_READ, '*') - const canUpdateSecrets = useCheckPermissions(PermissionAction.SECRETS_WRITE, '*') + const canReadSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*') + const canUpdateSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') const { data, error, isLoading, isSuccess, isError } = useSecretsQuery({ projectRef: projectRef, diff --git a/apps/studio/data/auth/auth-hooks-update-mutation.ts b/apps/studio/data/auth/auth-hooks-update-mutation.ts deleted file mode 100644 index 8b8ebecffe6..00000000000 --- a/apps/studio/data/auth/auth-hooks-update-mutation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import type { components } from 'data/api' -import { handleError, patch } from 'data/fetchers' -import type { ResponseError } from 'types' -import { authKeys } from './keys' - -export type AuthHooksUpdateVariables = { - projectRef: string - config: components['schemas']['UpdateGoTrueConfigHooksBody'] -} - -export async function updateAuthHooks({ projectRef, config }: AuthHooksUpdateVariables) { - const { data, error } = await patch('/platform/auth/{ref}/config/hooks', { - params: { path: { ref: projectRef } }, - body: config, - }) - - if (error) handleError(error) - return data -} - -type AuthHooksUpdateData = Awaited> - -export const useAuthHooksUpdateMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseMutationOptions, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation( - (vars) => updateAuthHooks(vars), - { - async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(authKeys.authConfig(projectRef)) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to update auth hooks: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/package.json b/apps/studio/package.json index 6131743e9b8..55ad9b69b3d 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -40,7 +40,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.73", + "@supabase/shared-types": "0.1.70", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", diff --git a/package-lock.json b/package-lock.json index 82c222e368b..884657ab1ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1826,7 +1826,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.73", + "@supabase/shared-types": "0.1.70", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", @@ -14739,9 +14739,9 @@ } }, "node_modules/@supabase/shared-types": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.73.tgz", - "integrity": "sha512-5BhGYrJ/ICBlIWdAZL3lRD2w6ZUAYvjpulvIkHfVKNcXpF3LpZ+2ONRDkm0B8dlWp+ZE1bm7DbFgJvoW+AT3XA==" + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.70.tgz", + "integrity": "sha512-Z/JBbISlxMcOjFcomZdB5AGKrQdTy/dy3HQ8EpHx3xKz1yktbUDrzVcEFUKidRPrQu1GAt9JSW6qBsBlXHcyLA==" }, "node_modules/@supabase/sql-to-rest": { "version": "0.1.6", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 3a85142fd88..15d57c796ef 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -101,10 +101,6 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } - '/platform/auth/{ref}/config/hooks': { - /** Updates GoTrue config hooks */ - patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] - } '/platform/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -126,24 +122,10 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/platform/auth/{ref}/users': { - /** - * Gets users - * @deprecated - */ + /** Gets users */ get: operations['UsersController_getUsers'] - /** Creates user */ - post: operations['UsersController_createUser'] - /** - * Delete user with given ID - * @deprecated - */ - delete: operations['UsersController_deleteUser'] - } - '/platform/auth/{ref}/users/{id}': { /** Delete user with given ID */ - delete: operations['UsersController_deleteUserById'] - /** Updates user with given ID */ - patch: operations['UsersController_updateUserById'] + delete: operations['UsersController_deleteUser'] } '/platform/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ @@ -1308,10 +1290,6 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } - '/v0/auth/{ref}/config/hooks': { - /** Updates GoTrue config hooks */ - patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] - } '/v0/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -1333,24 +1311,10 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/v0/auth/{ref}/users': { - /** - * Gets users - * @deprecated - */ + /** Gets users */ get: operations['UsersController_getUsers'] - /** Creates user */ - post: operations['UsersController_createUser'] - /** - * Delete user with given ID - * @deprecated - */ - delete: operations['UsersController_deleteUser'] - } - '/v0/auth/{ref}/users/{id}': { /** Delete user with given ID */ - delete: operations['UsersController_deleteUserById'] - /** Updates user with given ID */ - patch: operations['UsersController_updateUserById'] + delete: operations['UsersController_deleteUser'] } '/v0/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ @@ -3062,11 +3026,6 @@ export interface components { table: string table_id?: number } - CreateUserBody: { - email: string - email_confirm: boolean - password: string - } CreateUserContentFolderResponse: { id: string name: string @@ -3074,43 +3033,6 @@ export interface components { parent_id?: string | null project_id: number } - CreateUserReponse: { - aud?: string - banned_until?: string - confirmation_sent_at?: string - confirmation_token?: string - confirmed_at?: string - created_at?: string - deleted_at?: string - email?: string - email_change?: string - email_change_confirm_status?: number - email_change_sent_at?: string - email_change_token_current?: string - email_change_token_new?: string - email_confirmed_at?: string - encrypted_password?: string - id?: string - instance_id?: string - invited_at?: string - is_anonymous?: boolean - is_sso_user?: boolean - is_super_admin?: boolean - last_sign_in_at?: string - phone?: string - phone_change?: string - phone_change_sent_at?: string - phone_change_token?: string - phone_confirmed_at?: string - raw_app_meta_data?: Record - raw_user_meta_data?: Record - reauthentication_sent_at?: string - reauthentication_token?: string - recovery_sent_at?: string - recovery_token?: string - role?: string - updated_at?: string - } CreateVercelConnectionResponse: { env_sync_error?: components['schemas']['SyncVercelEnvError'] id: string @@ -6280,23 +6202,6 @@ export interface components { SMTP_USER?: string URI_ALLOW_LIST?: string } - UpdateGoTrueConfigHooksBody: { - HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean - HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string - HOOK_CUSTOM_ACCESS_TOKEN_URI?: string - HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED?: boolean - HOOK_MFA_VERIFICATION_ATTEMPT_SECRETS?: string - HOOK_MFA_VERIFICATION_ATTEMPT_URI?: string - HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED?: boolean - HOOK_PASSWORD_VERIFICATION_ATTEMPT_SECRETS?: string - HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI?: string - HOOK_SEND_EMAIL_ENABLED?: boolean - HOOK_SEND_EMAIL_SECRETS?: string - HOOK_SEND_EMAIL_URI?: string - HOOK_SEND_SMS_ENABLED?: boolean - HOOK_SEND_SMS_SECRETS?: string - HOOK_SEND_SMS_URI?: string - } UpdateMemberBody: { role_id: number } @@ -6515,46 +6420,6 @@ export interface components { table?: string table_id?: number } - UpdateUserBody: { - ban_duration?: string - } - UpdateUserReponse: { - aud?: string - banned_until?: string - confirmation_sent_at?: string - confirmation_token?: string - confirmed_at?: string - created_at?: string - deleted_at?: string - email?: string - email_change?: string - email_change_confirm_status?: number - email_change_sent_at?: string - email_change_token_current?: string - email_change_token_new?: string - email_confirmed_at?: string - encrypted_password?: string - id?: string - instance_id?: string - invited_at?: string - is_anonymous?: boolean - is_sso_user?: boolean - is_super_admin?: boolean - last_sign_in_at?: string - phone?: string - phone_change?: string - phone_change_sent_at?: string - phone_change_token?: string - phone_confirmed_at?: string - raw_app_meta_data?: Record - raw_user_meta_data?: Record - reauthentication_sent_at?: string - reauthentication_token?: string - recovery_sent_at?: string - recovery_token?: string - role?: string - updated_at?: string - } UpdateVercelConnectionsBody: { env_sync_targets?: ('production' | 'preview' | 'development')[] public_env_var_prefix?: string @@ -7275,34 +7140,6 @@ export interface operations { } } } - /** Updates GoTrue config hooks */ - GoTrueConfigController_updateGoTrueConfigHooks: { - parameters: { - path: { - /** @description Project ref */ - ref: string - } - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateGoTrueConfigHooksBody'] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['GoTrueConfigResponse'] - } - } - 403: { - content: never - } - /** @description Failed to update GoTrue config hooks */ - 500: { - content: never - } - } - } /** Sends an invite to the given email */ AuthInviteController_sendInvite: { parameters: { @@ -7441,10 +7278,7 @@ export interface operations { } } } - /** - * Gets users - * @deprecated - */ + /** Gets users */ UsersController_getUsers: { parameters: { query: { @@ -7473,38 +7307,7 @@ export interface operations { } } } - /** Creates user */ - UsersController_createUser: { - parameters: { - path: { - /** @description Project ref */ - ref: string - } - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateUserBody'] - } - } - responses: { - 201: { - content: { - 'application/json': components['schemas']['CreateUserReponse'] - } - } - 403: { - content: never - } - /** @description Failed to create user */ - 500: { - content: never - } - } - } - /** - * Delete user with given ID - * @deprecated - */ + /** Delete user with given ID */ UsersController_deleteUser: { parameters: { path: { @@ -7532,57 +7335,6 @@ export interface operations { } } } - /** Delete user with given ID */ - UsersController_deleteUserById: { - parameters: { - path: { - /** @description Project ref */ - ref: string - id: string - } - } - responses: { - 200: { - content: never - } - 403: { - content: never - } - /** @description Failed to delete user */ - 500: { - content: never - } - } - } - /** Updates user with given ID */ - UsersController_updateUserById: { - parameters: { - path: { - /** @description Project ref */ - ref: string - id: string - } - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateUserBody'] - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['UpdateUserReponse'] - } - } - 403: { - content: never - } - /** @description Failed to update user with given ID */ - 500: { - content: never - } - } - } /** Delete all factors associated to a user */ FactorsController_deleteFactors: { parameters: { @@ -12423,9 +12175,6 @@ export interface operations { 'application/json': components['schemas']['UpdateSecretsResponse'] } } - 403: { - content: never - } /** @description Failed to update project's secrets config */ 500: { content: never From cdb07346bcc17944693ac712467b1d1e02289c09 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 15 Oct 2024 16:32:37 +0800 Subject: [PATCH 027/255] Update PH consent banner (#29891) * Update PH consent banner * Fix --- .../Account/Preferences/AnalyticsSettings.tsx | 11 +++- apps/studio/components/ui/PageTelemetry.tsx | 58 +++++++++++++++---- .../data/telemetry/send-event-mutation.ts | 27 ++++++--- .../send-groups-identify-mutation.ts | 23 +++++++- .../telemetry/send-groups-reset-mutation.ts | 23 +++++++- .../data/telemetry/send-identify-mutation.ts | 29 +++++++--- .../telemetry/send-page-leave-mutation.ts | 24 +++++++- .../data/telemetry/send-page-mutation.ts | 24 +++++++- .../data/telemetry/send-reset-mutation.ts | 2 +- apps/studio/lib/constants/index.ts | 2 + apps/studio/next-env.d.ts | 2 +- apps/studio/pages/_app.tsx | 31 ---------- apps/studio/state/app-state.ts | 13 +++-- packages/ui-patterns/ConsentToast/index.tsx | 6 +- 14 files changed, 196 insertions(+), 79 deletions(-) diff --git a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx index 779e48e5c07..16f566b15a7 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AnalyticsSettings.tsx @@ -1,16 +1,23 @@ import { Toggle } from 'ui' import Panel from 'components/ui/Panel' -import { useAppStateSnapshot } from 'state/app-state' import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' +import { useFlag } from 'hooks/ui/useFlag' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' const AnalyticsSettings = () => { const snap = useAppStateSnapshot() + const enablePostHogTelemetry = useFlag('enablePosthogChanges') const { mutate: sendReset } = useSendResetMutation() + const consentFlag = enablePostHogTelemetry + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + const onToggleOptIn = () => { const value = !snap.isOptedInTelemetry ? 'true' : 'false' - snap.setIsOptedInTelemetry(value === 'true') + snap.setIsOptedInTelemetry(value === 'true', consentFlag) if (value === 'false') sendReset() } diff --git a/apps/studio/components/ui/PageTelemetry.tsx b/apps/studio/components/ui/PageTelemetry.tsx index 88e10f0beab..ad98b7e96ac 100644 --- a/apps/studio/components/ui/PageTelemetry.tsx +++ b/apps/studio/components/ui/PageTelemetry.tsx @@ -1,7 +1,8 @@ import { Sha256 } from '@aws-crypto/sha256-browser' import * as Sentry from '@sentry/nextjs' import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useState } from 'react' +import { PropsWithChildren, useEffect, useRef } from 'react' +import { toast } from 'sonner' import { useParams, useUser } from 'common' import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identify-mutation' @@ -13,6 +14,7 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' +import { ConsentToast } from 'ui-patterns/ConsentToast' const getAnonId = async (id: string) => { const hash = new Sha256() @@ -30,12 +32,14 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { const snap = useAppStateSnapshot() const organization = useSelectedOrganization() + const consentToastId = useRef() const previousPathname = usePrevious(router.pathname) const enablePostHogTelemetry = useFlag('enablePosthogChanges') - const consent = - typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null + + const consentFlag = enablePostHogTelemetry + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + const consent = typeof window !== 'undefined' ? localStorage.getItem(consentFlag) : null const trackTelemetryPH = enablePostHogTelemetry && consent === 'true' const { mutate: sendPage } = useSendPageMutation() @@ -44,13 +48,45 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { const { mutate: sendGroupsReset } = useSendGroupsResetMutation() useEffect(() => { - const consent = - typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null - if (consent !== null) snap.setIsOptedInTelemetry(consent === 'true') + if (typeof window !== 'undefined' && enablePostHogTelemetry !== undefined) { + const onAcceptConsent = () => { + snap.setIsOptedInTelemetry(true, consentFlag) + if (consentToastId.current) toast.dismiss(consentToastId.current) + } + + const onOptOut = () => { + snap.setIsOptedInTelemetry(false, consentFlag) + if (consentToastId.current) toast.dismiss(consentToastId.current) + } + + const hasAcknowledgedConsent = localStorage.getItem( + enablePostHogTelemetry + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + ) + snap.setIsOptedInTelemetry( + typeof hasAcknowledgedConsent === 'string' + ? hasAcknowledgedConsent === 'true' + : hasAcknowledgedConsent, + consentFlag + ) + + if (IS_PLATFORM && hasAcknowledgedConsent === null) { + setTimeout(() => { + consentToastId.current = toast( + , + { + id: 'consent-toast', + position: 'bottom-right', + duration: Infinity, + } + ) + }, 300) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [enablePostHogTelemetry]) useEffect(() => { function handleRouteChange() { diff --git a/apps/studio/data/telemetry/send-event-mutation.ts b/apps/studio/data/telemetry/send-event-mutation.ts index 34add0c887d..c2a22289c48 100644 --- a/apps/studio/data/telemetry/send-event-mutation.ts +++ b/apps/studio/data/telemetry/send-event-mutation.ts @@ -20,12 +20,16 @@ export type SendEventVariables = { type SendEventPayload = any -export async function sendEvent(type: 'GA' | 'PH', body: SendEventPayload) { - const consent = - typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null - if (consent !== 'true') return +export async function sendEvent({ + consent, + type, + body, +}: { + consent: boolean + type: 'GA' | 'PH' + body: SendEventPayload +}) { + if (!consent) return undefined const headers = type === 'PH' ? { Version: '2' } : undefined const { data, error } = await post(`/platform/telemetry/event`, { body, headers }) @@ -46,6 +50,15 @@ export const useSendEventMutation = ({ const router = useRouter() const usePostHogParameters = useFlag('enablePosthogChanges') + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem( + usePostHogParameters + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + ) + : null) === 'true' + const title = typeof document !== 'undefined' ? document?.title : '' const referrer = typeof document !== 'undefined' ? document?.referrer : '' @@ -85,7 +98,7 @@ export const useSendEventMutation = ({ custom_properties: otherVars, } as unknown as SendEventPH) : ({ ...vars, ...payload } as SendEventGA) - return sendEvent(type, body) + return sendEvent({ consent, type, body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/telemetry/send-groups-identify-mutation.ts b/apps/studio/data/telemetry/send-groups-identify-mutation.ts index 1b3d241d4a4..04bd2673623 100644 --- a/apps/studio/data/telemetry/send-groups-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-identify-mutation.ts @@ -2,12 +2,24 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' import { handleError, post } from 'data/fetchers' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import type { ResponseError } from 'types' export type SendGroupsIdentifyVariables = components['schemas']['TelemetryGroupsIdentityBody'] -export async function sendGroupsIdentify(body: SendGroupsIdentifyVariables) { - const { data, error } = await post(`/platform/telemetry/groups/identify`, { body }) +export async function sendGroupsIdentify({ + consent, + body, +}: { + consent: boolean + body: SendGroupsIdentifyVariables +}) { + if (!consent) return undefined + + const { data, error } = await post(`/platform/telemetry/groups/identify`, { + body, + credentials: 'include', + }) if (error) handleError(error) return data } @@ -22,8 +34,13 @@ export const useSendGroupsIdentifyMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) + : null) === 'true' + return useMutation( - (vars) => sendGroupsIdentify(vars), + (vars) => sendGroupsIdentify({ consent, body: vars }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/telemetry/send-groups-reset-mutation.ts b/apps/studio/data/telemetry/send-groups-reset-mutation.ts index 4d6e214d481..3523235426c 100644 --- a/apps/studio/data/telemetry/send-groups-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-reset-mutation.ts @@ -2,12 +2,24 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' import { handleError, post } from 'data/fetchers' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import type { ResponseError } from 'types' export type SendGroupsResetVariables = components['schemas']['TelemetryGroupsResetBody'] -export async function sendGroupsReset(body: SendGroupsResetVariables) { - const { data, error } = await post(`/platform/telemetry/groups/reset`, { body }) +export async function sendGroupsReset({ + consent, + body, +}: { + consent: boolean + body: SendGroupsResetVariables +}) { + if (!consent) return undefined + + const { data, error } = await post(`/platform/telemetry/groups/reset`, { + body, + credentials: 'include', + }) if (error) handleError(error) return data } @@ -22,8 +34,13 @@ export const useSendGroupsResetMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) + : null) === 'true' + return useMutation( - (vars) => sendGroupsReset(vars), + (vars) => sendGroupsReset({ consent, body: vars }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/telemetry/send-identify-mutation.ts b/apps/studio/data/telemetry/send-identify-mutation.ts index 76d9eb5ab3d..ad63eb75d6f 100644 --- a/apps/studio/data/telemetry/send-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-identify-mutation.ts @@ -1,4 +1,5 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { useRouter } from 'next/router' import { components } from 'api-types' import { isBrowser } from 'common' @@ -6,7 +7,6 @@ import { handleError, post } from 'data/fetchers' import { Profile } from 'data/profile/types' import { useFlag } from 'hooks/ui/useFlag' import { LOCAL_STORAGE_KEYS } from 'lib/constants' -import { useRouter } from 'next/router' import type { ResponseError } from 'types' type SendIdentifyGA = components['schemas']['TelemetryIdentifyBody'] @@ -20,12 +20,16 @@ export type SendIdentifyVariables = { type SendIdentifyPayload = any -export async function sendIdentify(type: 'GA' | 'PH', body: SendIdentifyPayload) { - const consent = - typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null - if (consent !== 'true') return +export async function sendIdentify({ + consent, + type, + body, +}: { + consent: boolean + type: 'GA' | 'PH' + body: SendIdentifyPayload +}) { + if (!consent) return undefined const headers = type === 'PH' ? { Version: '2' } : undefined const { data, error } = await post(`/platform/telemetry/identify`, { body, headers }) @@ -46,6 +50,15 @@ export const useSendIdentifyMutation = ({ const router = useRouter() const usePostHogParameters = useFlag('enablePosthogChanges') + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem( + usePostHogParameters + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + ) + : null) === 'true' + const payload = usePostHogParameters ? ({} as SendIdentifyPH) : ({ @@ -67,7 +80,7 @@ export const useSendIdentifyMutation = ({ project_ref: ref, } as SendIdentifyPH) : ({ user: userInfo, ...payload } as SendIdentifyGA) - return sendIdentify(type, body) + return sendIdentify({ consent, type, body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/telemetry/send-page-leave-mutation.ts b/apps/studio/data/telemetry/send-page-leave-mutation.ts index fc458c2f449..ad187b4ea07 100644 --- a/apps/studio/data/telemetry/send-page-leave-mutation.ts +++ b/apps/studio/data/telemetry/send-page-leave-mutation.ts @@ -2,13 +2,26 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' import { handleError, post } from 'data/fetchers' +import { useFlag } from 'hooks/ui/useFlag' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useRouter } from 'next/router' import type { ResponseError } from 'types' type SendPageLeaveBody = components['schemas']['TelemetryPageLeaveBody'] -export async function sendPageLeave(body: SendPageLeaveBody) { - const { data, error } = await post(`/platform/telemetry/page-leave`, { body }) +export async function sendPageLeave({ + consent, + body, +}: { + consent: boolean + body: SendPageLeaveBody +}) { + if (!consent) return undefined + + const { data, error } = await post(`/platform/telemetry/page-leave`, { + body, + credentials: 'include', + }) if (error) handleError(error) return data } @@ -21,6 +34,11 @@ export const useSendPageLeaveMutation = ({ ...options }: Omit, 'mutationFn'> = {}) => { const router = useRouter() + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) + : null) === 'true' + const url = typeof window !== 'undefined' ? window.location.href : '' const title = typeof document !== 'undefined' ? document?.title : '' @@ -30,7 +48,7 @@ export const useSendPageLeaveMutation = ({ pathname: router.pathname, } as SendPageLeaveBody - return useMutation((vars) => sendPageLeave(body), { + return useMutation((vars) => sendPageLeave({ consent, body }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/telemetry/send-page-mutation.ts b/apps/studio/data/telemetry/send-page-mutation.ts index 4d357c1be2a..6d3d7a67499 100644 --- a/apps/studio/data/telemetry/send-page-mutation.ts +++ b/apps/studio/data/telemetry/send-page-mutation.ts @@ -4,6 +4,7 @@ import { components } from 'api-types' import { isBrowser } from 'common' import { handleError, post } from 'data/fetchers' import { useFlag } from 'hooks/ui/useFlag' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useRouter } from 'next/router' import type { ResponseError } from 'types' @@ -16,7 +17,17 @@ export type SendPageVariables = { type SendPagePayload = any -export async function sendPage(type: 'GA' | 'PH', body: SendPagePayload) { +export async function sendPage({ + consent, + type, + body, +}: { + consent: boolean + type: 'GA' | 'PH' + body: SendPagePayload +}) { + if (!consent) return undefined + const headers = type === 'PH' ? { Version: '2' } : undefined const { data, error } = await post(`/platform/telemetry/page`, { body, headers }) if (error) handleError(error) @@ -33,6 +44,15 @@ export const useSendPageMutation = ({ const router = useRouter() const usePostHogParameters = useFlag('enablePosthogChanges') + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem( + usePostHogParameters + ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH + : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + ) + : null) === 'true' + const title = typeof document !== 'undefined' ? document?.title : '' const referrer = typeof document !== 'undefined' ? document?.referrer : '' @@ -65,7 +85,7 @@ export const useSendPageMutation = ({ const body = usePostHogParameters ? ({ page_url: url, ...payload } as SendPagePH) : ({ route: url, ...payload } as SendPageGA) - return sendPage(type, body) + return sendPage({ consent, type, body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/telemetry/send-reset-mutation.ts b/apps/studio/data/telemetry/send-reset-mutation.ts index c1056d8bf8f..070824b9f67 100644 --- a/apps/studio/data/telemetry/send-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-reset-mutation.ts @@ -16,7 +16,7 @@ export const useSendResetMutation = ({ onError, ...options }: Omit, 'mutationFn'> = {}) => { - return useMutation((vars) => sendReset(), { + return useMutation(() => sendReset(), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 3fe036d1d63..da484ef951d 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -30,6 +30,8 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const LOCAL_STORAGE_KEYS = { RECENTLY_VISITED_ORGANIZATION: 'supabase-organization', TELEMETRY_CONSENT: 'supabase-consent', + // Used for when we enable PH for telemetry instead of GA + TELEMETRY_CONSENT_PH: 'supabase-consent-ph', UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', diff --git a/apps/studio/next-env.d.ts b/apps/studio/next-env.d.ts index 4f11a03dc6c..a4a7b3f5cfa 100644 --- a/apps/studio/next-env.d.ts +++ b/apps/studio/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 8e5989df167..b1c6e7fff68 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -54,7 +54,6 @@ import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { AppPropsWithLayout } from 'types' import { SonnerToaster } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' -import { ConsentToast } from 'ui-patterns/ConsentToast' dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -119,36 +118,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { console.error(error.stack) } - useEffect(() => { - // Check for telemetry consent - if (typeof window !== 'undefined') { - const onAcceptConsent = () => { - snap.setIsOptedInTelemetry(true) - if (consentToastId.current) toast.dismiss(consentToastId.current) - } - - const onOptOut = () => { - snap.setIsOptedInTelemetry(false) - if (consentToastId.current) toast.dismiss(consentToastId.current) - } - - const hasAcknowledgedConsent = localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - if (IS_PLATFORM && hasAcknowledgedConsent === null) { - setTimeout(() => { - consentToastId.current = toast( - , - { - id: 'consent-toast', - position: 'bottom-right', - duration: Infinity, - } - ) - }, 300) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - useThemeSandbox() const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 4aa575e3702..7f4246a26dc 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -36,11 +36,16 @@ export const appState = proxy({ }, isOptedInTelemetry: false, - setIsOptedInTelemetry: (value: boolean) => { - appState.isOptedInTelemetry = value - if (typeof window !== 'undefined') { - localStorage.setItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) + setIsOptedInTelemetry: (value: boolean | null, flag = LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) => { + appState.isOptedInTelemetry = value === null ? false : value + if (typeof window !== 'undefined' && value !== null) { + localStorage.setItem(flag, value.toString()) } + // [Joshen] Eventually once we completely move to PH, we should just set local storage + // directly here, but since currently it's dependent on a feature flag, will need to pass in as a prop + // if (typeof window !== 'undefined') { + // localStorage.setItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) + // } }, showEnableBranchingModal: false, setShowEnableBranchingModal: (value: boolean) => { diff --git a/packages/ui-patterns/ConsentToast/index.tsx b/packages/ui-patterns/ConsentToast/index.tsx index 64011845e3d..8a45ede2242 100644 --- a/packages/ui-patterns/ConsentToast/index.tsx +++ b/packages/ui-patterns/ConsentToast/index.tsx @@ -22,10 +22,10 @@ export const ConsentToast = ({ onAccept = noop, onOptOut = noop }: ConsentToastP const isMobile = useBreakpoint(639) return ( -
+
-

- We only collect analytics essential to ensuring smooth operation of our services.{' '} +

+ We use first-party cookies to improve our services. Date: Tue, 15 Oct 2024 04:59:00 -0400 Subject: [PATCH 028/255] Update humans.txt (#29885) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index fa2f4f9dad2..d561d77e682 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -31,6 +31,7 @@ Egor Romanov Etienne Stalmans Fabrizio Fenoglio Francesco Sansalvadore +Greg P Greg Richardson Guilherme Souza Haydn Maley From 50cd9ee5dc811490d29f7628233d94db858de06b Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 15 Oct 2024 09:44:25 -0230 Subject: [PATCH 029/255] Chore/cron nudges (#29797) * Remove beginning empty state * Change modal to destructive * Reorder dropdown menu * Spacing --- .../Database/CronJobs/CronJobCard.tsx | 8 +- .../CronJobs/CronJobsDisabledState.tsx | 95 +++++++-------- .../Database/CronJobs/CronJobsListing.tsx | 113 ++++++++---------- .../Database/CronJobs/DeleteCronJob.tsx | 2 +- .../Database/CronJobs/EdgeFunctionSection.tsx | 2 +- .../CronJobs/HttpHeaderFieldsSection.tsx | 4 +- .../CronJobs/HttpParameterFieldsSection.tsx | 2 +- 7 files changed, 105 insertions(+), 121 deletions(-) diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx b/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx index 04e3124409f..fd6e123e6eb 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx +++ b/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx @@ -77,6 +77,9 @@ export const CronJobCard = ({ job, onEditCronJob, onDeleteCronJob }: CronJobCard - - - - - {!canToggleExtensions && ( - - - -

- - You need additional permissions to enable pg_cron for this project - -
- - - )} - -
+
+ +
+

+ Cron jobs in PostgreSQL allow you to schedule and automate tasks such as running SQL + queries or maintenance routines at specified intervals. +

+

Enable cron jobs in your project.

-
+ +
+ + + + + {!canToggleExtensions && ( + + + +
+ + You need additional permissions to enable pg_cron for this project + +
+
+
+ )} +
+ +
+
{ return ( <> - {(cronJobs ?? []).length == 0 ? ( -
- - setCreateCronJobSheetShown({ - jobname: '', - schedule: '', - command: '', - active: true, - }) - } - > -

- Cron jobs in PostgreSQL allow you to schedule and automate tasks such as running SQL - queries or maintenance routines at specified intervals. These jobs are managed using - cron-like syntax and are executed directly within the PostgreSQL server, making it - easy to schedule recurring tasks without needing an external scheduler. -

-
-
- ) : ( -
- - - - - } - /> - {cronJobs.map((job) => ( - setCreateCronJobSheetShown(job)} - onDeleteCronJob={(job) => setCronJobForDeletion(job)} - /> - ))} -
- )} + Documentation + + + + + } + /> + {(cronJobs ?? []).length == 0 ? ( +
+

No cron jobs created yet

+

+ Create one by clicking "Create a new cron job" +

+
+ ) : ( +
+ {cronJobs.map((job) => ( + setCreateCronJobSheetShown(job)} + onDeleteCronJob={(job) => setCronJobForDeletion(job)} + /> + ))} +
+ )} +
+ setIsClosingCreateCronJobSheet(true)} @@ -146,7 +134,6 @@ export const CronJobsListing = () => { onClose={() => setCronJobForDeletion(undefined)} cronJob={cronJobForDeletion!} /> - { return ( onClose()} onConfirm={handleDelete} diff --git a/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx b/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx index 52a622f820c..00cdf24bed6 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx +++ b/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx @@ -53,7 +53,7 @@ export const EdgeFunctionSection = ({ form }: HTTPRequestFieldsProps) => { }, [edgeFunctions, form, isSuccess, selectedProject?.ref, selectedProject?.restUrl]) return ( - + HTTP Headers -
+
{fields.map((field, index) => (
diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx b/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx index bdfa8d12b91..d00cb14571d 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx +++ b/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx @@ -25,7 +25,7 @@ export const HTTPParameterFieldsSection = ({ variant }: HTTPParameterFieldsSecti return ( HTTP Parameters -
+
{fields.map((field, index) => (
Date: Tue, 15 Oct 2024 14:42:22 +0200 Subject: [PATCH 030/255] docs: remove create an organization recommendation in supabase integration (#29898) --- .../guides/integrations/build-a-supabase-integration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/guides/integrations/build-a-supabase-integration.mdx b/apps/docs/content/guides/integrations/build-a-supabase-integration.mdx index b64028b5ec4..6a09e7dbd5b 100644 --- a/apps/docs/content/guides/integrations/build-a-supabase-integration.mdx +++ b/apps/docs/content/guides/integrations/build-a-supabase-integration.mdx @@ -147,9 +147,9 @@ postgresql://postgres:[DB-PASSWORD]@db.[REF].supabase.co:5432/postgres Note that you cannot retrieve the database password via the management API, so for the user's existing projects you will need to collect their database password in your UI. -### Create new organizations and projects +### Create new projects -If you don't need access to the user's existing project, we recommend creating a new project under a new organization. Use the [`/v1/organizations` endpoint](https://api.supabase.com/api/v1#/organizations/createOrganization) to create a new organization named after your integration, afterward use the [`/v1/projects` endpoint](https://api.supabase.com/api/v1#/projects/createProject) to create a new project. +Use the [`/v1/projects` endpoint](https://api.supabase.com/api/v1#/projects/createProject) to create a new project. When creating a new project, you can either ask the user to provide a database password, or you can generate a secure password for them. In any case, make sure to securely store the database password on your end which will allow you to construct the Postgres URI. From f6cf84858e73f91c2071a3fcac061691a8031679 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Tue, 15 Oct 2024 16:42:01 +0200 Subject: [PATCH 031/255] fix: Don't send the request twice when saving a policy from the Policy editor (#29899) Don't send the request twice when saving a policy from the Policy editor. --- .../Policies/AIPolicyEditorPanel/index.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index 6005512e4e6..b11333148e0 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -788,16 +788,18 @@ export const AIPolicyEditorPanel = memo(function ({ incomingChange !== undefined } onClick={() => { - const sql = editorOneRef.current?.getValue().trim() - if (!sql) return onSelectCancel() - executeMutation({ - sql: sql, - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - handleError: (error) => { - throw error - }, - }) + if (editView === 'conversation') { + const sql = editorOneRef.current?.getValue().trim() + if (!sql) return onSelectCancel() + executeMutation({ + sql: sql, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + handleError: (error) => { + throw error + }, + }) + } }} tooltip={{ content: { From e95b0cb277aa64dbb76894a76f4a76560dd5a38e Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Wed, 16 Oct 2024 02:11:05 +0200 Subject: [PATCH 032/255] add webinar link to auth page (#29907) add callout to auth page --- apps/www/components/Sections/ProductHeader.tsx | 4 +++- apps/www/pages/auth.tsx | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/www/components/Sections/ProductHeader.tsx b/apps/www/components/Sections/ProductHeader.tsx index 77f32e0c328..f307cf2aec7 100644 --- a/apps/www/components/Sections/ProductHeader.tsx +++ b/apps/www/components/Sections/ProductHeader.tsx @@ -12,12 +12,14 @@ interface Types { image?: React.ReactNode footer?: React.ReactNode documentation_url?: string + callout?: React.ReactNode } const ProductHeader = (props: Types) => ( -
+
+ {props.callout && props.callout}
{props.icon || props.title ? (
diff --git a/apps/www/pages/auth.tsx b/apps/www/pages/auth.tsx index f9885168f5f..34b7da5090e 100644 --- a/apps/www/pages/auth.tsx +++ b/apps/www/pages/auth.tsx @@ -7,11 +7,13 @@ import NextImage from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { Button, Image } from 'ui' +import { useBreakpoint } from 'common' import DefaultLayout from '~/components/Layouts/Default' import SectionContainer from '~/components/Layouts/SectionContainer' import ProductsNav from '~/components/Products/ProductsNav' import ProductHeader from '~/components/Sections/ProductHeader' +import EventCallout from '~/components/EventCallout' import Solutions from 'data/Solutions' import { PRODUCT_NAMES } from 'shared-data/products' @@ -26,7 +28,7 @@ const APISection = dynamic(() => import('~/components/Sections/APISection')) const GithubExamples = dynamic(() => import('~/components/Sections/GithubExamples')) function AuthPage() { - // base path for images + const isMobile = useBreakpoint(768) const { basePath } = useRouter() const meta_title = 'Auth | Built-in user management' @@ -52,6 +54,12 @@ function AuthPage() { + } icon={Solutions['authentication'].icon} title={Solutions['authentication'].name} h1={[ From 34f02f344b538ce1946ba915bf71c0c4972b20b4 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 16 Oct 2024 09:13:07 +0800 Subject: [PATCH 033/255] Revert "Revert "Update auth hooks to use new endpoint and add perms checking"" (#29894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "Revert "Update auth hooks to use new endpoint and add perms checking"…" This reverts commit a2a94d1f7402d07dbfd43246b46b562be140f05c. --- .../interfaces/Auth/Hooks/AddHookDropdown.tsx | 9 +- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 167 ++++++----- .../interfaces/Auth/Hooks/HookCard.tsx | 10 +- .../interfaces/Auth/Hooks/HooksListing.tsx | 91 +++--- .../EdgeFunctionSecrets.tsx | 4 +- .../data/auth/auth-hooks-update-mutation.ts | 54 ++++ apps/studio/package.json | 2 +- package-lock.json | 8 +- packages/api-types/types/api.d.ts | 263 +++++++++++++++++- 9 files changed, 450 insertions(+), 158 deletions(-) create mode 100644 apps/studio/data/auth/auth-hooks-update-mutation.ts diff --git a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx index f2802eafb60..12e55e0c8b2 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/AddHookDropdown.tsx @@ -16,7 +16,6 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - SheetSection, } from 'ui' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, isValidHook } from './hooks.utils' @@ -38,7 +37,7 @@ export const AddHookDropdown = ({ { enabled: IS_PLATFORM } ) const { data: authConfig } = useAuthConfigQuery({ projectRef }) - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -57,7 +56,7 @@ export const AddHookDropdown = ({ const isTeamsOrEnterprisePlan = subscription?.plan.id === 'team' || subscription?.plan.id === 'enterprise' - if (!canUpdateConfig) { + if (!canUpdateAuthHook) { return ( - +
{nonEnterpriseHookOptions.map((h) => ( onSelectHook(h.title)}> @@ -87,7 +86,7 @@ export const AddHookDropdown = ({ ))}
- + {!isTeamsOrEnterprisePlan && ( diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index 5a25cd7643f..ca32ae51599 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -14,7 +14,7 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import FunctionSelector from 'components/ui/FunctionSelector' import SchemaSelector from 'components/ui/SchemaSelector' import { AuthConfigResponse } from 'data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' import { executeSql } from 'data/sql/execute-sql-query' import { useFlag } from 'hooks/ui/useFlag' import { @@ -42,10 +42,10 @@ import { extractMethod, getRevokePermissionStatements, isValidHook } from './hoo interface CreateHookSheetProps { visible: boolean - onClose: () => void - onDelete: () => void title: HOOK_DEFINITION_TITLE | null authConfig: AuthConfigResponse + onClose: () => void + onDelete: () => void } export function generateAuthHookSecret() { @@ -109,14 +109,13 @@ const FormSchema = z export const CreateHookSheet = ({ visible, - onClose, - onDelete, title, authConfig, + onClose, + onDelete, }: CreateHookSheetProps) => { const { ref: projectRef } = useParams() const { project } = useProjectContext() - const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() const httpsAuthHooksEnabled = useFlag('httpsAuthHooksEnabled') const definition = useMemo( @@ -155,6 +154,79 @@ export const CreateHookSheet = ({ }, }) + const values = form.watch() + + const statements = useMemo(() => { + let permissionChanges: string[] = [] + if (hook.method.type === 'postgres') { + if ( + hook.method.schema !== '' && + hook.method.functionName !== '' && + hook.method.functionName !== values.postgresValues.functionName + ) { + permissionChanges = getRevokePermissionStatements( + hook.method.schema, + hook.method.functionName + ) + } + } + + if (values.postgresValues.functionName !== '') { + permissionChanges = [ + ...permissionChanges, + `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, + `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, + `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, + ] + } + return permissionChanges + }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) + + const { mutate: updateAuthHooks, isLoading: isUpdatingAuthHooks } = useAuthHooksUpdateMutation({ + onSuccess: () => { + toast.success(`Successfully created ${values.hookType}.`) + if (statements.length > 0) { + executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: statements.join('\n'), + }) + } + onClose() + }, + onError: (error) => { + toast.error(`Failed to create hook: ${error.message}`) + }, + }) + + const onSubmit: SubmitHandler> = async (values) => { + if (!project) return console.error('Project is required') + const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) + + if (!definition) { + return + } + + const enabledLabel = definition.enabledKey + const uriLabel = definition.uriKey + const secretsLabel = definition.secretsKey + + let url = '' + if (values.selectedType === 'postgres') { + url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` + } else { + url = values.httpsValues.url + } + + const payload = { + [enabledLabel]: values.enabled, + [uriLabel]: url, + [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, + } + + updateAuthHooks({ projectRef: projectRef!, config: payload }) + } + useEffect(() => { if (visible) { if (definition) { @@ -194,81 +266,6 @@ export const CreateHookSheet = ({ } }, [authConfig, title, visible, definition]) - const values = form.watch() - - const statements = useMemo(() => { - let permissionChanges: string[] = [] - if (hook.method.type === 'postgres') { - if ( - hook.method.schema !== '' && - hook.method.functionName !== '' && - hook.method.functionName !== values.postgresValues.functionName - ) { - permissionChanges = getRevokePermissionStatements( - hook.method.schema, - hook.method.functionName - ) - } - } - - if (values.postgresValues.functionName !== '') { - permissionChanges = [ - ...permissionChanges, - `-- Grant access to function to supabase_auth_admin\ngrant execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} to supabase_auth_admin;`, - `-- Grant access to schema to supabase_auth_admin\ngrant usage on schema ${values.postgresValues.schema} to supabase_auth_admin;`, - `-- Revoke function permissions from authenticated, anon and public\nrevoke execute on function ${values.postgresValues.schema}.${values.postgresValues.functionName} from authenticated, anon, public;`, - ] - } - return permissionChanges - }, [hook, values.postgresValues.schema, values.postgresValues.functionName]) - - const onSubmit: SubmitHandler> = async (values) => { - if (!project) return console.error('Project is required') - const definition = HOOKS_DEFINITIONS.find((d) => values.hookType === d.title) - - if (!definition) { - return - } - - const enabledLabel = definition.enabledKey - const uriLabel = definition.uriKey - const secretsLabel = definition.secretsKey - - let url = '' - if (values.selectedType === 'postgres') { - url = `pg-functions://postgres/${values.postgresValues.schema}/${values.postgresValues.functionName}` - } else { - url = values.httpsValues.url - } - - const payload = { - [enabledLabel]: values.enabled, - [uriLabel]: url, - [secretsLabel]: values.selectedType === 'https' ? values.httpsValues.secret : null, - } - - updateAuthConfig( - { projectRef: projectRef!, config: payload }, - { - onSuccess: () => { - toast.success(`Successfully created ${values.hookType}.`) - if (statements.length > 0) { - executeSql({ - projectRef, - connectionString: project.connectionString, - sql: statements.join('\n'), - }) - } - onClose() - }, - onError: (error) => { - toast.error(`Failed to create hook: ${error.message}`) - onClose() - }, - } - ) - } - return ( onClose()}> @@ -492,16 +489,16 @@ export const CreateHookSheet = ({
)} - diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index 38e78f349cc..9850a9a51da 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -1,18 +1,20 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Check, Webhook } from 'lucide-react' import { Badge, Input } from 'ui' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { copyToClipboard } from 'lib/helpers' import { Hook } from './hooks.constants' interface HookCardProps { hook: Hook - canUpdateConfig: boolean - onToggle: (enabled: boolean) => void onSelect: () => void } -export const HookCard = ({ hook, canUpdateConfig, onToggle, onSelect }: HookCardProps) => { +export const HookCard = ({ hook, onSelect }: HookCardProps) => { + const canUpdateAuthHook = useCheckPermissions(PermissionAction.AUTH_EXECUTE, '*') + return (
@@ -93,7 +95,7 @@ export const HookCard = ({ hook, canUpdateConfig, onToggle, onSelect }: HookCard
onSelect()} tooltip={{ content: { diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index 86e4149705d..37c469af4ac 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -1,23 +1,21 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' +import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { FormHeader } from 'components/ui/Forms/FormHeader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAuthHooksUpdateMutation } from 'data/auth/auth-hooks-update-mutation' +import { executeSql } from 'data/sql/execute-sql-query' +import { cn } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { AddHookDropdown } from './AddHookDropdown' import { CreateHookSheet } from './CreateHookSheet' import { HookCard } from './HookCard' import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants' import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils' -import { executeSql } from 'data/sql/execute-sql-query' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import CodeEditor from 'components/ui/CodeEditor/CodeEditor' -import { cn } from 'ui' export const HooksListing = () => { const { ref: projectRef } = useParams() @@ -27,8 +25,27 @@ export const HooksListing = () => { const [selectedHook, setSelectedHook] = useState(null) const [selectedHookForDeletion, setSelectedHookForDeletion] = useState(null) - const { mutateAsync: updateAuthConfig } = useAuthConfigUpdateMutation() - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const { mutate: updateAuthHooks, isLoading: isDeletingAuthHook } = useAuthHooksUpdateMutation({ + onSuccess: async () => { + if (!selectedHookForDeletion) return + + const { method } = selectedHookForDeletion + if (method.type === 'postgres') { + const revokeStatements = getRevokePermissionStatements(method.schema, method.functionName) + await executeSql({ + projectRef, + connectionString: project!.connectionString, + sql: revokeStatements.join('\n'), + }) + } + toast.success(`${selectedHookForDeletion.title} has been deleted.`) + setSelectedHookForDeletion(null) + setSelectedHook(null) + }, + onError: (error) => { + toast.error(`Failed to delete hook: ${error.message}`) + }, + }) const hooks: Hook[] = HOOKS_DEFINITIONS.map((definition) => { return { @@ -78,16 +95,7 @@ export const HooksListing = () => { - updateAuthConfig({ - projectRef: projectRef!, - config: { [hook.enabledKey]: enabled }, - }) - } - onSelect={() => { - setSelectedHook(hook.title) - }} + onSelect={() => setSelectedHook(hook.title)} /> ) })} @@ -98,9 +106,7 @@ export const HooksListing = () => { visible={!!selectedHook} onDelete={() => { const hook = hooks.find((h) => h.title === selectedHook) - if (hook) { - setSelectedHookForDeletion(hook) - } + if (hook) setSelectedHookForDeletion(hook) }} onClose={() => setSelectedHook(null)} authConfig={authConfig!} @@ -110,16 +116,14 @@ export const HooksListing = () => { visible={!!selectedHookForDeletion} size="large" variant="destructive" - title="Confirm to delete" + loading={isDeletingAuthHook} + title={`Confirm to delete ${selectedHookForDeletion?.title}`} confirmLabel="Delete" confirmLabelLoading="Deleting" onCancel={() => setSelectedHookForDeletion(null)} - onConfirm={async () => { - if (!selectedHookForDeletion) { - return - } - - await updateAuthConfig({ + onConfirm={() => { + if (!selectedHookForDeletion) return + updateAuthHooks({ projectRef: projectRef!, config: { [selectedHookForDeletion.enabledKey]: false, @@ -127,35 +131,20 @@ export const HooksListing = () => { [selectedHookForDeletion.secretsKey]: null, }, }) - - const { method } = selectedHookForDeletion - - if (method.type === 'postgres') { - const revokeStatements = getRevokePermissionStatements( - method.schema, - method.functionName - ) - await executeSql({ - projectRef, - connectionString: project!.connectionString, - sql: revokeStatements.join('\n'), - }) - } - toast.success(`${selectedHookForDeletion.title} has been deleted.`) - setSelectedHookForDeletion(null) - setSelectedHook(null) }} >
-

- {`Are you sure you want to delete the ${selectedHookForDeletion?.title}?`} +

+ Are you sure you want to delete the {selectedHookForDeletion?.title}?

{selectedHookForDeletion?.method.type === 'postgres' && ( <> -

- {`The following statements will be executed on the ${selectedHookForDeletion?.method.schema}.${selectedHookForDeletion?.method.functionName} function:`} +

+ The following statements will be executed on the{' '} + {selectedHookForDeletion?.method.schema}. + {selectedHookForDeletion?.method.functionName} function:

-
+
{ const [showCreateSecret, setShowCreateSecret] = useState(false) const [selectedSecret, setSelectedSecret] = useState() - const canReadSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*') - const canUpdateSecrets = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') + const canReadSecrets = useCheckPermissions(PermissionAction.SECRETS_READ, '*') + const canUpdateSecrets = useCheckPermissions(PermissionAction.SECRETS_WRITE, '*') const { data, error, isLoading, isSuccess, isError } = useSecretsQuery({ projectRef: projectRef, diff --git a/apps/studio/data/auth/auth-hooks-update-mutation.ts b/apps/studio/data/auth/auth-hooks-update-mutation.ts new file mode 100644 index 00000000000..8b8ebecffe6 --- /dev/null +++ b/apps/studio/data/auth/auth-hooks-update-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import type { components } from 'data/api' +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { authKeys } from './keys' + +export type AuthHooksUpdateVariables = { + projectRef: string + config: components['schemas']['UpdateGoTrueConfigHooksBody'] +} + +export async function updateAuthHooks({ projectRef, config }: AuthHooksUpdateVariables) { + const { data, error } = await patch('/platform/auth/{ref}/config/hooks', { + params: { path: { ref: projectRef } }, + body: config, + }) + + if (error) handleError(error) + return data +} + +type AuthHooksUpdateData = Awaited> + +export const useAuthHooksUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateAuthHooks(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + await queryClient.invalidateQueries(authKeys.authConfig(projectRef)) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update auth hooks: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 55ad9b69b3d..6131743e9b8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -40,7 +40,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.70", + "@supabase/shared-types": "0.1.73", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", diff --git a/package-lock.json b/package-lock.json index 884657ab1ac..82c222e368b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1826,7 +1826,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.70", + "@supabase/shared-types": "0.1.73", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", @@ -14739,9 +14739,9 @@ } }, "node_modules/@supabase/shared-types": { - "version": "0.1.70", - "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.70.tgz", - "integrity": "sha512-Z/JBbISlxMcOjFcomZdB5AGKrQdTy/dy3HQ8EpHx3xKz1yktbUDrzVcEFUKidRPrQu1GAt9JSW6qBsBlXHcyLA==" + "version": "0.1.73", + "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.73.tgz", + "integrity": "sha512-5BhGYrJ/ICBlIWdAZL3lRD2w6ZUAYvjpulvIkHfVKNcXpF3LpZ+2ONRDkm0B8dlWp+ZE1bm7DbFgJvoW+AT3XA==" }, "node_modules/@supabase/sql-to-rest": { "version": "0.1.6", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 15d57c796ef..3a85142fd88 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -101,6 +101,10 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } + '/platform/auth/{ref}/config/hooks': { + /** Updates GoTrue config hooks */ + patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] + } '/platform/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -122,11 +126,25 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/platform/auth/{ref}/users': { - /** Gets users */ + /** + * Gets users + * @deprecated + */ get: operations['UsersController_getUsers'] - /** Delete user with given ID */ + /** Creates user */ + post: operations['UsersController_createUser'] + /** + * Delete user with given ID + * @deprecated + */ delete: operations['UsersController_deleteUser'] } + '/platform/auth/{ref}/users/{id}': { + /** Delete user with given ID */ + delete: operations['UsersController_deleteUserById'] + /** Updates user with given ID */ + patch: operations['UsersController_updateUserById'] + } '/platform/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ delete: operations['FactorsController_deleteFactors'] @@ -1290,6 +1308,10 @@ export interface paths { /** Updates GoTrue config */ patch: operations['GoTrueConfigController_updateGoTrueConfig'] } + '/v0/auth/{ref}/config/hooks': { + /** Updates GoTrue config hooks */ + patch: operations['GoTrueConfigController_updateGoTrueConfigHooks'] + } '/v0/auth/{ref}/invite': { /** Sends an invite to the given email */ post: operations['AuthInviteController_sendInvite'] @@ -1311,11 +1333,25 @@ export interface paths { get: operations['TemplateController_getTemplate'] } '/v0/auth/{ref}/users': { - /** Gets users */ + /** + * Gets users + * @deprecated + */ get: operations['UsersController_getUsers'] - /** Delete user with given ID */ + /** Creates user */ + post: operations['UsersController_createUser'] + /** + * Delete user with given ID + * @deprecated + */ delete: operations['UsersController_deleteUser'] } + '/v0/auth/{ref}/users/{id}': { + /** Delete user with given ID */ + delete: operations['UsersController_deleteUserById'] + /** Updates user with given ID */ + patch: operations['UsersController_updateUserById'] + } '/v0/auth/{ref}/users/{id}/factors': { /** Delete all factors associated to a user */ delete: operations['FactorsController_deleteFactors'] @@ -3026,6 +3062,11 @@ export interface components { table: string table_id?: number } + CreateUserBody: { + email: string + email_confirm: boolean + password: string + } CreateUserContentFolderResponse: { id: string name: string @@ -3033,6 +3074,43 @@ export interface components { parent_id?: string | null project_id: number } + CreateUserReponse: { + aud?: string + banned_until?: string + confirmation_sent_at?: string + confirmation_token?: string + confirmed_at?: string + created_at?: string + deleted_at?: string + email?: string + email_change?: string + email_change_confirm_status?: number + email_change_sent_at?: string + email_change_token_current?: string + email_change_token_new?: string + email_confirmed_at?: string + encrypted_password?: string + id?: string + instance_id?: string + invited_at?: string + is_anonymous?: boolean + is_sso_user?: boolean + is_super_admin?: boolean + last_sign_in_at?: string + phone?: string + phone_change?: string + phone_change_sent_at?: string + phone_change_token?: string + phone_confirmed_at?: string + raw_app_meta_data?: Record + raw_user_meta_data?: Record + reauthentication_sent_at?: string + reauthentication_token?: string + recovery_sent_at?: string + recovery_token?: string + role?: string + updated_at?: string + } CreateVercelConnectionResponse: { env_sync_error?: components['schemas']['SyncVercelEnvError'] id: string @@ -6202,6 +6280,23 @@ export interface components { SMTP_USER?: string URI_ALLOW_LIST?: string } + UpdateGoTrueConfigHooksBody: { + HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean + HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string + HOOK_CUSTOM_ACCESS_TOKEN_URI?: string + HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED?: boolean + HOOK_MFA_VERIFICATION_ATTEMPT_SECRETS?: string + HOOK_MFA_VERIFICATION_ATTEMPT_URI?: string + HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED?: boolean + HOOK_PASSWORD_VERIFICATION_ATTEMPT_SECRETS?: string + HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI?: string + HOOK_SEND_EMAIL_ENABLED?: boolean + HOOK_SEND_EMAIL_SECRETS?: string + HOOK_SEND_EMAIL_URI?: string + HOOK_SEND_SMS_ENABLED?: boolean + HOOK_SEND_SMS_SECRETS?: string + HOOK_SEND_SMS_URI?: string + } UpdateMemberBody: { role_id: number } @@ -6420,6 +6515,46 @@ export interface components { table?: string table_id?: number } + UpdateUserBody: { + ban_duration?: string + } + UpdateUserReponse: { + aud?: string + banned_until?: string + confirmation_sent_at?: string + confirmation_token?: string + confirmed_at?: string + created_at?: string + deleted_at?: string + email?: string + email_change?: string + email_change_confirm_status?: number + email_change_sent_at?: string + email_change_token_current?: string + email_change_token_new?: string + email_confirmed_at?: string + encrypted_password?: string + id?: string + instance_id?: string + invited_at?: string + is_anonymous?: boolean + is_sso_user?: boolean + is_super_admin?: boolean + last_sign_in_at?: string + phone?: string + phone_change?: string + phone_change_sent_at?: string + phone_change_token?: string + phone_confirmed_at?: string + raw_app_meta_data?: Record + raw_user_meta_data?: Record + reauthentication_sent_at?: string + reauthentication_token?: string + recovery_sent_at?: string + recovery_token?: string + role?: string + updated_at?: string + } UpdateVercelConnectionsBody: { env_sync_targets?: ('production' | 'preview' | 'development')[] public_env_var_prefix?: string @@ -7140,6 +7275,34 @@ export interface operations { } } } + /** Updates GoTrue config hooks */ + GoTrueConfigController_updateGoTrueConfigHooks: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateGoTrueConfigHooksBody'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['GoTrueConfigResponse'] + } + } + 403: { + content: never + } + /** @description Failed to update GoTrue config hooks */ + 500: { + content: never + } + } + } /** Sends an invite to the given email */ AuthInviteController_sendInvite: { parameters: { @@ -7278,7 +7441,10 @@ export interface operations { } } } - /** Gets users */ + /** + * Gets users + * @deprecated + */ UsersController_getUsers: { parameters: { query: { @@ -7307,7 +7473,38 @@ export interface operations { } } } - /** Delete user with given ID */ + /** Creates user */ + UsersController_createUser: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateUserBody'] + } + } + responses: { + 201: { + content: { + 'application/json': components['schemas']['CreateUserReponse'] + } + } + 403: { + content: never + } + /** @description Failed to create user */ + 500: { + content: never + } + } + } + /** + * Delete user with given ID + * @deprecated + */ UsersController_deleteUser: { parameters: { path: { @@ -7335,6 +7532,57 @@ export interface operations { } } } + /** Delete user with given ID */ + UsersController_deleteUserById: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + responses: { + 200: { + content: never + } + 403: { + content: never + } + /** @description Failed to delete user */ + 500: { + content: never + } + } + } + /** Updates user with given ID */ + UsersController_updateUserById: { + parameters: { + path: { + /** @description Project ref */ + ref: string + id: string + } + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateUserBody'] + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['UpdateUserReponse'] + } + } + 403: { + content: never + } + /** @description Failed to update user with given ID */ + 500: { + content: never + } + } + } /** Delete all factors associated to a user */ FactorsController_deleteFactors: { parameters: { @@ -12175,6 +12423,9 @@ export interface operations { 'application/json': components['schemas']['UpdateSecretsResponse'] } } + 403: { + content: never + } /** @description Failed to update project's secrets config */ 500: { content: never From c506934a1b1241631217513b8647448cd064c3b2 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Wed, 16 Oct 2024 05:09:13 +0200 Subject: [PATCH 034/255] chore: Add Ivan to humans.txt (#29902) Update humans.txt --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index d561d77e682..f5f0e836ae6 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -37,6 +37,7 @@ Guilherme Souza Haydn Maley Hieu Pham Inian P +Ivan Vasilov Jenny Kibiri Jess Shears Joel Lee From ed207751ada7ecee51c6d9021f1aebb3752d17f3 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 16 Oct 2024 15:16:33 +0800 Subject: [PATCH 035/255] Update telemetry calls for docs to support PH (#29749) * Update telemetry calls for docs to support PH * Update telemetry calls for www to support PH * Add ts ignore * Remove use of useRouter for docs * Add credentials include for www and docs for telemetry calls * Update TELEMETRY_CONSENT in common for www and docs to reset telemetry opt in preference * Update common telemetry to use new endpoint and payload, and trigger reset request when opting out of telemetry from ui patterns PrigacySettings * Fix * Fix * Fix * Fix build issue in docs * Fix build issue in docs * I hope this fixes the build issues * once more... * Fix * Add credentials include * Fix --- .../features/telemetry/telemetry.client.tsx | 54 ++++++++++--------- apps/docs/lib/fetch/fetchWrappers.ts | 2 + apps/docs/lib/telemetry.ts | 31 ++++++++--- apps/www/lib/fetchWrapper.ts | 5 +- apps/www/lib/telemetry.ts | 51 +++++++++--------- apps/www/pages/_app.tsx | 46 +++++++++++----- packages/common/constants/local-storage.ts | 2 +- packages/common/fetchWrappers.tsx | 7 ++- packages/common/hooks/useTelemetryProps.ts | 15 +++++- packages/common/telemetry.ts | 31 ++++++++--- .../ui-patterns/PrivacySettings/index.tsx | 14 ++++- 11 files changed, 172 insertions(+), 86 deletions(-) diff --git a/apps/docs/features/telemetry/telemetry.client.tsx b/apps/docs/features/telemetry/telemetry.client.tsx index a0854d7c172..b32c4ad9c75 100644 --- a/apps/docs/features/telemetry/telemetry.client.tsx +++ b/apps/docs/features/telemetry/telemetry.client.tsx @@ -1,38 +1,42 @@ 'use client' -import { useTelemetryProps } from 'common' +import { isBrowser } from 'common' import { usePathname } from 'next/navigation' import { useCallback, useEffect } from 'react' import { useConsent } from 'ui-patterns/ConsentToast' -import { BASE_PATH, IS_PLATFORM } from '~/lib/constants' +import { IS_PLATFORM } from '~/lib/constants' import { unauthedAllowedPost } from '~/lib/fetch/fetchWrappers' const useSendPageTelemetryWithConsent = () => { + const pathname = usePathname() const { hasAcceptedConsent } = useConsent() - const telemetryProps = useTelemetryProps() - const sendPageTelemetry = useCallback( - (route: string) => { - if (!(IS_PLATFORM && hasAcceptedConsent)) return + const sendPageTelemetry = useCallback(() => { + if (!(IS_PLATFORM && hasAcceptedConsent)) return - unauthedAllowedPost('/platform/telemetry/page', { - body: { - // @ts-ignore [JOSHEN] To be fixed for PH - referrer: document.referrer, - title: document.title, - route: `${BASE_PATH}${route}`, - ga: { - screen_resolution: telemetryProps?.screenResolution, - language: telemetryProps?.language, - session_id: '', - }, + const title = typeof document !== 'undefined' ? document?.title : '' + const referrer = typeof document !== 'undefined' ? document?.referrer : '' + + unauthedAllowedPost('/platform/telemetry/page', { + body: { + pathname, + page_url: isBrowser ? window.location.href : '', + page_title: title, + ph: { + referrer, + language: navigator.language ?? 'en-US', + user_agent: navigator.userAgent, + search: isBrowser ? window.location.search : '', + viewport_height: isBrowser ? window.innerHeight : 0, + viewport_width: isBrowser ? window.innerWidth : 0, }, - }).catch((e) => { - console.error('Problem sending telemetry:', e) - }) - }, - [telemetryProps, hasAcceptedConsent] - ) + }, + headers: { Version: '2' }, + credentials: 'include', + }).catch((e) => { + console.error('Problem sending telemetry:', e) + }) + }, [pathname, hasAcceptedConsent]) return sendPageTelemetry } @@ -42,9 +46,7 @@ const PageTelemetry = () => { const sendPageTelemetry = useSendPageTelemetryWithConsent() useEffect(() => { - if (pathname) { - sendPageTelemetry(pathname) - } + if (pathname) sendPageTelemetry() }, [pathname, sendPageTelemetry]) return null diff --git a/apps/docs/lib/fetch/fetchWrappers.ts b/apps/docs/lib/fetch/fetchWrappers.ts index 440ac83dc8b..d978d4b06f2 100644 --- a/apps/docs/lib/fetch/fetchWrappers.ts +++ b/apps/docs/lib/fetch/fetchWrappers.ts @@ -49,6 +49,7 @@ export const post: typeof _post = async (url, init) => { const headers = await constructHeaders(init?.headers) return await _post(url, { + credentials: 'include', ...init, headers, }) @@ -58,6 +59,7 @@ export const unauthedAllowedPost: typeof _post = async (url, init) => { const headers = await constructHeaders(init?.headers, { allowUnauthenticated: true }) return await _post(url, { + credentials: 'include', ...init, headers, }) diff --git a/apps/docs/lib/telemetry.ts b/apps/docs/lib/telemetry.ts index 222dea30901..bfc8b89a73e 100644 --- a/apps/docs/lib/telemetry.ts +++ b/apps/docs/lib/telemetry.ts @@ -1,7 +1,6 @@ +import { isBrowser } from 'common' import { usePathname } from 'next/navigation' - import { useConsent } from 'ui-patterns/ConsentToast' - import { unauthedAllowedPost } from './fetch/fetchWrappers' type TelemetryEvent = { @@ -18,20 +17,36 @@ const noop = () => {} * Checks for user consent to telemetry before sending. */ const useSendTelemetryEvent = () => { - const { hasAcceptedConsent } = useConsent() const pathname = usePathname() + const { hasAcceptedConsent } = useConsent() if (!hasAcceptedConsent) return noop + const title = typeof document !== 'undefined' ? document?.title : '' + const referrer = typeof document !== 'undefined' ? document?.referrer : '' + return (event: TelemetryEvent) => unauthedAllowedPost('/platform/telemetry/event', { - // @ts-ignore - endpoint will accept this just fine body: { - ...event, - page_title: document?.title, - // @ts-ignore [JOSHEN] To be fixed for PH - page_location: pathname, + pathname, + action: event.action, + page_url: isBrowser ? window.location.href : '', + page_title: title, + ph: { + referrer, + language: navigator.language ?? 'en-US', + user_agent: navigator.userAgent, + search: isBrowser ? window.location.search : '', + viewport_height: isBrowser ? window.innerHeight : 0, + viewport_width: isBrowser ? window.innerWidth : 0, + }, + custom_properties: { + category: event.category, + label: event.label, + } as any, }, + headers: { Version: '2' }, + credentials: 'include', }) .then(({ error }) => { if (error) console.error(error) diff --git a/apps/www/lib/fetchWrapper.ts b/apps/www/lib/fetchWrapper.ts index e5ba7a29072..b129e8ab7e6 100644 --- a/apps/www/lib/fetchWrapper.ts +++ b/apps/www/lib/fetchWrapper.ts @@ -25,11 +25,13 @@ export async function get(url: string, options?: RequestInit) { } export async function post(url: string, data: DataProps, options?: RequestInit) { + const { headers: optionHeaders, ...otherOptions } = options || {} const accessToken = await getAccessToken() let headers = new Headers({ 'Content-Type': 'application/json', Accept: 'application/json', + ...optionHeaders, }) if (accessToken) { @@ -39,8 +41,9 @@ export async function post(url: string, data: DataProps, options?: RequestInit) return fetch(url, { method: 'POST', headers, + credentials: 'include', referrerPolicy: 'no-referrer-when-downgrade', body: JSON.stringify(data), - ...options, + ...otherOptions, }) } diff --git a/apps/www/lib/telemetry.ts b/apps/www/lib/telemetry.ts index 31f2eb90343..6255a45aa4c 100644 --- a/apps/www/lib/telemetry.ts +++ b/apps/www/lib/telemetry.ts @@ -1,7 +1,7 @@ -import { post } from '~/lib/fetchWrapper' -import { API_URL, IS_PROD, IS_PREVIEW } from 'lib/constants' +import { LOCAL_STORAGE_KEYS, TelemetryProps } from 'common' +import { API_URL, IS_PREVIEW, IS_PROD } from 'lib/constants' import { NextRouter } from 'next/router' -import { LOCAL_STORAGE_KEYS } from 'common' +import { post } from '~/lib/fetchWrapper' export interface TelemetryEvent { category: string @@ -10,17 +10,12 @@ export interface TelemetryEvent { value?: string } -export interface TelemetryProps { - screenResolution?: string - language: string -} - const noop = () => {} // This event is the same as in studio/lib/telemetry.tx // but uses different ENV variables for www -const sendEvent = (event: TelemetryEvent, gaProps: TelemetryProps, router: NextRouter) => { +const sendEvent = (event: TelemetryEvent, telemetryProps: TelemetryProps, router: NextRouter) => { const consent = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) @@ -32,22 +27,30 @@ const sendEvent = (event: TelemetryEvent, gaProps: TelemetryProps, router: NextR if (blockEvent) return noop const { category, action, label, value } = event + const title = typeof document !== 'undefined' ? document?.title : '' + const referrer = typeof document !== 'undefined' ? document?.referrer : '' - return post(`${API_URL}/telemetry/event`, { - action: action, - category: category, - label: label, - value: value, - page_referrer: document?.referrer, - page_title: document?.title, - page_location: router.asPath, - ga: { - screen_resolution: gaProps?.screenResolution, - language: gaProps?.language, + const { page_url, search, language, viewport_height, viewport_width } = telemetryProps + + return post( + `${API_URL}/telemetry/event`, + { + page_url, + action: action, + page_title: title, + pathname: router.pathname, + ph: { + search, + referrer, + language, + viewport_height, + viewport_width, + user_agent: navigator.userAgent, + }, + custom_properties: { category, label, value } as any, }, - }) + { headers: { Version: '2' }, credentials: 'include' } + ) } -export default { - sendEvent, -} +export default { sendEvent } diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index a2884f5133d..4720fd4810a 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -3,7 +3,14 @@ import 'config/code-hike.scss' import '../styles/index.css' import { SessionContextProvider } from '@supabase/auth-helpers-react' -import { AuthProvider, IS_PROD, ThemeProvider, useTelemetryProps, useThemeSandbox } from 'common' +import { + AuthProvider, + IS_PROD, + isBrowser, + ThemeProvider, + useTelemetryProps, + useThemeSandbox, +} from 'common' import { DefaultSeo } from 'next-seo' import { AppProps } from 'next/app' import Head from 'next/head' @@ -30,25 +37,38 @@ export default function App({ Component, pageProps }: AppProps) { const IS_DEV = !IS_PROD && !IS_PREVIEW const blockEvents = IS_DEV || !hasAcceptedConsent + const title = typeof document !== 'undefined' ? document?.title : '' + const referrer = typeof document !== 'undefined' ? document?.referrer : '' + + const { search, language, viewport_height, viewport_width } = telemetryProps + useThemeSandbox() - function handlePageTelemetry(route: string) { - return post(`${API_URL}/telemetry/page`, { - referrer: document.referrer, - title: document.title, - route, - ga: { - screen_resolution: telemetryProps?.screenResolution, - language: telemetryProps?.language, + function handlePageTelemetry(url: string) { + return post( + `${API_URL}/telemetry/page`, + { + page_url: url, + page_title: title, + pathname: router.pathname, + ph: { + referrer, + language, + search, + viewport_height, + viewport_width, + user_agent: navigator.userAgent, + }, }, - }) + { headers: { Version: '2' }, credentials: 'include' } + ) } useEffect(() => { if (blockEvents) return - function handleRouteChange(url: string) { - handlePageTelemetry(url) + function handleRouteChange() { + handlePageTelemetry(window.location.href) } // Listen for page changes after a navigation or when the query changes @@ -64,7 +84,7 @@ export default function App({ Component, pageProps }: AppProps) { * Send page telemetry on first page load */ if (router.isReady) { - handlePageTelemetry(router.asPath) + handlePageTelemetry(window.location.href) } }, [router.isReady, consentValue]) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 46b45969e9c..3a3faca6fbf 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -1,5 +1,5 @@ export const LOCAL_STORAGE_KEYS = { - TELEMETRY_CONSENT: 'supabase-consent', + TELEMETRY_CONSENT: 'supabase-consent-ph', HIDE_PROMO_TOAST: 'supabase-hide-promo-toast', BLOG_VIEW: 'supabase-blog-view', } diff --git a/packages/common/fetchWrappers.tsx b/packages/common/fetchWrappers.tsx index 0313e49565f..54b8c1879f4 100644 --- a/packages/common/fetchWrappers.tsx +++ b/packages/common/fetchWrappers.tsx @@ -2,16 +2,19 @@ interface DataProps { [prop: string]: any } -export const post = (url: string, data: DataProps, options = {}) => { +export const post = (url: string, data: DataProps, options = {} as { [key: string]: any }) => { + const { optionHeaders, ...otherOptions } = options return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', + ...optionHeaders, }, + credentials: 'include', referrerPolicy: 'no-referrer-when-downgrade', body: JSON.stringify(data), - ...options, + ...otherOptions, }).catch((error) => { console.error('Error at fetchWrapper - post:', error) }) diff --git a/packages/common/hooks/useTelemetryProps.ts b/packages/common/hooks/useTelemetryProps.ts index e780615654c..88a16ef8b8f 100644 --- a/packages/common/hooks/useTelemetryProps.ts +++ b/packages/common/hooks/useTelemetryProps.ts @@ -3,12 +3,25 @@ import { useRouter } from 'next/compat/router' import { isBrowser } from '../helpers' -export function useTelemetryProps() { +export interface TelemetryProps { + screenResolution?: string + page_url: string + search: string + language: string + viewport_height: number + viewport_width: number +} + +export function useTelemetryProps(): TelemetryProps { const router = useRouter() const locale = router ? router.locale : undefined return { screenResolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined, + page_url: isBrowser ? window.location.href : '', + search: isBrowser ? window.location.search : '', + viewport_height: isBrowser ? window.innerHeight : 0, + viewport_width: isBrowser ? window.innerWidth : 0, language: locale ?? 'en-US', } } diff --git a/packages/common/telemetry.ts b/packages/common/telemetry.ts index eb536b448d6..a2a8857d875 100644 --- a/packages/common/telemetry.ts +++ b/packages/common/telemetry.ts @@ -1,13 +1,28 @@ import { post } from './fetchWrappers' export function handlePageTelemetry(API_URL: string, route: string, telemetryProps: any) { - return post(`${API_URL}/telemetry/page`, { - referrer: document.referrer, - title: document.title, - route, - ga: { - screen_resolution: telemetryProps?.screenResolution, - language: telemetryProps?.language, + const { page_url, search, language, viewport_height, viewport_width } = telemetryProps + return post( + `${API_URL}/telemetry/page`, + { + page_url, + page_title: document.title, + pathname: route, + ph: { + referrer: document.referrer, + user_agent: navigator.userAgent, + search, + language, + viewport_height, + viewport_width, + }, }, - }) + { + headers: { Version: '2' }, + } + ) +} + +export function handleResetTelemetry(API_URL: string) { + return post(`${API_URL}/telemetry/reset`, {}) } diff --git a/packages/ui-patterns/PrivacySettings/index.tsx b/packages/ui-patterns/PrivacySettings/index.tsx index a8e20482901..d6b34a5ddd9 100644 --- a/packages/ui-patterns/PrivacySettings/index.tsx +++ b/packages/ui-patterns/PrivacySettings/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { LOCAL_STORAGE_KEYS } from 'common' +import { handleResetTelemetry, LOCAL_STORAGE_KEYS } from 'common' import Link from 'next/link' import { PropsWithChildren, useEffect, useState } from 'react' import { Modal, Toggle } from 'ui' @@ -30,6 +30,10 @@ export const PrivacySettings = ({ setIsOpen(false) } + const handleOptOutTelemetry = async () => { + handleResetTelemetry(process.env.NEXT_PUBLIC_API_URL!) + } + return ( <>
- - +
) diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index fc218959191..48147e1aff5 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -139,7 +139,7 @@ const FileExplorerRow: ItemRenderer = ({ const isOpened = openedFolders.length > columnIndex ? isEqual(openedFolders[columnIndex], item) : false const isPreviewed = !isEmpty(selectedFilePreview) && isEqual(selectedFilePreview?.id, item.id) - const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const { show } = useContextMenu() diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx index dbdb27cf183..12eaec45dda 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FolderContextMenu.tsx @@ -15,7 +15,7 @@ const FolderContextMenu = ({ id = '' }: FolderContextMenuProps) => { const storageExplorerStore = useStorageStore() const { openedFolders, downloadFolder, setSelectedItemToRename, setSelectedItemsToDelete } = storageExplorerStore - const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') return ( diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx index 64211c7da76..89c772ab182 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ItemContextMenu.tsx @@ -27,7 +27,7 @@ const ItemContextMenu = ({ id = '' }: ItemContextMenuProps) => { } = storageExplorerStore const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef) const isPublic = selectedBucket.public - const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const onHandleClick = async (event: any, item: StorageItemWithColumn, expiresIn?: number) => { if (item.isCorrupted) return diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx index 87195540a10..1e28f2d0ee4 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/PreviewPane.tsx @@ -107,7 +107,7 @@ const PreviewPane = () => { } = storageExplorerStore const { onCopyUrl } = useCopyUrl(storageExplorerStore.projectRef) - const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateFiles = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') if (!file) { return null diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx index da82bffcc7a..25a5c517e49 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx @@ -33,7 +33,7 @@ export const S3Connection = () => { const [openDeleteDialog, setOpenDeleteDialog] = useState(false) const [deleteCred, setDeleteCred] = useState<{ id: string; description: string }>() - const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') const { data: projectAPI } = useProjectApiQuery({ projectRef: projectRef }) const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery( diff --git a/apps/studio/package.json b/apps/studio/package.json index 0fa19c3910b..e5afc2f6c2f 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -40,7 +40,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.73", + "@supabase/shared-types": "0.1.74", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", diff --git a/package-lock.json b/package-lock.json index 02d1b085118..abc76663856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1800,7 +1800,7 @@ "@supabase/auth-js": "2.64.2", "@supabase/pg-meta": "*", "@supabase/realtime-js": "2.10.2", - "@supabase/shared-types": "0.1.73", + "@supabase/shared-types": "0.1.74", "@supabase/supabase-js": "^2.44.3", "@tanstack/react-query": "4.35.7", "@tanstack/react-query-devtools": "4.35.7", @@ -15830,9 +15830,9 @@ } }, "node_modules/@supabase/shared-types": { - "version": "0.1.73", - "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.73.tgz", - "integrity": "sha512-5BhGYrJ/ICBlIWdAZL3lRD2w6ZUAYvjpulvIkHfVKNcXpF3LpZ+2ONRDkm0B8dlWp+ZE1bm7DbFgJvoW+AT3XA==" + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.74.tgz", + "integrity": "sha512-vYbNA6JtV4PNTSuyRQsRBn6v99WMKCBN5wgS2Ezl3n4EGgCoJCvHyQFbNEwbf1vvZVum/j1bAAK3n3io1jxiqA==" }, "node_modules/@supabase/sql-to-rest": { "version": "0.1.6", From 123dd5b426c4844e44b0b6b74c573a8f99d744a3 Mon Sep 17 00:00:00 2001 From: Mayur Date: Thu, 17 Oct 2024 07:00:13 -0400 Subject: [PATCH 050/255] docs: update Cloudflare worker itty router link example (#29904) fix: 404 link for the cloudflare worker itty router --- examples/with-cloudflare-workers/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/with-cloudflare-workers/README.md b/examples/with-cloudflare-workers/README.md index 55ae9ce695c..afaf759e5f3 100644 --- a/examples/with-cloudflare-workers/README.md +++ b/examples/with-cloudflare-workers/README.md @@ -68,7 +68,7 @@ return new Response(JSON.stringify(data), { --- -[👉 Next lesson](/04-proxy-supabase-requests-with-cloudflare-workers-and-itty-router) +[👉 Next lesson](https://github.com/dijonmusters/supabase-data-at-the-edge/tree/main/04-proxy-supabase-requests-with-cloudflare-workers-and-itty-router) --- From 7a2242a35c327c07a3a476f49955eadf301a1134 Mon Sep 17 00:00:00 2001 From: Shersheial <4740525+sharshi@users.noreply.github.com> Date: Thu, 17 Oct 2024 07:06:24 -0400 Subject: [PATCH 051/255] Chore: Close parenthesis in code example (#29457) Close parenthesis in code example --- apps/docs/spec/supabase_csharp_v1.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/spec/supabase_csharp_v1.yml b/apps/docs/spec/supabase_csharp_v1.yml index 270eedfcb86..35ba174d3b1 100644 --- a/apps/docs/spec/supabase_csharp_v1.yml +++ b/apps/docs/spec/supabase_csharp_v1.yml @@ -1497,7 +1497,7 @@ functions: code: | ```c# var result = await supabase.From() - .Where(x => x.Name == null + .Where(x => x.Name == null) .Get(); ``` From 2d4e046afb39b94d56af8fd096d5fdde6654b8a0 Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Thu, 17 Oct 2024 04:37:54 -0700 Subject: [PATCH 052/255] Adding a prompts folder (#29131) * adds migration prompt * Adds a prompt for coding style * Update examples/prompts/code-format-sql.md Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update examples/prompts/database-create-migration.md Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Tyler Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- examples/prompts/code-format-sql.md | 141 ++++++++++++++++++ examples/prompts/database-create-migration.md | 48 ++++++ 2 files changed, 189 insertions(+) create mode 100644 examples/prompts/code-format-sql.md create mode 100644 examples/prompts/database-create-migration.md diff --git a/examples/prompts/code-format-sql.md b/examples/prompts/code-format-sql.md new file mode 100644 index 00000000000..ed3cb254a30 --- /dev/null +++ b/examples/prompts/code-format-sql.md @@ -0,0 +1,141 @@ +# PostgreSQL SQL Style Guide + +## General + +- Use lowercase for SQL reserved words to maintain consistency and readability. +- Employ consistent, descriptive identifiers for tables, columns, and other database objects. +- Use white space and indentation to enhance the readability of your code. +- Store dates in ISO 8601 format (`yyyy-mm-ddThh:mm:ss.sssss`). +- Include comments for complex logic, using '/* ... */' for block comments and '--' for line comments. + +## Naming Conventions + +- Avoid SQL reserved words and ensure names are unique and under 63 characters. +- Use snake_case for tables and columns. +- Prefer plurals for table names +- Prefer singular names for columns. + +## Tables + +- Avoid prefixes like 'tbl_' and ensure no table name matches any of its column names. +- Always add an `id` column of type `identity generated always` unless otherwise specified. +- Create all tables in the `public` schema unless otherwise specified. +- Always add the schema to SQL queries for clarity. +- Always add a comment to describe what the table does. The comment can be up to 1024 characters. + +## Columns + +- Use singular names and avoid generic names like 'id'. +- For references to foreign tables, use the singular of the table name with the `_id` suffix. For example `user_id` to reference the `users` table +- Always use lowercase except in cases involving acronyms or when readability would be enhanced by an exception. + +#### Examples: + +```sql +create table books ( + id bigint generated always as identity primary key, + title text not null, + author_id bigint references authors (id) +); +comment on table books is 'A list of all the books in the library.'; +``` + + +## Queries + +- When the query is shorter keep it on just a few lines. As it gets larger start adding newlines for readability +- Add spaces for readability. + +Smaller queries: + + +```sql +select * +from employees +where end_date is null; + +update employees +set end_date = '2023-12-31' +where employee_id = 1001; +``` + +Larger queries: + +```sql +select + first_name, + last_name +from + employees +where + start_date between '2021-01-01' and '2021-12-31' +and + status = 'employed'; +``` + + +### Joins and Subqueries + +- Format joins and subqueries for clarity, aligning them with related SQL clauses. +- Prefer full table names when referencing tables. This helps for readability. + +```sql +select + employees.employee_name, + departments.department_name +from + employees +join + departments on employees.department_id = departments.department_id +where + employees.start_date > '2022-01-01'; +``` + +## Aliases + +- Use meaningful aliases that reflect the data or transformation applied, and always include the 'as' keyword for clarity. + +```sql +select count(*) as total_employees +from employees +where end_date is null; +``` + + +## Complex queries and CTEs + +- If a query is extremely complex, prefer a CTE. +- Make sure the CTE is clear and linear. Prefer readability over performance. +- Add comments to each block. + +```sql +with department_employees as ( + -- Get all employees and their departments + select + employees.department_id, + employees.first_name, + employees.last_name, + departments.department_name + from + employees + join + departments on employees.department_id = departments.department_id +), +employee_counts as ( + -- Count how many employees in each department + select + department_name, + count(*) as num_employees + from + department_employees + group by + department_name +) +select + department_name, + num_employees +from + employee_counts +order by + department_name; +``` \ No newline at end of file diff --git a/examples/prompts/database-create-migration.md b/examples/prompts/database-create-migration.md new file mode 100644 index 00000000000..19b987d4334 --- /dev/null +++ b/examples/prompts/database-create-migration.md @@ -0,0 +1,48 @@ + + +# Database: Create migration + +You are a Postgres Expert who loves creating secure database schemas. + +This project uses the migrations provided by the Supabase CLI. + +## Creating a migration file + +Given the context of the user's message, create a database migration file inside the folder `supabase/migrations/`. + +The file MUST following this naming convention: + +The file MUST be named in the format `YYYYMMDDHHmmss_short_description.sql` with proper casing for months, minutes, and seconds in UTC time: + +1. `YYYY` - Four digits for the year (e.g., `2024`). +2. `MM` - Two digits for the month (01 to 12). +3. `DD` - Two digits for the day of the month (01 to 31). +4. `HH` - Two digits for the hour in 24-hour format (00 to 23). +5. `mm` - Two digits for the minute (00 to 59). +6. `ss` - Two digits for the second (00 to 59). +7. Add an appropriate description for the migration. + +For example: + +``` +20240906123045_create_profiles.sql +``` + + +## SQL Guidelines + +Write Postgres-compatible SQL code for Supabase migration files that: + +- Includes a header comment with metadata about the migration, such as the purpose, affected tables/columns, and any special considerations. +- Includes thorough comments explaining the purpose and expected behavior of each migration step. +- Write all SQL in lowercase. +- Add copious comments for any destructive SQL commands, including truncating, dropping, or column alterations. +- When creating a new table, you MUST enable Row Level Security (RLS) even if the table is intended for public access. +- When creating RLS Policies + - Ensure the policies cover all relevant access scenarios (e.g. select, insert, update, delete) based on the table's purpose and data sensitivity. + - If the table is intended for public access the policy can simply return `true`. + - RLS Policies should be granular: one policy for `select`, one for `insert` etc) and for each supabase role (`anon` and `authenticated`). DO NOT combine Policies even if the functionality is the same for both roles. + - Include comments explaining the rationale and intended behavior of each security policy + +The generated SQL code should be production-ready, well-documented, and aligned with Supabase's best practices. + From 12692d52c56afeef2795dc49c50e9a0d6d033b1c Mon Sep 17 00:00:00 2001 From: Chris Gwilliams <517923+encima@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:46:03 +0300 Subject: [PATCH 053/255] Fix schema in docs for pg-cron enabling (#28288) fix schema for pg-cron enabling Co-authored-by: Ivan Vasilov --- apps/docs/content/guides/database/extensions/pg_cron.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/extensions/pg_cron.mdx b/apps/docs/content/guides/database/extensions/pg_cron.mdx index 22a0efe7b64..6b934fbbe97 100644 --- a/apps/docs/content/guides/database/extensions/pg_cron.mdx +++ b/apps/docs/content/guides/database/extensions/pg_cron.mdx @@ -34,7 +34,7 @@ pg_cron is not fully supported on Fly Postgres. Read more about this [Fly Postgr ```sql -- Example: enable the "pg_cron" extension -create extension pg_cron with schema extensions; +create extension pg_cron with schema pg_catalog; grant usage on schema cron to postgres; grant all privileges on all tables in schema cron to postgres; From dc1282a9dddef239dbfa513034af44a0ab96d5eb Mon Sep 17 00:00:00 2001 From: Alexander Korotkov Date: Thu, 17 Oct 2024 14:53:16 +0300 Subject: [PATCH 054/255] Reference Postgres by website title page in humans.txt (#25932) --- apps/docs/public/humans.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index f5f0e836ae6..c5b81d36cae 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -100,7 +100,7 @@ TECHNOLOGIES Supabase would not be possible without the work of some other amazing open source contributors. -- Postgres - https://www.postgresql.org/docs/devel/git.html +- Postgres - https://www.postgresql.org/ - PostgREST - https://github.com/PostgREST - Kong - https://github.com/kong/ - Next.js - https://nextjs.org/ From f54181075a3e055f9634716704b4d2e11d508d8a Mon Sep 17 00:00:00 2001 From: "Jules, Azad EMERY" <25751709+ethicnology@users.noreply.github.com> Date: Thu, 17 Oct 2024 08:00:35 -0400 Subject: [PATCH 055/255] fix: docker compose obsolete attribute version (#28298) --- docker/docker-compose.s3.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker/docker-compose.s3.yml b/docker/docker-compose.s3.yml index bb8be624280..eca5830222f 100644 --- a/docker/docker-compose.s3.yml +++ b/docker/docker-compose.s3.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: minio: From 0bd09b097d51081e427d3b98854d0f3163536047 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 17 Oct 2024 14:10:06 +0200 Subject: [PATCH 056/255] fix(self-host): supavisor pooler port redirection (#29946) When changing the ports to not default pooler failed connection because the internal port was wrongly exposed --- docker/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 35c851a571e..69e61a70a58 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -455,8 +455,8 @@ services: - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server restart: unless-stopped ports: - - ${POSTGRES_PORT}:${POSTGRES_PORT} - - ${POOLER_PROXY_PORT_TRANSACTION}:${POOLER_PROXY_PORT_TRANSACTION} + - ${POSTGRES_PORT}:5432 + - ${POOLER_PROXY_PORT_TRANSACTION}:6543 environment: - PORT=4000 - POSTGRES_PORT=${POSTGRES_PORT} From 608c76a1767e644387f613a09dd520b169c60c2e Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Thu, 17 Oct 2024 09:47:28 -0230 Subject: [PATCH 057/255] Chore/link to storage settings (#29806) * Link to storage settings * Update the global limit relationship to individual bucket limits * Handle 413 status better --- .../interfaces/Storage/CreateBucketModal.tsx | 6 +-- .../interfaces/Storage/EditBucketModal.tsx | 8 +-- .../storageExplorer/StorageExplorerStore.tsx | 52 ++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx index facb37bd767..a2db5c7f6db 100644 --- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx @@ -200,14 +200,14 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => { {IS_PLATFORM && (

- Note: The{' '} + Note: Individual bucket uploads will still be capped at the{' '} global upload limit {' '} - takes precedence over this value ({formattedGlobalUploadLimit}) + of {formattedGlobalUploadLimit}

)} diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx index fb070116d96..913ecd364dc 100644 --- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx @@ -204,16 +204,16 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) =>
{IS_PLATFORM && ( -
+

- Note: The{' '} + Note: Individual bucket upload will still be capped at the{' '} global upload limit {' '} - takes precedence over this value ({formattedGlobalUploadLimit}) + of {formattedGlobalUploadLimit}

)} diff --git a/apps/studio/localStores/storageExplorer/StorageExplorerStore.tsx b/apps/studio/localStores/storageExplorer/StorageExplorerStore.tsx index a6858e9a5fb..24e60285183 100644 --- a/apps/studio/localStores/storageExplorer/StorageExplorerStore.tsx +++ b/apps/studio/localStores/storageExplorer/StorageExplorerStore.tsx @@ -44,6 +44,7 @@ import { IS_PLATFORM } from 'lib/constants' import { lookupMime } from 'lib/mime' import { PROJECT_ENDPOINT_PROTOCOL } from 'pages/api/constants' import { Button, SONNER_DEFAULT_DURATION, SonnerProgress } from 'ui' +import Link from 'next/link' type CachedFile = { id: string; fetchedAt: number; expiresIn: number; url: string } @@ -265,7 +266,6 @@ class StorageExplorerStore { popOpenedFoldersAtIndex = (index: number) => { this.openedFolders = this.openedFolders.slice(0, index + 1) } - clearOpenedFolders = () => { this.openedFolders = [] } @@ -566,13 +566,22 @@ class StorageExplorerStore {

Failed to upload {numberOfFilesRejected} file{numberOfFilesRejected > 1 ? 's' : ''} as{' '} {numberOfFilesRejected > 1 ? 'their' : 'its'} size - {numberOfFilesRejected > 1 ? 's are' : ' is'} beyond the upload limit of {value} + {numberOfFilesRejected > 1 ? 's are' : ' is'} beyond the global upload limit of {value} {unit}.

- You may change the file size upload limit under Storage in Project Settings. + You can change the global file size upload limit in{' '} + + Storage settings + + .

-
+
, + { duration: 8000 } ) if (numberOfFilesRejected === filesToUpload.length) return @@ -733,26 +742,31 @@ class StorageExplorerStore { chunkSize, onShouldRetry(error) { const status = error.originalResponse ? error.originalResponse.getStatus() : 0 - const doNotRetryStatuses = [400, 403, 404, 409, 415, 429] + const doNotRetryStatuses = [400, 403, 404, 409, 413, 415, 429] return !doNotRetryStatuses.includes(status) }, onError: (error) => { numberOfFilesUploadedFail += 1 - if ( - error instanceof tus.DetailedError && - error.originalResponse?.getStatus() === 415 - ) { - // Unsupported mime type - toast.error( - capitalize( - error.originalResponse.getBody() || - `Failed to upload ${file.name}: ${metadata.mimetype} is not allowed` - ), - { - description: `Allowed MIME types: ${this.selectedBucket.allowed_mime_types?.join(', ')}`, - } - ) + if (error instanceof tus.DetailedError) { + const status = error.originalResponse?.getStatus() + if (status === 415) { + // Unsupported mime type + toast.error( + capitalize( + error?.originalResponse?.getBody() || + `Failed to upload ${file.name}: ${metadata.mimetype} is not allowed` + ), + { + description: `Allowed MIME types: ${this.selectedBucket.allowed_mime_types?.join(', ')}`, + } + ) + } else if (status === 413) { + // Payload too large + toast.error( + `Failed to upload ${file.name}: File size exceeds the bucket upload limit.` + ) + } } else { toast.error(`Failed to upload ${file.name}: ${error.message}`) } From 9effbb9899f388f37d364a48f5086b29b16b0ead Mon Sep 17 00:00:00 2001 From: Artur Zakirov Date: Thu, 17 Oct 2024 21:27:39 +0900 Subject: [PATCH 058/255] Add Artur Zakirov to humans.txt (#29055) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index c5b81d36cae..597265188dc 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -10,6 +10,7 @@ Andrew Valleteau Angelico de los Reyes Ant Wilson Ariuna K +Artur Zakirov Beng Eu Bo Lu Bobbie Soedirgo From 7ca8d1c873c93a9d7ff702e71bb9454ca09e06b4 Mon Sep 17 00:00:00 2001 From: James Glenn <47917431+JR-G@users.noreply.github.com> Date: Thu, 17 Oct 2024 13:47:58 +0100 Subject: [PATCH 059/255] docs: Sveltekit connect guide update (#28964) docs: sveltekit connect guide update Co-authored-by: Ivan Vasilov --- .../Connect/content/sveltekit/supabasejs/content.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx index 6f9468e2f18..7b058645c07 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Home/Connect/content/sveltekit/supabasejs/content.tsx @@ -21,8 +21,8 @@ const ContentFile = ({ projectKeys }: ContentFileProps) => { {` -SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} -SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} +PUBLIC_SUPABASE_URL=${projectKeys.apiUrl ?? 'your-project-url'} +PUBLIC_SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} `} @@ -31,9 +31,10 @@ SUPABASE_ANON_KEY=${projectKeys.anonKey ?? 'your-anon-key'} {` import { createClient } from "@supabase/supabase-js"; +import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from "$env/static/public" -const supabaseUrl = process.env.SUPABASE_URL; -const supabaseKey = process.env.SUPABASE_ANON_KEY; +const supabaseUrl = PUBLIC_SUPABASE_URL; +const supabaseKey = PUBLIC_SUPABASE_ANON_KEY; export const supabase = createClient(supabaseUrl, supabaseKey); `} From 182b7f1c4ef18f54e798defe233edc4ae4a1c934 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:32:28 -0400 Subject: [PATCH 060/255] fix(docs): local build without secrets (#29930) Allow docs to build locally for contributors without secrets by guarding the partners DB query behind an IS_PLATFORM check. --- apps/docs/app/guides/(with-sidebar)/layout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/app/guides/(with-sidebar)/layout.tsx b/apps/docs/app/guides/(with-sidebar)/layout.tsx index b67a47c2fda..452ee294b26 100644 --- a/apps/docs/app/guides/(with-sidebar)/layout.tsx +++ b/apps/docs/app/guides/(with-sidebar)/layout.tsx @@ -1,10 +1,12 @@ import { type PropsWithChildren } from 'react' +import { IS_PLATFORM } from 'common' + import { supabaseMisc } from '~/lib/supabaseMisc' import Layout from '~/layouts/guides' const GuidesLayout = async ({ children }: PropsWithChildren) => { - const partners = await getPartners() + const partners = IS_PLATFORM ? await getPartners() : [] const partnerNavItems = partners.map((partner) => ({ name: partner.title, url: `https://supabase.com/partners/integrations/${partner.slug}` as `https://${string}`, From 6719b1c0e0b98be5770ebaf282837249a53d25ee Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 17 Oct 2024 10:33:07 -0400 Subject: [PATCH 061/255] fix(docs): make command examples copyable (#29932) Remove extraneous text and promp markers from command examples so they are more easily copyable. --- .../guides/platform/ssl-enforcement.mdx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/guides/platform/ssl-enforcement.mdx b/apps/docs/content/guides/platform/ssl-enforcement.mdx index 6512f254bfd..d0b4c82efa6 100644 --- a/apps/docs/content/guides/platform/ssl-enforcement.mdx +++ b/apps/docs/content/guides/platform/ssl-enforcement.mdx @@ -31,14 +31,18 @@ To get started: You can use the `get` subcommand of the CLI to check whether SSL is currently being enforced: ```bash -> supabase ssl-enforcement --project-ref {ref} get --experimental +supabase ssl-enforcement --project-ref {ref} get --experimental +``` + +Response if SSL is being enforced: + +```bash SSL is being enforced. ``` -Or similarly, if SSL is not being enforced, you will see: +Response if SSL is not being enforced: ```bash -> supabase ssl-enforcement --project-ref {ref} get --experimental SSL is *NOT* being enforced. ``` @@ -47,15 +51,13 @@ SSL is *NOT* being enforced. The `update` subcommand is used to change the SSL enforcement status for your project: ```bash -> supabase ssl-enforcement --project-ref {ref} update --enable-db-ssl-enforcement --experimental -SSL is now being enforced. +supabase ssl-enforcement --project-ref {ref} update --enable-db-ssl-enforcement --experimental ``` Similarly, to disable SSL enforcement: ```bash -> supabase ssl-enforcement --project-ref {ref} update --disable-db-ssl-enforcement --experimental -SSL is *NOT* being enforced. +supabase ssl-enforcement --project-ref {ref} update --disable-db-ssl-enforcement --experimental ``` ### A note about Postgres SSL modes @@ -67,11 +69,11 @@ The strongest mode offered by Postgres is `verify-full` and this is the mode you Once the CA certificate has been downloaded, add it to the certificate authority list used by Postgres. ```bash -> cat {location of downloaded prod-ca-2021.crt} >> ~/.postgres/root.crt +cat {location of downloaded prod-ca-2021.crt} >> ~/.postgres/root.crt ``` With the CA certificate added to the trusted certificate authorities list, use `psql` or your client library to connect to Supabase: ```bash -> psql "postgresql://aws-0-eu-central-1.pooler.supabase.com:6543/postgres?sslmode=verify-full" -U postgres. +psql "postgresql://aws-0-eu-central-1.pooler.supabase.com:6543/postgres?sslmode=verify-full" -U postgres. ``` From 7feff8f45c9191fe27101e520719531574f6d049 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 17 Oct 2024 16:34:24 +0200 Subject: [PATCH 062/255] fix(docs): self hosting db connection update (#29948) * fix(docs): update self-hosting docs Follow up: https://github.com/supabase/supabase/pull/29596 * chore: add env configurable tenant id --- .../content/guides/self-hosting/docker.mdx | 18 +++++++++++++++--- docker/.env.example | 1 + docker/docker-compose.yml | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 026fc05db9f..d45e1886573 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -107,14 +107,25 @@ You can add new Functions as `volumes/functions//index.ts`. Resta ### Accessing Postgres -You can connect to the Postgres database locally on port `5432`. For example, if you have `psql` on your local machine you can run: +By default, the Supabase stack runs the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler. Supavisor provides efficient management of database connections. + +You can connect to the Postgres database using the following methods: + +1. For session-based connections (equivalent to direct PostgreSQL connections): ```bash -psql -h 127.0.0.1 -p 5432 -d postgres -U postgres +psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:5432/postgres' ``` -The default password is `your-super-secret-and-long-postgres-password`. You should change this as soon as possible using the [instructions below](#update-secrets). By default the database is not accessible from outside the local machine. You can [change this](#exposing-your-postgres-database) by updating the `docker-compose.yml` file. +2. For pooled transactional connections: +```bash +psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:6543/postgres' +``` + +The default tenant ID is `your-tenant-id`, and the default password is `your-super-secret-and-long-postgres-password`. You should change these as soon as possible using the [instructions below](#update-secrets). + +By default, the database is not accessible from outside the local machine but the pooler is. You can [change this](#exposing-your-postgres-database) by updating the `docker-compose.yml` file. ## Securing your services @@ -145,6 +156,7 @@ Update the `.env` file with your own secrets. In particular, these are required: - `JWT_SECRET`: used by PostgREST and GoTrue, among others. - `SITE_URL`: the base URL of your site. - `SMTP_*`: mail server credentials. You can use any SMTP server. +- `POOLER_TENANT_ID`: the tenant-id that will be used by supavisor pooler for your connection string You will need to [restart](#restarting-all-services) the services for the changes to take effect. diff --git a/docker/.env.example b/docker/.env.example index fb00d7de1b1..f3c5ad067d4 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -25,6 +25,7 @@ POSTGRES_PORT=5432 POOLER_PROXY_PORT_TRANSACTION=6543 POOLER_DEFAULT_POOL_SIZE=20 POOLER_MAX_CLIENT_CONN=100 +POOLER_TENANT_ID=your-tenant-id ############ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 69e61a70a58..6cca5cb04bd 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -470,7 +470,7 @@ services: - METRICS_JWT_SECRET=${JWT_SECRET} - REGION=local - ERL_AFLAGS=-proto_dist inet_tcp - - POOLER_TENANT_ID=your-tenant-id + - POOLER_TENANT_ID=${POOLER_TENANT_ID} - POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE} - POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN} - POOLER_POOL_MODE=transaction From 76d750f29547685ad459e5561c54a9506dfb3b15 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 17 Oct 2024 23:41:56 +0800 Subject: [PATCH 063/255] Update www page leave behaviour (#29945) --- apps/www/pages/_app.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index f3308fffbfc..1d6cfb3592a 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -89,21 +89,20 @@ export default function App({ Component, pageProps }: AppProps) { }, [router.isReady, consentValue]) useEffect(() => { - if (blockEvents) return - const handleBeforeUnload = async () => { - await post(`${API_URL}/telemetry/page-leave`, { - page_url: window.location.href, - page_title: title, - pathname: router.pathname, - }) + if (!blockEvents) { + await post(`${API_URL}/telemetry/page-leave`, { + page_url: window.location.href, + page_title: title, + pathname: router.pathname, + }) + } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => { window.removeEventListener('beforeunload', handleBeforeUnload) } - }, []) + }, [blockEvents, router.pathname, title]) const site_title = `${APP_NAME} | The Open Source Firebase Alternative` const { basePath } = useRouter() From 061dce4d8c7decf1586a98980ebe750f7488ea88 Mon Sep 17 00:00:00 2001 From: TheOtherBrian1 <91111415+TheOtherBrian1@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:44:19 -0400 Subject: [PATCH 064/255] Update logs.mdx | remove restart suggestion (#29934) --- apps/docs/content/guides/monitoring-troubleshooting/logs.mdx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/guides/monitoring-troubleshooting/logs.mdx b/apps/docs/content/guides/monitoring-troubleshooting/logs.mdx index 1eb9b45bc26..d1d408dab4a 100644 --- a/apps/docs/content/guides/monitoring-troubleshooting/logs.mdx +++ b/apps/docs/content/guides/monitoring-troubleshooting/logs.mdx @@ -148,9 +148,8 @@ Do not log Personal Identifiable Information (PII) within the `User-Agent` heade To enable query logs for other categories of statements: 1. [Enable the pgAudit extension](https://supabase.com/dashboard/project/_/database/extensions). -2. [Restart your project](https://supabase.com/dashboard/project/_/settings/general) using the **Fast database reboot** option. -3. Configure `pgaudit.log` (see below). Perform a fast reboot if needed. -4. View your query logs under [Logs > Postgres Logs](https://supabase.com/dashboard/project/_/logs/postgres-logs). +2. Configure `pgaudit.log` (see below). Perform a fast reboot if needed. +3. View your query logs under [Logs > Postgres Logs](https://supabase.com/dashboard/project/_/logs/postgres-logs). ### Configuring `pgaudit.log` From 5bfad6bf1c6249cb3b5dcfa6baca955bc9b4e1c0 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 18 Oct 2024 16:18:18 +0800 Subject: [PATCH 065/255] fix(self-hosted): GraphiQL invalided authentication credentials (#29972) --- apps/studio/pages/project/[ref]/api/graphiql.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/studio/pages/project/[ref]/api/graphiql.tsx b/apps/studio/pages/project/[ref]/api/graphiql.tsx index a658863ddc5..0d7e0bfb9d8 100644 --- a/apps/studio/pages/project/[ref]/api/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/api/graphiql.tsx @@ -71,7 +71,9 @@ const GraphiQLPage: NextPageWithLayout = () => { ...opts, headers: { ...opts?.headers, - Authorization: `Bearer ${accessToken}`, + ...(accessToken && { + Authorization: `Bearer ${accessToken}`, + }), 'x-graphql-authorization': opts?.headers?.['Authorization'] ?? opts?.headers?.['authorization'] ?? From 9a00bf4354c5a1a9e6cb138ecaa59e80d5382825 Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 18 Oct 2024 10:33:49 +0200 Subject: [PATCH 066/255] fix: update security.txt wording (#29973) Fixup a missed mention of security@supabase.io and point everything to Hackerone. --- apps/docs/public/.well-known/security.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/docs/public/.well-known/security.txt b/apps/docs/public/.well-known/security.txt index 19ee976897f..d73657a4ce2 100644 --- a/apps/docs/public/.well-known/security.txt +++ b/apps/docs/public/.well-known/security.txt @@ -6,7 +6,9 @@ At Supabase, we consider the security of our systems a top priority. But no matt If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems. -Out of scope vulnerabilities: +The full scope of our VDP is available at https://hackerone.com/supabase. + +Here is a brief list of some common out of scope vulnerabilities: - Clickjacking on pages with no sensitive actions. - Unauthenticated/logout/login CSRF. @@ -25,7 +27,7 @@ Testing guidelines: - Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data. Reporting guidelines: -- E-mail your findings to security@supabase.io. +- File a report through our VDP at https://hackerone.com/supabase - Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Disclosure guidelines: @@ -36,7 +38,7 @@ Disclosure guidelines: - information about Supabase employees, contractors or partners What we promise: -- We will respond to your report within 7 business days with our evaluation of the report and an expected resolution date. +- We will respond to your report within 5 business days with our evaluation of the report and an expected resolution date. - If you have followed the instructions above, we will not take any legal action against you in regard to the report. - We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission. - We will keep you informed of the progress towards resolving the problem. From d7f92e4d4832d909aca3a259b9ee052abfa48dd8 Mon Sep 17 00:00:00 2001 From: Rodrigo Mansueli Date: Fri, 18 Oct 2024 08:10:01 -0300 Subject: [PATCH 067/255] chore: Update RateLimits.tsx (#28978) * chore: Update RateLimits.tsx Make it clearer * fix: run prettier * fix: apply suggestions from code review Co-authored-by: Alaister Young --------- Co-authored-by: joel Co-authored-by: Alaister Young --- .../components/interfaces/Auth/RateLimits/RateLimits.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index a6258a90e71..5c8c22d1a85 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -408,8 +408,8 @@ const RateLimits = () => { - Number of sign up and sign-in requests that can be made per hour per IP - address (excludes anonymous users). + Number of sign up and sign-in requests that can be made in a 5 minute + interval per IP address (excludes anonymous users).

} > From 9dd7deb990f9da91603bd2e9b23ca67e0d4d8729 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 18 Oct 2024 16:25:13 +0200 Subject: [PATCH 068/255] chore: Add a clean script to all packages/apps (#29926) Add a clean command. --- apps/design-system/package.json | 1 + apps/docs/package.json | 2 +- apps/studio/package.json | 1 + apps/www/package.json | 1 + package-lock.json | 270 ++++++++++++++++++++++++++++-- package.json | 19 +-- packages/ai-commands/package.json | 1 + packages/api-types/package.json | 1 + packages/common/package.json | 1 + packages/generator/package.json | 1 + packages/icons/package.json | 3 +- packages/shared-data/package.json | 4 +- packages/ui-patterns/package.json | 1 + packages/ui/package.json | 1 + playwright-tests/package.json | 3 +- tests/package.json | 1 + turbo.json | 4 + 17 files changed, 279 insertions(+), 36 deletions(-) diff --git a/apps/design-system/package.json b/apps/design-system/package.json index 156079b3a62..01cac2a43e7 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -10,6 +10,7 @@ "start": "next start", "lint": "next lint", "content:build": "contentlayer2 build", + "clean": "rimraf node_modules .next .turbo", "typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/apps/docs/package.json b/apps/docs/package.json index 1950af77461..62f16690760 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -22,7 +22,7 @@ "last-changed:reset": "npm run last-changed -- --reset", "codegen:references": "tsx features/docs/Reference.generated.script.ts", "codemod:frontmatter": "node ./scripts/codemod/mdx-meta.mjs && prettier --write \"content/**/*.mdx\"", - "clean": "rm -rf ./docs/features/docs/generated" + "clean": "rimraf node_modules ./docs/features/docs/generated" }, "dependencies": { "@code-hike/mdx": "^0.9.0", diff --git a/apps/studio/package.json b/apps/studio/package.json index e5afc2f6c2f..0b1f65ed4d8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo", "test": "vitest --run", "test:watch": "vitest watch", "test:ui": "vitest --ui", diff --git a/apps/www/package.json b/apps/www/package.json index 6fbfcbd3cd5..48833eec172 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -9,6 +9,7 @@ "export": "next export", "start": "next start", "lint": "next lint", + "clean": "rimraf node_modules", "typecheck": "npm run content:build && tsc --noEmit", "content:build": "contentlayer2 build", "prettier": "prettier --write \"./{pages,components,lib,stores,styles,tests}/**/*.{ts,tsx,md,js,jsx,json}\"", diff --git a/package-lock.json b/package-lock.json index abc76663856..2a1db90666b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "Apache-2.0", "workspaces": [ "apps/*", - "apps/docs/spec/parser", "tests", "playwright-tests", "packages/*" @@ -19,6 +18,7 @@ "eslint": "^8.57.0", "prettier": "^4.0.0-alpha.8", "prettier-plugin-sql-cst": "^0.11.0", + "rimraf": "^6.0.0", "sass": "^1.72.0", "supabase": "^1.151.1", "turbo": "2.0.4" @@ -8206,6 +8206,26 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": { "version": "6.0.0", "license": "ISC", @@ -8216,6 +8236,21 @@ "node": ">=10" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/semver": { "version": "7.5.4", "license": "ISC", @@ -9680,6 +9715,43 @@ "node": ">=10" } }, + "node_modules/@npmcli/move-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@octokit/auth-app": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.0.3.tgz", @@ -20893,6 +20965,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/del/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/del/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -23201,6 +23310,41 @@ "node": ">=12.0.0" } }, + "node_modules/flat-cache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", @@ -32791,8 +32935,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "license": "ISC", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -35066,6 +35211,22 @@ "node": ">= 6" } }, + "node_modules/pgsql-parser/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pgsql-parser/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -38767,31 +38928,106 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "license": "ISC", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "dev": true, "dependencies": { - "glob": "^7.1.3" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "license": "ISC", + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": "*" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/jackspeak": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.2.tgz", + "integrity": "sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.1.tgz", + "integrity": "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" diff --git a/package.json b/package.json index 591bec4f72e..82a14eb6657 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,12 @@ "author": "Supabase, Inc.", "license": "Apache-2.0", "private": true, - "workspaces": [ - "apps/*", - "apps/docs/spec/parser", - "tests", - "playwright-tests", - "packages/*" - ], + "workspaces": ["apps/*", "tests", "playwright-tests", "packages/*"], "scripts": { "build": "turbo run build", "build:studio": "turbo run build --filter=studio", "build:design-system": "turbo run build --filter=design-system", + "clean": "turbo run clean --parallel && rimraf node_modules", "dev": "turbo run dev --parallel", "dev:reference": "turbo run dev --filter=reference --parallel", "dev:studio": "turbo run dev --filter=studio --parallel", @@ -45,6 +40,7 @@ "eslint": "^8.57.0", "prettier": "^4.0.0-alpha.8", "prettier-plugin-sql-cst": "^0.11.0", + "rimraf": "^6.0.0", "sass": "^1.72.0", "supabase": "^1.151.1", "turbo": "2.0.4" @@ -57,13 +53,6 @@ "npm": "^10.0.0", "node": "^20.0.0" }, - "keywords": [ - "postgres", - "firebase", - "storage", - "functions", - "database", - "auth" - ], + "keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"], "packageManager": "npm@10.7.0" } diff --git a/packages/ai-commands/package.json b/packages/ai-commands/package.json index 2db6c03668b..49d84317fc5 100644 --- a/packages/ai-commands/package.json +++ b/packages/ai-commands/package.json @@ -6,6 +6,7 @@ "license": "MIT", "sideEffects": false, "scripts": { + "clean": "rimraf node_modules", "typecheck": "tsc --noEmit", "test": "jest" }, diff --git a/packages/api-types/package.json b/packages/api-types/package.json index a71266edcba..8225d4d0719 100644 --- a/packages/api-types/package.json +++ b/packages/api-types/package.json @@ -5,6 +5,7 @@ "main": "./index.ts", "types": "./index.ts", "scripts": { + "clean": "rimraf node_modules", "codegen": "openapi-typescript http://localhost:8080/api-json -o types/api.d.ts --alphabetize && prettier --write types/api.d.ts" }, "author": "", diff --git a/packages/common/package.json b/packages/common/package.json index 07194c309d7..34ce7c0c484 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -5,6 +5,7 @@ "types": "./index.tsx", "license": "MIT", "scripts": { + "clean": "rimraf node_modules", "gen:types": "supabase gen types typescript --local >| ./database-types.ts", "typecheck_CURRENTLY_IGNORED": "tsc --noEmit" }, diff --git a/packages/generator/package.json b/packages/generator/package.json index e779983e126..bee0861f1b5 100644 --- a/packages/generator/package.json +++ b/packages/generator/package.json @@ -5,6 +5,7 @@ "types": "./index.tsx", "license": "MIT", "scripts": { + "clean": "rimraf node_modules", "tsdoc:dereference:functions:v1": "tsx ./tsdoc.ts --input ../../apps/docs/spec/enrichments/tsdoc_v1/functions.json --output ../../apps/docs/spec/enrichments/tsdoc_v1/functions_dereferenced.json", "tsdoc:dereference:gotrue:v1": "tsx ./tsdoc.ts --input ../../apps/docs/spec/enrichments/tsdoc_v1/gotrue.json --output ../../apps/docs/spec/enrichments/tsdoc_v1/gotrue_dereferenced.json", "tsdoc:dereference:postgrest:v1": "tsx ./tsdoc.ts --input ../../apps/docs/spec/enrichments/tsdoc_v1/postgrest.json --output ../../apps/docs/spec/enrichments/tsdoc_v1/postgrest_dereferenced.json", diff --git a/packages/icons/package.json b/packages/icons/package.json index a33333c9b24..9db7c532dd8 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -2,7 +2,8 @@ "name": "icons", "main": "./src/icons/index.ts", "scripts": { - "build:icons": "build-icons --templateSrc=./scripts/exportTemplate.mjs --renderUniqueKey --iconFileExtension=.ts --exportFileName=index.ts" + "build:icons": "build-icons --templateSrc=./scripts/exportTemplate.mjs --renderUniqueKey --iconFileExtension=.ts --exportFileName=index.ts", + "clean": "rimraf node_modules" }, "dependencies": { "typescript": "~5.5.0", diff --git a/packages/shared-data/package.json b/packages/shared-data/package.json index 9dd0d49e5be..01d3593e582 100644 --- a/packages/shared-data/package.json +++ b/packages/shared-data/package.json @@ -4,7 +4,9 @@ "description": "Data shared between Supabase apps", "main": "./index.ts", "types": "./index.ts", - "scripts": {}, + "scripts": { + "clean": "rimraf node_modules" + }, "author": "", "license": "MIT" } diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index f1b3bc5d1e3..60589196347 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -6,6 +6,7 @@ "sideEffects": false, "scripts": { "test": "vitest", + "clean": "rimraf node_modules", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/ui/package.json b/packages/ui/package.json index dbbfa23eb6b..df7ad43ac4c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,7 @@ "transform-tokens": "node ./transformTokens.js", "extract-design-tokens": "node internals/tokens/extract-design-tokens.js", "generate-styles": "npm run extract-design-tokens && npm run transform-tokens && npm run cleanse-css-for-tailwind && npm run generate-demo-tailwind-classes", + "clean": "rimraf node_modules", "test": "vitest" }, "dependencies": { diff --git a/playwright-tests/package.json b/playwright-tests/package.json index dc978f7caf4..a47f3552792 100644 --- a/playwright-tests/package.json +++ b/playwright-tests/package.json @@ -9,7 +9,8 @@ "test": "playwright test", "posttest": "supabase stop --no-backup", "codegen:setup": "npm run env:setup && NODE_ENV=test npm --prefix ../apps/studio run dev", - "codegen": "playwright codegen http://localhost:8082/project/default" + "codegen": "playwright codegen http://localhost:8082/project/default", + "clean": "rimraf node_modules" }, "keywords": [], "author": "", diff --git a/tests/package.json b/tests/package.json index 79e649014de..9feb6a2070c 100644 --- a/tests/package.json +++ b/tests/package.json @@ -12,6 +12,7 @@ "allure:generate": "rm -rf allure-report && node_modules/allure-commandline/bin/allure generate", "allure:serve": "node_modules/allure-commandline/bin/allure serve", "test:report": "npm run allure:generate && npm run allure:serve", + "clean": "rimraf node_modules", "format": "prettier --write \"./**/*.{js,jsx,ts,tsx,css,md}\"" }, "author": "Supabase, Inc.", diff --git a/turbo.json b/turbo.json index 4e4c8327e90..7f58710cd1b 100644 --- a/turbo.json +++ b/turbo.json @@ -77,6 +77,10 @@ "lint": { "outputs": [] }, + "clean": { + "outputs": [], + "cache": false + }, "dev": { "cache": false }, From 784a3aca324bb1ec1a9dd4b05e9d1c664587a822 Mon Sep 17 00:00:00 2001 From: Karan Das <127123023+0xkar4n@users.noreply.github.com> Date: Fri, 18 Oct 2024 20:10:20 +0530 Subject: [PATCH 069/255] Chore: Replaced any with interface CSSProperties (#29650) Chore: Replaced any with interface CSSProperties where css is used as a props --- apps/www/components/Panel/Panel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/www/components/Panel/Panel.tsx b/apps/www/components/Panel/Panel.tsx index 327210fac98..352d9e4e729 100644 --- a/apps/www/components/Panel/Panel.tsx +++ b/apps/www/components/Panel/Panel.tsx @@ -12,8 +12,8 @@ interface Props { hasInnerShimmer?: boolean shimmerFromColor?: string shimmerToColor?: string - style?: any - innerStyle?: any + style?: React.CSSProperties + innerStyle?: React.CSSProperties hasMotion?: boolean } From 995331f912a9a2336d3ed4f9b69cbd55c5281b88 Mon Sep 17 00:00:00 2001 From: Greg Date: Fri, 18 Oct 2024 15:36:38 -0400 Subject: [PATCH 070/255] Updated the Edge Functions Hero Call to Action text (#29965) Co-authored-by: Greg Papas --- apps/www/data/products/functions/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/data/products/functions/page.tsx b/apps/www/data/products/functions/page.tsx index cf94adb7bca..304be154d07 100644 --- a/apps/www/data/products/functions/page.tsx +++ b/apps/www/data/products/functions/page.tsx @@ -44,7 +44,7 @@ export default (isMobile?: boolean) => ({ image: , ctas: [ { - label: 'Launch a free database', + label: 'Create a free account', href: '/dashboard', type: 'primary' as any, }, From 62dc3ce5144af725ceac7972c8928c00f6b8eeda Mon Sep 17 00:00:00 2001 From: TheOtherBrian1 <91111415+TheOtherBrian1@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:40:48 -0400 Subject: [PATCH 071/255] updated pg_net docs | outlines configuration process (#29984) * updated pg_net docs | outlines configuration process * updated pg_net docs | outlines configuration process --------- Co-authored-by: Brian Brennglass --- .../guides/database/extensions/pg_net.mdx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 8556179afb4..bfedae9c95d 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -309,9 +309,9 @@ order by "created" desc; ## Configuration - + -See the [self-hosting docs](/docs/guides/hosting/overview#configuration) for self-hosting instructions. +Supabase supports reconfiguring pg*net starting from v0.10.0+. For the latest release, initiate a postgres upgrade in the [Infrastructure Settings](https://supabase.com/dashboard/project/*/settings/infrastructure). @@ -329,18 +329,11 @@ where "name" like 'pg_net%'; ### Alter settings -Note, that doing `ALTER SYSTEM` requires SUPERUSER but on PostgreSQL >= 15, you can do: +Change variables: ```sql -grant alter system on parameter pg_net.ttl to ; -grant alter system on parameter pg_net.batch_size to ; -``` - -Then variables can be changed: - -```sql -alter system set pg_net.ttl to '1 hour' -alter system set pg_net.batch_size to 500; +alter role "postgres" set pg_net.ttl to '24 hour'; +alter role "postgres" set pg_net.batch_size to 500; ``` Then reload the settings and restart the `pg_net` background worker with: From 9f9b083ba5a31112ec8ceeedeb43ea6fa5958996 Mon Sep 17 00:00:00 2001 From: Thomas <31189692+ecktoteckto@users.noreply.github.com> Date: Sun, 20 Oct 2024 06:29:53 +0200 Subject: [PATCH 072/255] fix: item description and unit price of Billing Breakdown (#29996) --- .../BillingSettings/BillingBreakdown/UpcomingInvoice.tsx | 9 ++++++--- .../interfaces/Organization/BillingSettings/helpers.ts | 3 +++ apps/studio/data/analytics/org-daily-stats-query.ts | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index f8d9f5c3047..2bfef53e189 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -113,7 +113,7 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { Usage - + Unit price Cost @@ -124,6 +124,9 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { {item.description ?? 'Unknown'} + {item.usage_metric && + billingMetricUnit(item.usage_metric) && + ` (${billingMetricUnit(item.usage_metric)})`} {!item.proration && ( @@ -131,12 +134,12 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { )} {!item.proration && ( - + {item.unit_price === 0 ? 'FREE' : item.unit_price ? formatCurrency(item.unit_price) - : null} + : `${item.unit_price_desc}`} )} {formatCurrency(item.amount)} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts b/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts index 62677c4ba83..7d89dc35e24 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/helpers.ts @@ -46,6 +46,9 @@ export const billingMetricUnit = (pricingMetric: PricingMetric) => { PricingMetric.PITR_7, PricingMetric.PITR_14, PricingMetric.PITR_28, + PricingMetric.LOG_DRAIN, + PricingMetric.AUTH_MFA_PHONE, + PricingMetric.AUTH_MFA_WEB_AUTHN, ].includes(pricingMetric) ) { return 'Hours' diff --git a/apps/studio/data/analytics/org-daily-stats-query.ts b/apps/studio/data/analytics/org-daily-stats-query.ts index 4f03e565f34..f6c0ca56ed7 100644 --- a/apps/studio/data/analytics/org-daily-stats-query.ts +++ b/apps/studio/data/analytics/org-daily-stats-query.ts @@ -40,6 +40,8 @@ export enum PricingMetric { DISK_THROUGHPUT_GP3 = 'DISK_THROUGHPUT_GP3', LOG_DRAIN = 'LOG_DRAIN', LOG_DRAIN_EVENTS = 'LOG_DRAIN_EVENTS', + AUTH_MFA_PHONE = 'AUTH_MFA_PHONE', + AUTH_MFA_WEB_AUTHN = 'AUTH_MFA_WEB_AUTHN', } export enum ComputeUsageMetric { From c4dfc4a4547146c4fca55875d6f9c8db013a8ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Mon, 21 Oct 2024 18:22:08 +0800 Subject: [PATCH 073/255] chore: cleanup dead code paths for Stripe (#30001) --- .../CostControl/CostControl.tsx | 39 +------- .../Subscription/Subscription.tsx | 40 +-------- .../Organization/Usage/TotalUsage.tsx | 4 +- .../Organization/Usage/Usage.constants.tsx | 10 +-- .../Addons/ComputeInstanceSidePanel.tsx | 12 --- .../Settings/Addons/CustomDomainSidePanel.tsx | 59 ++---------- .../Settings/Addons/IPv4SidePanel.tsx | 90 +++++-------------- .../Settings/Addons/PITRSidePanel.tsx | 57 ++---------- .../DeployNewReplicaPanel.tsx | 5 +- .../studio/components/ui/Charts/AreaChart.tsx | 2 +- apps/studio/components/ui/Charts/BarChart.tsx | 2 +- ...g-subscription-cancel-schedule-mutation.ts | 52 ----------- .../pages/project/[ref]/reports/database.tsx | 7 +- .../pages/project/[ref]/settings/database.tsx | 9 +- 14 files changed, 46 insertions(+), 342 deletions(-) delete mode 100644 apps/studio/data/subscriptions/org-subscription-cancel-schedule-mutation.ts diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 172cf511853..0a4e467a763 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' -import { Calendar, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' @@ -14,13 +13,12 @@ import { import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useOrganizationBillingSubscriptionCancelSchedule } from 'data/subscriptions/org-subscription-cancel-schedule-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Button } from 'ui' import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' import SpendCapSidePanel from './SpendCapSidePanel' @@ -51,9 +49,6 @@ const CostControl = ({}: CostControlProps) => { const canChangeTier = !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') - const { mutate: cancelSubscriptionSchedule, isLoading: cancelSubscriptionScheduleLoading } = - useOrganizationBillingSubscriptionCancelSchedule() - return ( <> @@ -132,36 +127,6 @@ const CostControl = ({}: CostControlProps) => {

)} - {/** Toggled on spend cap, scheduled change for end-of-cycle */} - {subscription?.scheduled_plan_change?.target_plan === 'pro' && - subscription?.scheduled_plan_change?.usage_billing_enabled === false && - subscription?.plan.id === 'pro' && ( - - - Scheduled change - -
- Your spend cap will be enabled on{' '} - {dayjs(subscription?.scheduled_plan_change?.at).format('MMMM D, YYYY')}. - You will not be charged for any over-usage moving on. If you would like - to keep the spend cap disabled and scale as you go, you may still cancel - the scheduled change. -
-
- -
-
-
- )} -
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 674d32fb95b..c24a6a46c70 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import dayjs from 'dayjs' -import { Calendar, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { @@ -13,12 +13,11 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import SparkBar from 'components/ui/SparkBar' -import { useOrganizationBillingSubscriptionCancelSchedule } from 'data/subscriptions/org-subscription-cancel-schedule-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Button } from 'ui' import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' import { Restriction } from '../Restriction' import PlanUpdateSidePanel from './PlanUpdateSidePanel' @@ -41,9 +40,6 @@ const Subscription = () => { isSuccess, } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canReadSubscriptions }) - const { mutate: cancelSubscriptionSchedule, isLoading: cancelSubscriptionScheduleLoading } = - useOrganizationBillingSubscriptionCancelSchedule() - const currentPlan = subscription?.plan const planName = currentPlan?.name ?? 'Unknown' const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() @@ -99,38 +95,6 @@ const Subscription = () => {

- {subscription?.scheduled_plan_change && - subscription?.scheduled_plan_change?.target_plan !== subscription.plan.id && ( - - - Scheduled downgrade - -
- Your organization will automatically be downgraded from the{' '} - {subscription.plan.name} plan to the{' '} - - {subscription?.scheduled_plan_change?.target_plan} - {' '} - plan on{' '} - {dayjs(subscription?.scheduled_plan_change?.at).format('MMMM D, YYYY')}. - If you would like to stay on the {subscription.plan.name}{' '} - plan, cancel the scheduled downgrade. -
-
- -
-
-
- )} -
diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index 04e6b853e75..efc429764c8 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -215,60 +215,11 @@ const CustomDomainSidePanel = () => {
- {hasChanges && ( - <> - {selectedOption === 'cd_none' || - (selectedCustomDomain?.price ?? 0) < (subscriptionCDOption?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

- - Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your - organization and used in subsequent billing cycles. - -

- ) - : !subscription?.billing_via_partner && ( -

- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedCustomDomain?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your custom domain add on. - - - ) - } - + {hasChanges && selectedOption !== 'cd_none' && ( +

+ There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +

)} {isFreePlan && ( diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index ab14efb9640..252cabdfd02 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -187,72 +187,30 @@ const IPv4SidePanel = () => { {hasChanges && ( <> - {selectedOption === 'ipv4_none' || - (selectedIPv4?.price ?? 0) < (subscriptionIpV4Option?.variant.price ?? 0) ? ( - subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

- Upon clicking confirm, the add-on is removed immediately and any unused time in - the current billing cycle is added as prorated credits to your organization and - used in subsequent billing cycles. -

- ) - ) : ( - <> - - There might be some downtime when enabling the add-on since some DNS clients - might have cached the old DNS entry. Generally, this should be less than a - minute. - -

- By default, this is only applied to the Primary database for your project. If{' '} - - Read replicas - {' '} - are used, each replica also gets its own IPv4 address, with a corresponding{' '} - {formatCurrency(selectedIPv4?.price)}{' '} - charge. -

- {!subscription?.billing_via_partner && ( -

- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the respective amount will be added to your monthly - invoice. The addon is prepaid per month and in case of a downgrade, you - get credits for the remaining time. For the current billing cycle you're - immediately charged a prorated amount for the remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you - change your PITR add on. - - - ) - } - + + There might be some downtime when enabling the add-on since some DNS clients might + have cached the old DNS entry. Generally, this should be less than a minute. + + {selectedOption !== 'ipv4_none' && ( +

+ By default, this is only applied to the Primary database for your project. If{' '} + + Read replicas + {' '} + are used, each replica also gets its own IPv4 address, with a corresponding{' '} + {formatCurrency(selectedIPv4?.price)}{' '} + charge. +

+ )} + {!subscription?.billing_via_partner && ( +

+ There are no immediate charges. The addon is billed at the end of your billing + cycle based on your usage and prorated to the hour. +

)} )} diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 8169b1ec33f..62b21a77f8f 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -346,58 +346,11 @@ const PITRSidePanel = () => {
)} - {hasChanges && !blockDowngradeDueToReadReplicas && ( - <> - {selectedOption === 'pitr_0' || - (selectedPitr?.price ?? 0) < (subscriptionPitr?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

- Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your organization - and used in subsequent billing cycles. -

- ) - : !subscription?.billing_via_partner && ( -

- {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedPitr?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

- )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your PITR add on. - - - ) - } - + {hasChanges && !blockDowngradeDueToReadReplicas && selectedOption !== 'pitr_0' && ( +

+ There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +

)}
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index a1e5ab6d240..7a6bcf26f45 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -98,10 +98,7 @@ const DeployNewReplicaPanel = ({ // @ts-ignore const { size_gb, type, throughput_mbps, iops } = diskConfiguration?.attributes ?? {} - const showNewDiskManagementUI = - diskManagementV2 && - subscription?.usage_based_billing_project_addons && - project?.cloud_provider === 'AWS' + const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' const readReplicaDiskSizes = (size_gb ?? 0) * 1.25 const additionalCostDiskSize = readReplicaDiskSizes * DISK_PRICING[type as DiskType]?.storage ?? 0 const additionalCostIOPS = calculateIOPSPrice({ diff --git a/apps/studio/components/ui/Charts/AreaChart.tsx b/apps/studio/components/ui/Charts/AreaChart.tsx index 933999a0cf1..c5820d5b7e9 100644 --- a/apps/studio/components/ui/Charts/AreaChart.tsx +++ b/apps/studio/components/ui/Charts/AreaChart.tsx @@ -48,7 +48,7 @@ const AreaChart = ({ if (data.length === 0) { return ( , - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation( - (vars) => cancelSubscriptionSchedule(vars), - { - async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(variables.slug)) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to cancel subscription schedule: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 5e565ec1dc8..c573b79a3c2 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -26,7 +26,6 @@ import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { NextPageWithLayout } from 'types' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' @@ -53,11 +52,7 @@ const DatabaseUsage = () => { const isReplicaSelected = state.selectedDatabaseId !== project?.ref - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) - const showNewDiskManagementUI = - subscription?.usage_based_billing_project_addons && - diskManagementV2 && - project?.cloud_provider === 'AWS' + const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' const report = useDatabaseReport() const { data } = useDatabaseSizeQuery({ diff --git a/apps/studio/pages/project/[ref]/settings/database.tsx b/apps/studio/pages/project/[ref]/settings/database.tsx index 2a7b0c0239c..23d237dfb53 100644 --- a/apps/studio/pages/project/[ref]/settings/database.tsx +++ b/apps/studio/pages/project/[ref]/settings/database.tsx @@ -15,20 +15,13 @@ import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfigu import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' import { useFlag } from 'hooks/ui/useFlag' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' const ProjectSettings: NextPageWithLayout = () => { const diskManagementV2 = useFlag('diskManagementV2') const project = useSelectedProject() - const selectedOrg = useSelectedOrganization() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrg?.slug }) - const showNewDiskManagementUI = - diskManagementV2 && - project?.cloud_provider === 'AWS' && - subscription?.usage_based_billing_project_addons + const showNewDiskManagementUI = diskManagementV2 && project?.cloud_provider === 'AWS' return ( <> From 442bba9ed58b8ab02525188f85ef2ed7e672baf0 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 21 Oct 2024 12:44:42 +0200 Subject: [PATCH 074/255] fix copy btn alignment, fix titles (#29980) --- .../DatabaseApiSelectionRender.tsx | 10 ++++++---- .../FunctionInvocationSelectionRender.tsx | 10 ++++++---- .../interfaces/Settings/Logs/LogsFormatters.tsx | 6 +++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx index c7a6d94f3f1..055552e3e7a 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx @@ -39,8 +39,8 @@ const DatabaseApiSelectionRender = ({ log }: any) => {
-

Request Metadata

-
+        

Request Metadata

+
           
{
-

Response Metadata

-
+        

+ Response Metadata +

+
           
)}
-

Request Metadata

-
+        

Request Metadata

+
           
-

Response Metadata

-
+        

+ Response Metadata +

+
           
{ return ( -
+
{label} {valueRender ?? value} From b319ea91a45c86ac186e87604547c277d962adf3 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 21 Oct 2024 21:14:46 +0900 Subject: [PATCH 075/255] docs: Update auth self-host docs to the latest info (#29994) * docs: Update auth self-host docs to the latest info * update description of URI_ALLOW_LIST * update the variable name * Add more description about how to configure auth on self-hosted environment * minor styling fix --- .../self-hosting/analytics/config/page.tsx | 2 +- .../self-hosting/auth/config/page.tsx | 2 +- .../self-hosting/realtime/config/page.tsx | 2 +- .../self-hosting/storage/config/page.tsx | 2 +- .../content/guides/self-hosting/docker.mdx | 2 +- apps/docs/spec/gotrue_v1_config.yaml | 60 +++++++++++-------- 6 files changed, 41 insertions(+), 29 deletions(-) diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx index d397c0003f0..03a88cc171f 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx @@ -21,7 +21,7 @@ const AnalyticsConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx index d8a33d04694..9d6ff028b51 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx @@ -21,7 +21,7 @@ const AuthConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx index 01180ba859c..d28ca1077a7 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx @@ -21,7 +21,7 @@ const RealtimeConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx index 766bc4d190a..1201543f943 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx @@ -21,7 +21,7 @@ const StorageConfigPage = async () => { diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index d45e1886573..4139801d85b 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -265,7 +265,7 @@ Each system has a number of configuration options which can be found in the rele - [Postgres](https://hub.docker.com/_/postgres/) - [PostgREST](https://postgrest.org/en/stable/configuration.html) - [Realtime](https://github.com/supabase/realtime#server) -- [GoTrue](https://github.com/supabase/gotrue) +- [Auth](https://github.com/supabase/auth) - [Storage](https://github.com/supabase/storage-api) - [Kong](https://docs.konghq.com/gateway/latest/install/docker/) - [Supavisor](https://supabase.github.io/supavisor/development/docs/) diff --git a/apps/docs/spec/gotrue_v1_config.yaml b/apps/docs/spec/gotrue_v1_config.yaml index 31827802154..ff4a418c9ff 100644 --- a/apps/docs/spec/gotrue_v1_config.yaml +++ b/apps/docs/spec/gotrue_v1_config.yaml @@ -11,9 +11,11 @@ info: title: 'GoTrue' # {string} A readable name. source: 'https://github.com/supabase/gotrue' # {string} Where developers can find the source code. bugs: 'https://github.com/supabase/gotrue/issues' # {string} Where developers can file bugs. - spec: 'https://github.com/supabase/supabase/blob/master/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). + spec: 'https://github.com/supabase/supabase/blob/master/docs/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). description: | - A `config.toml` file is generated after running `supabase init`. This file is located in the `supabase` folder under `supabase/config.toml`. + In a self-hosted environment, you do not have access to the Auth configuration such as third party OAuth provider settings through the Supabase dashboard. Instead, you configure them through the `docker-compose.yml` file. You can read more about the configuration in the [Self-hosting guide](/docs/guides/self-hosting/docker#configuring-services). + + You can find the complete list of available configuration parameters on the README of the [Auth repository](https://github.com/supabase/auth?tab=readme-ov-file#configuration). tags: - id: general title: General @@ -21,58 +23,68 @@ info: # This section is an array of public functions which a user might need to execute. parameters: - - id: 'project_id' # {string} A unique identifier for this param. - title: 'project_id' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - A string used to distinguish different Supabase projects on the same host. Defaults to the working directory name when running `supabase init`. - - id: 'GOTRUE_EXTERNAL_GITHUB' # {string} A unique identifier for this param. - title: 'auth.external.github' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - Describes whether the Github provider is enabled or not. - id: 'GOTRUE_SITE_URL' # {string} A unique identifier for this param. - title: 'auth.site_url' # {string} Any name. + title: 'GOTRUE_SITE_URL' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails. + - id: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + Whether the external provider, GitHub in this case, is enabled or not. + - id: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client ID registered with the external provider. + - id: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client Secret provided by the external provider when you registered. + - id: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The URI a OAuth2 provider will redirect to with the `code` and `state` values. - id: 'GOTRUE_URI_ALLOW_LIST' # {string} A unique identifier for this param. - title: 'auth.additional_redirect_urls' # {string} Any name. + title: 'GOTRUE_URI_ALLOW_LIST' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | - A list of *exact* URLs that auth providers are permitted to redirect to post authentication. + A comma separated list of URIs (e.g. `"https://foo.example.com,https://*.foo.example.com,https://bar.example.com"`) which are permitted as valid `redirect_to` destinations. - id: 'GOTRUE_JWT_EXP' # {string} A unique identifier for this param. - title: 'auth.jwt_expiry' # {string} Any name. + title: 'GOTRUE_JWT_EXP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week). - id: 'GOTRUE_DISABLE_SIGNUP' # {string} A unique identifier for this param. - title: 'auth.enable_signup' # {string} Any name. + title: 'GOTRUE_DISABLE_SIGNUP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups to your project. - id: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.enable_signup' # {string} Any name. + title: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups via email to your project. - id: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.double_confirm_changes' # {string} Any name. + title: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | If enabled, a user will be required to confirm any email change on both the old, and new email addresses. If disabled, only the new email is required to confirm. - id: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} A unique identifier for this param. - title: 'auth.email.enable_confirmations' # {string} Any name. + title: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | From 262c4393457893bbacd8bd6ce2b6789c7ff08749 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Mon, 21 Oct 2024 10:22:02 -0230 Subject: [PATCH 076/255] Pass search term to entity query (#29986) --- .../Database/Indexes/CreateIndexSidePanel.tsx | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index dd527130d09..9faecac311e 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronsUpDown } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -50,7 +50,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = const [selectedIndexType, setSelectedIndexType] = useState(INDEX_TYPES[0].value) const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false) const [tableDropdownOpen, setTableDropdownOpen] = useState(false) - + const [searchTerm, setSearchTerm] = useState('') const { refetch: refetchIndexes } = useIndexesQuery({ schema: selectedSchema, projectRef: project?.ref, @@ -60,10 +60,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data: entities, isLoading } = useEntityTypesQuery({ + const { data: entities, isLoading: isLoadingEntities } = useEntityTypesQuery({ schema: selectedSchema, sort: 'alphabetical', - search: undefined, + search: searchTerm, projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -94,6 +94,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = [entities?.pages] ) + function handleSearchChange(value: string) { + setSearchTerm(value) + } + const columns = tableColumns?.result[0]?.columns ?? [] const columnOptions: MultiSelectOption[] = columns .filter((column): column is NonNullable => column !== null) @@ -181,7 +185,11 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp sameWidthAsTrigger > - + No schemas found @@ -218,7 +226,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp name="select-table" description={ isSelectEntityDisabled && - !isLoading && + !isLoadingEntities && 'Create a table in this schema via the Table or SQL editor first' } isReactForm={false} @@ -228,28 +236,27 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp open={tableDropdownOpen} onOpenChange={setTableDropdownOpen} > - - {isLoading ? ( - - ) : ( - - )} + + - - + {/* [Terry] shouldFilter context: + https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */} + + - No tables found + + {isLoadingEntities ? ( +
+ + Loading... +
+ ) : ( + 'No tables found' + )} +
7 ? 'h-[210px]' : ''}> {(entityTypes ?? []).map((entity) => ( From 2e159931e1fccd6f0ecbd050c461d6f7aa8a0c76 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Tue, 22 Oct 2024 00:21:24 +0200 Subject: [PATCH 077/255] fix crash in settings page (#30018) * fix * fix possible crash on authorize page * Fix formatting * fix type err --------- Co-authored-by: Terry Sutton --- .../DiskManagementPanelForm.tsx | 22 +++++++++++++------ apps/studio/pages/authorize.tsx | 10 ++++----- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 98221dbad77..5e58cd95843 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -123,7 +123,7 @@ export function DiskManagementPanelForm() { const { data: addons } = useProjectAddonsQuery({ projectRef }) const currentCompute = (addons?.selected_addons ?? []).find( - (x) => x.type === 'compute_instance' + (x: { type: string }) => x.type === 'compute_instance' )?.variant const maxIopsBasedOnCompute = COMPUTE_SIZE_MAX_IOPS[(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_SIZE_MAX_IOPS] @@ -135,7 +135,7 @@ export function DiskManagementPanelForm() { const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug, }) - const planId = subscription?.plan.id ?? '' + const planId = subscription?.plan.id ?? 'free' const isPlanUpgradeRequired = subscription?.plan.id === 'pro' && !subscription.usage_billing_enabled @@ -156,7 +156,7 @@ export function DiskManagementPanelForm() { }) const defaultValues = { - storageType: type, + storageType: type || DiskType.GP3, provisionedIOPS: iops, throughput: throughput_mbps, totalSize: size_gb, @@ -180,8 +180,7 @@ export function DiskManagementPanelForm() { isWithinCooldownWindow || !canUpdateDiskConfiguration - const { includedDiskGB: includedDiskGBMeta } = - PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} + const { includedDiskGB: includedDiskGBMeta } = PLAN_DETAILS[planId] const includedDiskGB = includedDiskGBMeta[watchedStorageType] const minIOPS = IOPS_RANGE[watchedStorageType]?.min ?? 0 @@ -201,12 +200,21 @@ export function DiskManagementPanelForm() { updateDiskConfigurationRQ({ ref: projectRef, ...data }) } + // i hate typescript enums + // gotta clean this up but it allows me to remove type casts for now + function getEnumFromFormValue(value: string) { + if (value === 'io2') return DiskType.IO2 + if (value === 'gp3') return DiskType.GP3 + return DiskType.IO2 + } + + const newStorageType = form.getValues('storageType') const diskSizePrice = calculateDiskSizePrice({ planId, oldSize: form.formState.defaultValues?.totalSize || 0, - oldStorageType: form.formState.defaultValues?.storageType as DiskType, + oldStorageType: getEnumFromFormValue(form.formState.defaultValues?.storageType || 'io2'), newSize: form.getValues('totalSize'), - newStorageType: form.getValues('storageType') as DiskType, + newStorageType: getEnumFromFormValue(newStorageType), }) const iopsPrice = calculateIOPSPrice({ diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 2c23397a595..f79e8d7a2d3 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -44,7 +44,7 @@ const APIAuthorizationPage: NextPageWithLayout = () => { useEffect(() => { if (!isLoadingOrganizations) { - setSelectedOrgSlug(organizations?.[0].slug ?? undefined) + setSelectedOrgSlug(organizations?.[0]?.slug ?? undefined) } }, [isLoadingOrganizations]) @@ -113,7 +113,7 @@ const APIAuthorizationPage: NextPageWithLayout = () => { if (isApproved) { const approvedOrganization = organizations?.find( - (org) => org.slug === requester.approved_organization_slug + (org) => org?.slug === requester.approved_organization_slug ) return ( @@ -184,9 +184,9 @@ const APIAuthorizationPage: NextPageWithLayout = () => { > {(organizations ?? []).map((organization) => ( {organization.name} From e3e6f7dcf75fa90dae9c7c51d3e5c4943fd05f78 Mon Sep 17 00:00:00 2001 From: Mark Burggraf Date: Mon, 21 Oct 2024 17:22:43 -0700 Subject: [PATCH 078/255] =?UTF-8?q?added=20vercel-postgres=20migration=20g?= =?UTF-8?q?uide=20'Migrate=20from=20Vercel=20Postgres=20t=E2=80=A6=20(#299?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added vercel-postgres migration guide 'Migrate from Vercel Postgres to Supabase' * fix: icons and build Update apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- apps/docs/app/page.tsx | 63 +----------- .../NavigationMenu.constants.ts | 65 +++++++++++-- .../guides/platform/migrating-to-supabase.mdx | 8 +- .../migrating-to-supabase/vercel-postgres.mdx | 97 +++++++++++++++++++ .../public/img/icons/vercel-icon-light.svg | 3 + apps/docs/public/img/icons/vercel-icon.svg | 3 + 6 files changed, 170 insertions(+), 69 deletions(-) create mode 100644 apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx create mode 100644 apps/docs/public/img/icons/vercel-icon-light.svg create mode 100644 apps/docs/public/img/icons/vercel-icon.svg diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index ad7a21f787b..b01885c6b6d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -4,6 +4,7 @@ import { IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' +import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker' import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker' import HomeLayout from '~/layouts/HomeLayout' @@ -70,60 +71,6 @@ const products = [ }, ] -const migrationGuides = [ - { - title: 'Auth0', - icon: '/docs/img/icons/auth0-icon', - href: '/guides/resources/migrating-to-supabase/auth0', - hasLightIcon: true, - }, - { - title: 'Firebase Auth', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-auth', - }, - { - title: 'Firestore Data', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firestore-data', - }, - { - title: 'Firebase Storage', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-storage', - }, - { - title: 'Heroku', - icon: '/docs/img/icons/heroku-icon', - href: '/guides/resources/migrating-to-supabase/heroku', - }, - { - title: 'Render', - icon: '/docs/img/icons/render-icon', - href: '/guides/resources/migrating-to-supabase/render', - }, - { - title: 'Amazon RDS', - icon: '/docs/img/icons/aws-rds-icon', - href: '/guides/resources/migrating-to-supabase/amazon-rds', - }, - { - title: 'Postgres', - icon: '/docs/img/icons/postgres-icon', - href: '/guides/resources/migrating-to-supabase/postgres', - }, - { - title: 'MySQL', - icon: '/docs/img/icons/mysql-icon', - href: '/guides/resources/migrating-to-supabase/mysql', - }, - { - title: 'MSSQL', - icon: '/docs/img/icons/mssql-icon', - href: '/guides/resources/migrating-to-supabase/mssql', - }, -] - const selfHostingOptions = [ { title: 'Auth', @@ -268,11 +215,11 @@ const HomePage = () => (
    - {migrationGuides.map((guide) => { + {MIGRATION_PAGES.map((guide) => { return ( -
  • - - +
  • + +
  • ) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index ac355889d2d..6aa8637e1ff 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1,3 +1,7 @@ +import type { ComponentProps } from 'react' + +import type { IconPanel } from 'ui-patterns/IconPanel' + import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Navigation.types' export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ @@ -1809,23 +1813,64 @@ export const local_development: NavMenuConstant = { ], } -export const MIGRATION_PAGES: Partial[] = [ - { name: 'Amazon RDS', url: '/guides/platform/migrating-to-supabase/amazon-rds' }, - { name: 'Auth0', url: '/guides/platform/migrating-to-supabase/auth0' }, - { name: 'Firebase Auth', url: '/guides/platform/migrating-to-supabase/firebase-auth' }, +export const MIGRATION_PAGES: Partial>[] = [ { - name: 'Firebase Firestore', + name: 'Auth0', + icon: '/docs/img/icons/auth0-icon', + url: '/guides/platform/migrating-to-supabase/auth0', + hasLightIcon: true, + }, + { + name: 'Firebase Auth', + icon: '/docs/img/icons/firebase-icon', + url: '/guides/platform/migrating-to-supabase/firebase-auth', + }, + { + name: 'Firestore Data', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firestore-data', }, { name: 'Firebase Storage', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firebase-storage', }, - { name: 'Heroku Postgres', url: '/guides/platform/migrating-to-supabase/heroku' }, - { name: 'MySQL', url: '/guides/platform/migrating-to-supabase/mysql' }, - { name: 'MSSQL', url: '/guides/platform/migrating-to-supabase/mssql' }, - { name: 'Postgres', url: '/guides/platform/migrating-to-supabase/postgres' }, - { name: 'Render', url: '/guides/platform/migrating-to-supabase/render' }, + { + name: 'Heroku', + icon: '/docs/img/icons/heroku-icon', + url: '/guides/platform/migrating-to-supabase/heroku', + }, + { + name: 'Render', + icon: '/docs/img/icons/render-icon', + url: '/guides/platform/migrating-to-supabase/render', + }, + { + name: 'Amazon RDS', + icon: '/docs/img/icons/aws-rds-icon', + url: '/guides/platform/migrating-to-supabase/amazon-rds', + }, + { + name: 'Postgres', + icon: '/docs/img/icons/postgres-icon', + url: '/guides/platform/migrating-to-supabase/postgres', + }, + { + name: 'Vercel Postgres', + icon: '/docs/img/icons/vercel-icon', + url: '/guides/platform/migrating-to-supabase/vercel-postgres', + hasLightIcon: true, + }, + { + name: 'MySQL', + icon: '/docs/img/icons/mysql-icon', + url: '/guides/platform/migrating-to-supabase/mysql', + }, + { + name: 'MSSQL', + icon: '/docs/img/icons/mssql-icon', + url: '/guides/platform/migrating-to-supabase/mssql', + }, ] export const platform: NavMenuConstant = { diff --git a/apps/docs/content/guides/platform/migrating-to-supabase.mdx b/apps/docs/content/guides/platform/migrating-to-supabase.mdx index 494520af8af..f4715f672cf 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase.mdx @@ -11,7 +11,13 @@ Learn how to migrate to Supabase from another database service.
    {migrationPages.map((page) => ( - + ))}
    diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx new file mode 100644 index 00000000000..a94bfaf697f --- /dev/null +++ b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx @@ -0,0 +1,97 @@ +--- +title: 'Migrate from Vercel Postgres to Supabase' +description: 'Migrate your existing Vercel Postgres database to Supabase.' +subtitle: 'Migrate your existing Vercel Postgres database to Supabase.' +--- + +This guide demonstrates how to migrate your Vercel Postgres database to Supabase to get the most out of Postgres while gaining access to all the features you need to build a project. + +## Retrieve your Vercel Postgres database credentials [#retrieve-credentials] + +1. Log in to your Vercel Dashboard [https://vercel.com/login](https://vercel.com/login). +1. Click on the **Storage** tab. +1. Click on your Postgres Database. +1. Under the **Quickstart** section, select **psql** then click **Show Secret** to reveal your database password. +1. Copy the string after `psql ` to the clipboard. + +Example: + +```bash +psql "postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +Copy this part to your clipboard: + +```bash +"postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Set your OLD_DB_URL environment variable + +Set the **OLD_DB_URL** environment variable at the command line using your Vercel Postgres Database credentials. + +Example: + +```bash +export OLD_DB_URL="postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] + +1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set. +1. Click the **Copy** button to the right of your connection string to copy it to the clipboard. + +## Set your NEW_DB_URL environment variable + +Set the **NEW_DB_URL** environment variable at the command line using your Supabase connection string. You will need to replace `[YOUR-PASSWORD]` with your actual database password. + +Example: + +```bash +export NEW_DB_URL="postgresql://postgres.xxxxxxxxxxxxxxxxxxxx:[YOUR-PASSWORD]@aws-0-us-west-1.pooler.supabase.com:5432/postgres" +``` + +## Migrate the database + +You will need the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tools, which are included in a full [PostgreSQL installation](https://www.postgresql.org/download). + +1. Export your database to a file in console + + Use `pg_dump` with your Postgres credentials to export your database to a file (e.g., `dump.sql`). + +```bash +pg_dump "$OLD_DB_URL" \ + --clean \ + --if-exists \ + --quote-all-identifiers \ + --no-owner \ + --no-privileges \ + > dump.sql +``` + +2. Import the database to your Supabase project + + Use `psql` to import the Postgres database file to your Supabase project. + + ```bash + psql -d "$NEW_DB_URL" -f dump.sql + ``` + +Additional options + +- To only migrate a single database schema, add the `--schema=PATTERN` parameter to your `pg_dump` command. +- To exclude a schema: `--exclude-schema=PATTERN`. +- To only migrate a single table: `--table=PATTERN`. +- To exclude a table: `--exclude-table=PATTERN`. + +Run `pg_dump --help` for a full list of options. + + + +## Enterprise + +[Contact us](https://forms.supabase.com/enterprise) if you need more help migrating your project. diff --git a/apps/docs/public/img/icons/vercel-icon-light.svg b/apps/docs/public/img/icons/vercel-icon-light.svg new file mode 100644 index 00000000000..72948d01a7d --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/public/img/icons/vercel-icon.svg b/apps/docs/public/img/icons/vercel-icon.svg new file mode 100644 index 00000000000..4e3b0e93358 --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon.svg @@ -0,0 +1,3 @@ + + + From 8271fa3c51c172c296e69f58db1ab112f2113344 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Tue, 22 Oct 2024 10:03:10 +0300 Subject: [PATCH 079/255] Docs: add --no-restart flag to Postgres Config doc (#30019) Update custom-postgres-config.mdx --- apps/docs/content/guides/database/custom-postgres-config.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index ba8093c1c4e..1fb50e31c2e 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -160,6 +160,6 @@ alter role "" set "" to default; ### Considerations -1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. +1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. However, you can use the --no-restart flag to bypass the restart and keep the connections intact. Keep in mind that this depends on the specific configuration changes you’re making. if the change requires a restart, using the --no-restart flag will prevent the restart but you won’t see those changes take effect until a restart is manually triggered. 1. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute. 1. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io). From 1dcc16ea41c6e764041ab63e4807e2c3b4f4f61e Mon Sep 17 00:00:00 2001 From: Copple <10214025+kiwicopple@users.noreply.github.com> Date: Tue, 22 Oct 2024 09:03:36 +0200 Subject: [PATCH 080/255] chore: update cli reference doc (#29740) --- apps/docs/spec/cli_v1_commands.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index cda35983ec8..d207ef08c80 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 1.200.3 + version: 1.207.8 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -1128,6 +1128,10 @@ commands: name: --config description: Config overrides specified as a 'key=value' pair default_value: '[]' + - id: no-restart + name: --no-restart + description: Do not restart the database after updating config. + default_value: 'false' - id: replace-existing-overrides name: --replace-existing-overrides description: | @@ -2878,7 +2882,7 @@ commands: Requires the local development stack to be started by running `supabase start`. - Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql` by default, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. + Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. Note that since Postgres roles are cluster level entities, those changes will persist between resets. In order to reset custom roles, you need to restart the local development stack. examples: @@ -2976,7 +2980,7 @@ commands: default_value: 'false' - id: include-seed name: --include-seed - description: Include seed data from supabase/seed.sql. + description: Include seed data from your config. default_value: 'false' - id: linked name: --linked From d586346e275b20da133f2439c883ddff73996b89 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Oct 2024 15:21:06 +0800 Subject: [PATCH 081/255] Chore/sentry fixes 211024 (#30003) * Resolve https://supabase.sentry.io/issues/5808689343 * Fix SecurityStatus map key prop issue * Fix FileExplorerHeader to use ButtonTooltip * Fix https://supabase.sentry.io/issues/5998687405 * Fix https://supabase.sentry.io/issues/5414129652 * Fix https://supabase.sentry.io/issues/5414129652 * Fix map key issue in QueryPerformanceGrid * Add whitelist error for sign up mutation * Fix map key in DiskUsage * Fix prop name in info-tooltip --- .../grid/components/editor/TextEditor.tsx | 8 +- .../interfaces/Auth/Users/Users.utils.tsx | 7 +- .../interfaces/Home/SecurityStatus.tsx | 11 +-- .../Usage/UsageSection/DiskUsage.tsx | 5 +- .../QueryPerformance/QueryPerformanceGrid.tsx | 1 + .../StorageExplorer/ColumnContextMenu.tsx | 16 ++-- .../StorageExplorer/FileExplorerHeader.tsx | 88 ++++++------------- .../retrieve-index-from-select-query.ts | 1 + apps/studio/data/misc/signup-mutation.ts | 1 + apps/studio/pages/authorize.tsx | 13 ++- packages/ui-patterns/info-tooltip.tsx | 4 +- 11 files changed, 68 insertions(+), 87 deletions(-) diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 7496f78c727..14c111adea1 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -15,6 +15,7 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, + cn, } from 'ui' import { useTrackedState } from '../../store/Store' import { BlockKeys } from '../common/BlockKeys' @@ -188,9 +189,10 @@ export const TextEditor = ({ } >
    setIsPopoverOpen(!isPopoverOpen)} > {value === null ? : value === '' ? : value} diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index 54ace3d0262..4bb4eaf5024 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -210,9 +210,12 @@ export function getAvatarUrl(user: User): string | undefined { profile_url || profileImageUrl || profileImageURL || - profile_image_url) as string | undefined + profile_image_url || + '') as string | undefined - return SUPPORTED_CSP_AVATAR_URLS.some((x) => (url ?? '').startsWith(x)) ? url : undefined + if (url === undefined) return undefined + const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x)) + return isSupported ? url : undefined } export const formatUserColumns = ({ diff --git a/apps/studio/components/interfaces/Home/SecurityStatus.tsx b/apps/studio/components/interfaces/Home/SecurityStatus.tsx index eeba9e89523..be4dc9b4425 100644 --- a/apps/studio/components/interfaces/Home/SecurityStatus.tsx +++ b/apps/studio/components/interfaces/Home/SecurityStatus.tsx @@ -1,6 +1,6 @@ import { CheckCircle2, ChevronRight, Loader2 } from 'lucide-react' import Link from 'next/link' -import { useState } from 'react' +import { Fragment, useState } from 'react' import { useParams } from 'common' import { useProjectLintsQuery } from 'data/lint/lint-query' @@ -81,12 +81,9 @@ export const SecurityStatus = () => { const { label, descriptionShort } = LINT_TABS.find((tab) => tab.id === level) ?? {} return ( lints.length > 0 && ( - <> + -
    +
    @@ -109,7 +106,7 @@ export const SecurityStatus = () => {
    - + ) ) })} diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index 7c449363541..c682d1e1e53 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -169,7 +169,10 @@ const DiskUsage = ({ const totalDiskUsage = primaryDiskUsage + replicaDiskUsage return ( -
    +
    {project.name} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 8b69078bb51..06d0bd6eefe 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -154,6 +154,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance return ( { if (typeof idx === 'number' && idx >= 0) { setSelectedRow(idx) diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx index 8adbf34019c..3cfb338bee8 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ColumnContextMenu.tsx @@ -60,15 +60,13 @@ const ColumnContextMenu = ({ id = '' }: ColumnContextMenuProps) => { return ( - {canUpdateFiles && ( - <> - onSelectCreateFolder(props.index)}> - - New folder - - - - )} + {canUpdateFiles && [ + onSelectCreateFolder(props.index)}> + + New folder + , + , + ]} onSelectAllItemsInColumn(props.index)}> Select all items diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx index 479fc46ca2b..5e31b0477ca 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -1,10 +1,10 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { compact, debounce, isEqual, noop } from 'lodash' import { useCallback, useEffect, useRef, useState } from 'react' import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import APIDocsButton from 'components/ui/APIDocsButton' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' import { @@ -426,64 +426,34 @@ const FileExplorerHeader = ({ {/* @ts-ignore */}
    - - - - - {!canUpdateStorage && ( - - - -
    - - You need additional permissions to upload files - -
    -
    -
    - )} -
    - - - - - {!canUpdateStorage && ( - - - -
    - - You need additional permissions to create folders - -
    -
    -
    - )} -
    + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to upload files', + }, + }} + > + Upload files + + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={() => addNewFolderPlaceholder(-1)} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to create folders', + }, + }} + > + Create folder +
    diff --git a/apps/studio/data/database/retrieve-index-from-select-query.ts b/apps/studio/data/database/retrieve-index-from-select-query.ts index 06d80dddebc..8ef31cde994 100644 --- a/apps/studio/data/database/retrieve-index-from-select-query.ts +++ b/apps/studio/data/database/retrieve-index-from-select-query.ts @@ -112,6 +112,7 @@ export const useGetIndexesFromSelectQuery = { const { auth_id } = useParams() const [selectedOrgSlug, setSelectedOrgSlug] = useState() - const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery() + const { + data: organizations, + isSuccess: isSuccessOrganizations, + isLoading: isLoadingOrganizations, + } = useOrganizationsQuery() const { data: requester, isLoading, isError, error } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null const isExpired = dayjs().isAfter(dayjs(requester?.expires_at)) @@ -43,10 +47,11 @@ const APIAuthorizationPage: NextPageWithLayout = () => { const isSubmitting = isApproving || isDeclining useEffect(() => { - if (!isLoadingOrganizations) { - setSelectedOrgSlug(organizations?.[0]?.slug ?? undefined) + if (isSuccessOrganizations && organizations.length > 0) { + setSelectedOrgSlug(organizations[0].slug) } - }, [isLoadingOrganizations]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessOrganizations]) const onApproveRequest = async () => { if (!auth_id) { diff --git a/packages/ui-patterns/info-tooltip.tsx b/packages/ui-patterns/info-tooltip.tsx index ae2f2004fdf..e3de1744883 100644 --- a/packages/ui-patterns/info-tooltip.tsx +++ b/packages/ui-patterns/info-tooltip.tsx @@ -10,9 +10,9 @@ const SVG = forwardRef>((props, ref {...props} > )) From f9016e9128d04ae25cad465db348c63fa6222bba Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 22 Oct 2024 06:12:57 -0230 Subject: [PATCH 082/255] Chore/auth logs timestamps (#30013) * co/auth-logs-timestamps * Opt to use TimestampInfo instead * Revert test data --------- Co-authored-by: Joshen Lim --- apps/studio/components/interfaces/Auth/Users/UserLogs.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index 6e5636ca8a8..391f65f922d 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -9,7 +9,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { User } from 'data/auth/users-query' import useLogsPreview from 'hooks/analytics/useLogsPreview' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition, TimestampInfo } from 'ui-patterns' import { UserHeader } from './UserHeader' import { PANEL_PADDING } from './UserPanel' @@ -103,7 +103,6 @@ export const UserLogs = ({ user }: UserLogsProps) => {
    {authLogs.map((log) => { const status = ((log.status ?? '-') as any).toString() - const formattedTime = dayjs(log.timestamp).format('DD MMM HH:mm:ss') const is400 = status.startsWith('4') const is500 = status.startsWith('5') @@ -112,8 +111,8 @@ export const UserLogs = ({ user }: UserLogsProps) => { key={log.id} className="flex items-center transition font-mono px-2 py-1.5 bg-surface-100 divide-x" > -

    - {formattedTime} +

    +

    Date: Tue, 22 Oct 2024 09:39:36 -0230 Subject: [PATCH 083/255] new Integrations page (#29671) * Start new integrations page * Move cronjobs into integrations * Rearrange * Remove duplicate * Move around menu items --- .../Wrappers/WrappersDisabledState.tsx | 2 +- .../CronJobs/CreateCronJobSheet.tsx | 3 +- .../CronJobs/CronJobCard.tsx | 0 .../CronJobs/CronJobScheduleSection.tsx | 0 .../CronJobs/CronJobs.constants.tsx | 0 .../CronJobs/CronJobs.utils.test.ts | 0 .../CronJobs/CronJobs.utils.ts | 0 .../CronJobs/CronJobsDisabledState.tsx | 3 +- .../CronJobs/CronJobsListing.tsx | 3 +- .../CronJobs/DeleteCronJob.tsx | 0 .../CronJobs/EdgeFunctionSection.tsx | 0 .../CronJobs/HttpHeaderFieldsSection.tsx | 0 .../CronJobs/HttpParameterFieldsSection.tsx | 0 .../CronJobs/HttpRequestSection.tsx | 0 .../CronJobs/SqlFunctionSection.tsx | 0 .../CronJobs/SqlSnippetSection.tsx | 0 .../Settings/Vault/VaultNavTabs.tsx | 6 +- .../DatabaseLayout/DatabaseMenu.utils.tsx | 16 +-- .../layouts/DocsLayout/DocsLayout.utils.tsx | 5 +- .../ProjectIntegrations.Commands.tsx | 107 ++++++++++++++++++ .../ProjectIntegrationsLayout.tsx | 59 ++++++++++ .../ProjectIntegrationsMenu.utils.tsx | 66 +++++++++++ .../NavigationBar/NavigationBar.utils.tsx | 8 +- ...gsMenu.utils.ts => SettingsMenu.utils.tsx} | 4 +- apps/studio/next.config.js | 30 +++++ .../{database => integrations}/cron-jobs.tsx | 8 +- .../[ref]/{api => integrations}/graphiql.tsx | 6 +- .../{database => integrations}/hooks.tsx | 5 +- .../{settings => integrations}/vault/keys.tsx | 5 +- .../vault/secrets.tsx | 7 +- .../wrappers/[id].tsx | 6 +- .../wrappers/index.tsx | 5 +- .../wrappers/new.tsx | 0 33 files changed, 320 insertions(+), 34 deletions(-) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CreateCronJobSheet.tsx (99%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobCard.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobScheduleSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.constants.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.utils.test.ts (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobs.utils.ts (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobsDisabledState.tsx (97%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/CronJobsListing.tsx (98%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/DeleteCronJob.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/EdgeFunctionSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpHeaderFieldsSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpParameterFieldsSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/HttpRequestSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/SqlFunctionSection.tsx (100%) rename apps/studio/components/interfaces/{Database => Integrations}/CronJobs/SqlSnippetSection.tsx (100%) create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx create mode 100644 apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx rename apps/studio/components/layouts/ProjectSettingsLayout/{SettingsMenu.utils.ts => SettingsMenu.utils.tsx} (96%) rename apps/studio/pages/project/[ref]/{database => integrations}/cron-jobs.tsx (71%) rename apps/studio/pages/project/[ref]/{api => integrations}/graphiql.tsx (94%) rename apps/studio/pages/project/[ref]/{database => integrations}/hooks.tsx (95%) rename apps/studio/pages/project/[ref]/{settings => integrations}/vault/keys.tsx (89%) rename apps/studio/pages/project/[ref]/{settings => integrations}/vault/secrets.tsx (89%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/[id].tsx (75%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/index.tsx (93%) rename apps/studio/pages/project/[ref]/{database => integrations}/wrappers/new.tsx (100%) diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 920e6a3577d..61933f017b7 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -75,7 +75,7 @@ const WrappersDisabledState = () => { return (
    diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobCard.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobCard.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobCard.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobScheduleSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.constants.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.constants.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.constants.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.test.ts rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobs.utils.ts rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.ts diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx similarity index 97% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx index c6003b569d9..4668f703848 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/CronJobsDisabledState.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsDisabledState.tsx @@ -8,8 +8,9 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button } from 'ui' -import EnableExtensionModal from '../Extensions/EnableExtensionModal' + import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' export const CronJobsDisabledState = () => { const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) diff --git a/apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx index 916dd0a0612..24a6b8958ba 100644 --- a/apps/studio/components/interfaces/Database/CronJobs/CronJobsListing.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsListing.tsx @@ -10,11 +10,12 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { Button, Sheet, SheetContent } from 'ui' -import EnableExtensionModal from '../Extensions/EnableExtensionModal' + import { CreateCronJobSheet } from './CreateCronJobSheet' import { CronJobCard } from './CronJobCard' import { CronJobsDisabledState } from './CronJobsDisabledState' import DeleteCronJob from './DeleteCronJob' +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' export const CronJobsListing = () => { const { project } = useProjectContext() diff --git a/apps/studio/components/interfaces/Database/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/DeleteCronJob.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/EdgeFunctionSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/EdgeFunctionSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpHeaderFieldsSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpParameterFieldsSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpParameterFieldsSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpParameterFieldsSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/HttpRequestSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/HttpRequestSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/HttpRequestSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/SqlFunctionSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/SqlFunctionSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/SqlFunctionSection.tsx diff --git a/apps/studio/components/interfaces/Database/CronJobs/SqlSnippetSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/CronJobs/SqlSnippetSection.tsx rename to apps/studio/components/interfaces/Integrations/CronJobs/SqlSnippetSection.tsx diff --git a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx b/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx index a79cb9fe45c..27d3f4b16c7 100644 --- a/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/VaultNavTabs.tsx @@ -11,10 +11,12 @@ const VaultNavTabs = (props: PropsWithChildren) => { return ( - Secrets Management + + Secrets Management + - Encryption Keys + Encryption Keys ) diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index 02f7c059f0f..bf527528b39 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -101,7 +101,8 @@ export const generateDatabaseMenu = ( { name: 'Wrappers', key: 'wrappers', - url: `/project/${ref}/database/wrappers`, + url: `/project/${ref}/integrations/wrappers`, + rightIcon: , items: [], }, { @@ -115,17 +116,8 @@ export const generateDatabaseMenu = ( { name: 'Webhooks', key: 'hooks', - url: `/project/${ref}/database/hooks`, - items: [], - }, - ] - : []), - ...(!!cronUiEnabled - ? [ - { - name: 'Cron Jobs', - key: 'cron-jobs', - url: `/project/${ref}/database/cron-jobs`, + url: `/project/${ref}/integrations/hooks`, + rightIcon: , items: [], }, ] diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index f6f69f2c2bf..04aa4edd1e5 100644 --- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -1,6 +1,6 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import { BASE_PATH } from 'lib/constants' -import { Book, BookOpen } from 'lucide-react' +import { ArrowUpRight, Book, BookOpen } from 'lucide-react' import SVG from 'react-inlinesvg' export const generateDocsMenu = ( @@ -71,7 +71,7 @@ export const generateDocsMenu = ( { name: 'GraphiQL', key: 'graphiql', - url: `/project/${ref}/api/graphiql`, + url: `/project/${ref}/integrations/graphiql`, icon: ( ), items: [], + rightIcon: , }, ], }, diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx new file mode 100644 index 00000000000..891f526f234 --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx @@ -0,0 +1,107 @@ +import { Code } from 'lucide-react' + +import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/CommandMenu.utils' +import type { CommandOptions } from 'ui-patterns/CommandMenu' +import { useRegisterCommands } from 'ui-patterns/CommandMenu' +import { useParams } from 'common' +import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' + +export function useDatabaseGotoCommands(options?: CommandOptions) { + let { ref } = useParams() + ref ||= '_' + + useRegisterCommands( + COMMAND_MENU_SECTIONS.QUERY, + [ + { + id: 'run-sql', + name: 'Run SQL', + route: `/project/${ref}/sql/new`, + icon: () => , + }, + ], + { + ...options, + deps: [ref], + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 2 }, + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + [ + { + id: 'nav-database-tables', + name: 'Tables', + value: 'Database: Tables', + route: `/project/${ref}/database/tables`, + defaultHidden: true, + }, + { + id: 'nav-database-triggers', + name: 'Triggers', + value: 'Database: Triggers', + route: `/project/${ref}/database/triggers`, + defaultHidden: true, + }, + { + id: 'nav-database-functions', + name: 'Functions', + value: 'Database: Functions', + route: `/project/${ref}/database/functions`, + defaultHidden: true, + }, + { + id: 'nav-database-extensions', + name: 'Extensions', + value: 'Database: Extensions', + route: `/project/${ref}/database/extensions`, + defaultHidden: true, + }, + { + id: 'nav-database-roles', + name: 'Roles', + value: 'Database: Roles', + route: `/project/${ref}/database/roles`, + defaultHidden: true, + }, + { + id: 'nav-database-replication', + name: 'Replication', + value: 'Database: Replication', + route: `/project/${ref}/database/replication`, + defaultHidden: true, + }, + { + id: 'nav-database-hooks', + name: 'Webhooks', + value: 'Database: Webhooks', + route: `/project/${ref}/database/hooks`, + defaultHidden: true, + }, + { + id: 'nav-database-backups', + name: 'Backups', + value: 'Database: Backups', + route: `/project/${ref}/database/backups/scheduled`, + defaultHidden: true, + }, + { + id: 'nav-database-wrappers', + name: 'Wrappers', + value: 'Database: Wrappers', + route: `/project/${ref}/database/wrappers`, + defaultHidden: true, + }, + { + id: 'nav-database-migrations', + name: 'Migrations', + value: 'Database: Migrations', + route: `/project/${ref}/database/migrations`, + defaultHidden: true, + }, + ], + { ...options, deps: [ref] } + ) +} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx new file mode 100644 index 00000000000..a7a08d5f617 --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx @@ -0,0 +1,59 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' + +import { ProductMenu } from 'components/ui/ProductMenu' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { withAuth } from 'hooks/misc/withAuth' +import ProjectLayout from '../ProjectLayout/ProjectLayout' +import { generateProjectIntegrationsMenu } from './ProjectIntegrationsMenu.utils' +import { useFlag } from 'hooks/ui/useFlag' + +export interface ProjectIntegrationsLayoutProps { + title?: string +} + +const ProjectIntegrationsMenu = () => { + const cronUiEnabled = useFlag('cronUi') + const project = useSelectedProject() + + const router = useRouter() + const page = router.pathname.split('/')[4] + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + console.log({ data }) + const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined + const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined + + return ( + <> + + + ) +} + +const ProjectIntegrationsLayout = ({ + children, +}: PropsWithChildren) => { + return ( + } + isBlocking={false} + > + {children} + + ) +} + +export default withAuth(ProjectIntegrationsLayout) diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx new file mode 100644 index 00000000000..78f2f91eb6a --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx @@ -0,0 +1,66 @@ +import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import type { Project } from 'data/projects/project-detail-query' + +export const generateProjectIntegrationsMenu = ( + project?: Project, + flags?: { + pgNetExtensionExists: boolean + cronUiEnabled: boolean + graphqlExtensionExists: boolean + } +): ProductMenuGroup[] => { + const ref = project?.ref ?? 'default' + const { pgNetExtensionExists, cronUiEnabled, graphqlExtensionExists } = flags || {} + + return [ + { + title: 'Manage', + items: [ + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/integrations/wrappers`, + items: [], + }, + ...(!!pgNetExtensionExists + ? [ + { + name: 'Webhooks', + key: 'hooks', + url: `/project/${ref}/integrations/hooks`, + items: [], + }, + ] + : []), + + ...(!!cronUiEnabled + ? [ + { + name: 'Cron Jobs', + key: 'cron-jobs', + url: `/project/${ref}/integrations/cron-jobs`, + items: [], + }, + ] + : []), + ...(!!graphqlExtensionExists + ? [ + { + name: 'GraphiQL', + key: 'graphiql', + url: `/project/${ref}/integrations/graphiql`, + items: [], + }, + ] + : []), + { + name: 'Vault', + key: 'vault', + url: `/project/${ref}/integrations/vault/secrets`, + items: [], + label: 'BETA', + }, + ], + }, + ] +} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 3073b168764..159f5bb7dae 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -12,7 +12,7 @@ import { TableEditor, } from 'icons' import { ICON_SIZE, ICON_STROKE_WIDTH } from './NavigationBar' -import { Settings, FileText, List, Lightbulb } from 'lucide-react' +import { Settings, FileText, List, Lightbulb, Blocks } from 'lucide-react' export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP @@ -138,6 +138,12 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/api`), }, + { + key: 'integrations', + label: 'Integrations', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`), + }, ] } diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx similarity index 96% rename from apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts rename to apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 534af51b477..768c38a20c1 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -2,6 +2,7 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.typ import type { Project } from 'data/projects/project-detail-query' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import type { Organization } from 'types' +import { ArrowUpRight } from 'lucide-react' export const generateSettingsMenu = ( ref?: string, @@ -62,8 +63,9 @@ export const generateSettingsMenu = ( { name: 'Vault', key: 'vault', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/vault/secrets`, + url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/secrets`, items: [], + rightIcon: , label: 'BETA', }, ], diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 5b25632526d..6ea759427e0 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -394,6 +394,36 @@ const nextConfig = { source: '/project/:ref/database/performance-advisor', destination: '/project/:ref/advisors/performance', }, + { + permanent: true, + source: '/project/:ref/database/webhooks', + destination: '/project/:ref/integrations/webhooks', + }, + { + permanent: true, + source: '/project/:ref/database/wrappers', + destination: '/project/:ref/integrations/wrappers', + }, + { + permanent: true, + source: '/project/:ref/.....', + destination: '/project/:ref/integrations/cron-jobs', + }, + { + permanent: true, + source: '/project/:ref/api/graphiql', + destination: '/project/:ref/integrations/graphiql', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/secrets', + destination: '/project/:ref/integrations/vault/secrets', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/keys', + destination: '/project/:ref/integrations/vault/keys', + }, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ { diff --git a/apps/studio/pages/project/[ref]/database/cron-jobs.tsx b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx similarity index 71% rename from apps/studio/pages/project/[ref]/database/cron-jobs.tsx rename to apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx index f0fdf1c88a9..cb183f11488 100644 --- a/apps/studio/pages/project/[ref]/database/cron-jobs.tsx +++ b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx @@ -1,7 +1,7 @@ -import { CronJobsListing } from 'components/interfaces/Database/CronJobs/CronJobsListing' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import { CronJobsListing } from 'components/interfaces/Integrations/CronJobs/CronJobsListing' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const CronJobsPage: NextPageWithLayout = () => { // TODO: Change this to the correct permissions @@ -23,6 +23,8 @@ const CronJobsPage: NextPageWithLayout = () => { ) } -CronJobsPage.getLayout = (page) => {page} +CronJobsPage.getLayout = (page) => ( + {page} +) export default CronJobsPage diff --git a/apps/studio/pages/project/[ref]/api/graphiql.tsx b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx similarity index 94% rename from apps/studio/pages/project/[ref]/api/graphiql.tsx rename to apps/studio/pages/project/[ref]/integrations/graphiql.tsx index 0d7e0bfb9d8..3d34d908c1b 100644 --- a/apps/studio/pages/project/[ref]/api/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx @@ -7,7 +7,6 @@ import { toast } from 'sonner' import { useParams } from 'common' import ExtensionCard from 'components/interfaces/Database/Extensions/ExtensionCard' import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' -import DocsLayout from 'components/layouts/DocsLayout/DocsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { Loading } from 'components/ui/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' @@ -18,6 +17,7 @@ import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const GraphiQLPage: NextPageWithLayout = () => { const { resolvedTheme } = useTheme() @@ -111,6 +111,8 @@ const GraphiQLPage: NextPageWithLayout = () => { return } -GraphiQLPage.getLayout = (page) => {page} +GraphiQLPage.getLayout = (page) => ( + {page} +) export default GraphiQLPage diff --git a/apps/studio/pages/project/[ref]/database/hooks.tsx b/apps/studio/pages/project/[ref]/integrations/hooks.tsx similarity index 95% rename from apps/studio/pages/project/[ref]/database/hooks.tsx rename to apps/studio/pages/project/[ref]/integrations/hooks.tsx index 3bc6f1ad659..31474bccbda 100644 --- a/apps/studio/pages/project/[ref]/database/hooks.tsx +++ b/apps/studio/pages/project/[ref]/integrations/hooks.tsx @@ -16,6 +16,7 @@ import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const HooksPage: NextPageWithLayout = () => { const { project } = useProjectContext() @@ -124,6 +125,8 @@ const HooksPage: NextPageWithLayout = () => { ) } -HooksPage.getLayout = (page) => {page} +HooksPage.getLayout = (page) => ( + {page} +) export default HooksPage diff --git a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx similarity index 89% rename from apps/studio/pages/project/[ref]/settings/vault/keys.tsx rename to apps/studio/pages/project/[ref]/integrations/vault/keys.tsx index 068b46426ed..63e5fd1b4cb 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/keys.tsx +++ b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx @@ -2,6 +2,7 @@ import { useParams } from 'common' import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { @@ -54,5 +55,7 @@ const VaultSettingsSecrets: NextPageWithLayout = () => { ) } -VaultSettingsSecrets.getLayout = (page) => {page} +VaultSettingsSecrets.getLayout = (page) => ( + {page} +) export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx similarity index 89% rename from apps/studio/pages/project/[ref]/settings/vault/secrets.tsx rename to apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx index 5adf498d416..bd3c1c7eade 100644 --- a/apps/studio/pages/project/[ref]/settings/vault/secrets.tsx +++ b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx @@ -1,8 +1,9 @@ import { useParams } from 'common/hooks' import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault' import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' + import { ScaffoldContainer, ScaffoldDescription, @@ -55,5 +56,7 @@ const VaultSettingsSecrets: NextPageWithLayout = () => { ) } -VaultSettingsSecrets.getLayout = (page) => {page} +VaultSettingsSecrets.getLayout = (page) => ( + {page} +) export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/database/wrappers/[id].tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx similarity index 75% rename from apps/studio/pages/project/[ref]/database/wrappers/[id].tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx index d73f76480aa..d9110015d27 100644 --- a/apps/studio/pages/project/[ref]/database/wrappers/[id].tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { EditWrapper } from 'components/interfaces/Database' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' @@ -17,6 +17,8 @@ const DatabaseWrappersNew: NextPageWithLayout = () => { return } -DatabaseWrappersNew.getLayout = (page) => {page} +DatabaseWrappersNew.getLayout = (page) => ( + {page} +) export default DatabaseWrappersNew diff --git a/apps/studio/pages/project/[ref]/database/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx similarity index 93% rename from apps/studio/pages/project/[ref]/database/wrappers/index.tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx index 93e3bf73f4a..6c0d2ea7566 100644 --- a/apps/studio/pages/project/[ref]/database/wrappers/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx @@ -19,6 +19,7 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const DatabaseWrappers: NextPageWithLayout = () => { const { project } = useProjectContext() @@ -80,6 +81,8 @@ const DatabaseWrappers: NextPageWithLayout = () => { ) } -DatabaseWrappers.getLayout = (page) => {page} +DatabaseWrappers.getLayout = (page) => ( + {page} +) export default DatabaseWrappers diff --git a/apps/studio/pages/project/[ref]/database/wrappers/new.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx similarity index 100% rename from apps/studio/pages/project/[ref]/database/wrappers/new.tsx rename to apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx From d47048dcd6fe81eede090147f01adfaef2664583 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 22 Oct 2024 20:49:20 +0800 Subject: [PATCH 084/255] Update auth hooks to allow selection of fns that return void (#30022) * Update auth hooks to allow selection of fns that return void * Update for only send email hook --- .../interfaces/Auth/Hooks/CreateHookSheet.tsx | 34 ++++++++++++------- .../studio/components/ui/FunctionSelector.tsx | 6 ++-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index ca32ae51599..cfc81587e92 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -123,6 +123,11 @@ export const CreateHookSheet = ({ [title] ) + const supportedReturnTypes = + definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ['json', 'jsonb', 'void'] + : ['json', 'jsonb'] + const hook: Hook = useMemo(() => { return { ...definition, @@ -288,20 +293,21 @@ export const CreateHookSheet = ({
    - +
    ( ( - + -
    +
    ( ( { - if (func.return_type === 'json' || func.return_type === 'jsonb') { + if (supportedReturnTypes.includes(func.return_type)) { const { value } = convertArgumentTypes(func.argument_types) if (value.length !== 1) return false return value[0].type === 'json' || value[0].type === 'jsonb' @@ -403,7 +409,11 @@ export const CreateHookSheet = ({ No function with a single JSON/B argument
    - and JSON/B return type found in this schema. + and JSON/B + {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ' or void' + : ''}{' '} + return type found in this schema.
    } /> @@ -413,8 +423,8 @@ export const CreateHookSheet = ({ />
    -

    - The following statements will be executed on the function: +

    + The following statements will be executed on the selected function:

    ) : (
    -

    Choose a function...

    +

    Select a function

    )} @@ -124,7 +124,7 @@ const FunctionSelector = ({ No functions found 7 ? 'h-[210px]' : ''}> - {!(functions || []).length && ( + {!functions.length && ( )} - {functions?.map((func) => ( + {functions.map((func) => ( Date: Tue, 22 Oct 2024 11:48:43 -0230 Subject: [PATCH 085/255] Fix wrappers link (#30032) * Fix wrappers link * Update links --- .../interfaces/Database/Wrappers/CreateWrapper.tsx | 4 ++-- .../components/interfaces/Database/Wrappers/EditWrapper.tsx | 6 +++--- .../interfaces/Database/Wrappers/WrappersDropdown.tsx | 2 +- .../interfaces/Database/Wrappers/WrappersEmptyState.tsx | 2 +- .../components/layouts/DatabaseLayout/Database.Commands.tsx | 2 +- .../ProjectIntegrations.Commands.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx index 5e7e016d34a..685e05b56a2 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx @@ -52,7 +52,7 @@ const CreateWrapper = () => { const hasNewSchema = newTables.some((table) => table.is_new_schema) if (hasNewSchema) invalidateSchemasQuery(queryClient, ref) - router.push(`/project/${ref}/database/wrappers`) + router.push(`/project/${ref}/integrations/wrappers`) }, }) @@ -89,7 +89,7 @@ const CreateWrapper = () => {

    ) diff --git a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx index 895d5aa505c..71aed86921c 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -62,7 +62,7 @@ const EditWrapper = () => { const hasNewSchema = wrapperTables.some((table) => table.is_new_schema) if (hasNewSchema) invalidateSchemasQuery(queryClient, ref) - router.push(`/project/${ref}/database/wrappers`) + router.push(`/project/${ref}/integrations/wrappers`) }, }) @@ -117,7 +117,7 @@ const EditWrapper = () => {

    ) @@ -169,7 +169,7 @@ const EditWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - +

    Back

    diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx index 9bb472c3298..8d8a262ae00 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx @@ -86,7 +86,7 @@ const WrapperDropdown = ({ buttonText = 'Add wrapper', align = 'end' }: WrapperD onSelect={() => { setOpen(false) router.push( - `/project/${ref}/database/wrappers/new?type=${wrapper.name.toLowerCase()}` + `/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}` ) }} className="gap-2 cursor-pointer" diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx index 39bc4db5957..3196e7c2cb9 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx @@ -19,7 +19,7 @@ const WrappersEmptyState = () => {
    {WRAPPERS.map((wrapper) => ( diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx index 891f526f234..0fdc8a11075 100644 --- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx @@ -91,7 +91,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-wrappers', name: 'Wrappers', value: 'Database: Wrappers', - route: `/project/${ref}/database/wrappers`, + route: `/project/${ref}/integrations/wrappers`, defaultHidden: true, }, { diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx index 891f526f234..0fdc8a11075 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx @@ -91,7 +91,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-wrappers', name: 'Wrappers', value: 'Database: Wrappers', - route: `/project/${ref}/database/wrappers`, + route: `/project/${ref}/integrations/wrappers`, defaultHidden: true, }, { From 38d994da678976c0dd095c72c86fa863aa079137 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Tue, 22 Oct 2024 19:16:44 +0300 Subject: [PATCH 086/255] Chore: Clarify Disk Terms in the doc (#29955) * Chore: Clarify Disk Metrics in the doc * Add note about Disk size * consolidate terms to disk and database size * fix vale comments * Update database-size.mdx * Update database-size.mdx --------- Co-authored-by: Inian --- .../content/guides/platform/database-size.mdx | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index a455371e9ad..3415a649820 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -1,19 +1,18 @@ --- -id: 'database-size' -title: 'Database size' +id: 'disk-metrics' +title: 'Understanding Database and Disk Size' description: 'Understanding how database size applies to your subscription.' --- -Database size refers to the _monthly average storage usage_, as reported by Postgres. This metric is reported in your organization's [billing usage](https://supabase.com/dashboard/org/_/usage) and is updated daily. As you read this document we will refer to "database size" and "disk size": +Disk metrics refer to the storage usage reported by Postgres. These metrics are updated daily. As you read through this document, we will refer to "database size" and "disk size": -- "Database size" is the total size of used storage from your database. -- "Disk Storage size" describes the size of the underlying available storage. +- _Database size_: Displays the actual size of the data within your Postgres database. This can be found on the [Database Reports page](https://supabase.com/dashboard/project/_/reports/database). -## Database space management +- _Disk size_: Shows the overall disk space usage, which includes both the database size and additional files required for Postgres to function like the Write Ahead Log (WAL) and other system log files. You can view this on the [Database Settings page](https://supabase.com/dashboard/project/_/settings/database). -### Database size +## Database size -This SQL query will show the current size of your Postgres database: +This SQL query will show the size of all databases in your Postgres cluster: ```sql select @@ -23,7 +22,7 @@ from pg_database; This value is reported in the [database report page](https://supabase.com/dashboard/project/_/reports/database). -Database Space is consumed primarily by your data, indexes, and materialized views. You can reduce your disk size by removing any of these and running a Vacuum operation. +Database size is consumed primarily by your data, indexes, and materialized views. You can reduce your database size by removing any of these and running a Vacuum operation. @@ -69,39 +68,37 @@ Running a manual vacuum after deleting large amounts of data from your DB could New Supabase projects have a database size of ~40-60mb. This space includes pre-installed extensions, schemas, and default Postgres data. Additional database size is used when installing extensions, even if those extensions are inactive. -## Disk management +## Disk size -Supabase uses network-attached storage to balance performance with scalability. The behavior of your disk depends on your billing plan. +Supabase uses network-attached storage to balance performance with scalability. The disk scaling behavior depends on your billing plan. ### Paid plan behavior -Projects on the Pro Plan and above have auto-scaling Disk Storage. +Projects on the Pro Plan and higher have auto-scaling disks. -Disk Storage expands automatically when the database reaches 90% of the disk size. The disk is expanded to be 50% larger (e.g., 8GB -> 12GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. +Disk size expands automatically when the database reaches 90% of the allocated disk size. The disk is expanded to be 50% larger (for example, 8 GB -> 12 GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. -The automatic resize operation will add an additional 50% capped to a maximum of 200GB. If 50% of your current usage is more than 200GB then only 200GB will be added to your disk (e.g. a size of 1500GB will resize to 1700GB). +The automatic resize operation will add an additional 50% capped to a maximum of 200 GB. If 50% of your current usage is more than 200 GB then only 200 GB will be added to your disk (for example a size of 1500 GB will resize to 1700 GB). -The Disk Storage Size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum default you can expand the Disk Storage to is 200GB. If you wish to manually expand it to any more than 200GB, please [contact us](https://supabase.com/dashboard/support/new) to discuss expanding the 200GB limit. +Disk size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum disk size for the Pro/Team Plan is 60 TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan. -You may want to import a lot of data into your database which requires multiple disk expansions; for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the Disk Storage Size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). +You may want to import a lot of data into your database which requires multiple disk expansions. for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the disk size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). -Due to provider restrictions, disk expansions can occur only once every six hours. During the six-hour window, the disk cannot be resized again. +Due to restrictions on the underlying cloud provider, disk expansions can occur only once every six hours. During the six hour cool down window, the disk cannot be resized again. -The maximum Disk Storage Size for the Pro/Team Plan is 60TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan. - ### Free Plan behavior -Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500MB limit. Once in read-only mode, you have several options: +Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500 MB limit. Once in read-only mode, you have these options: -- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale and expand beyond the 8GB database size limit. +- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8 GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale beyond the 8 GB disk size limit. - [Disable read-only mode](#disabling-read-only-mode) and reduce your database size. ### Read-only mode @@ -134,6 +131,4 @@ set default_transaction_read_only = 'off'; ### Reducing disk size -Disks don't automatically downsize during normal operation. Once you have [reduced your database space usage](/docs/guides/platform/database-size#database-space-management), they _will_ automatically "right-size" when a Supabase (or Postgres) update is applied. Their final size after the update is 1.2x the size of the database (min 8 GB). For example, if your database is 100GB in size, and you have a 200GB disk, the size after a Supabase update will be 120GB. - -Learn more about [upgrading your project](/docs/guides/platform/migrating-and-upgrading-projects#upgrade-your-project). +Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB. From b2cd52aac858821cec884e7449ac93ba148aec55 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Tue, 22 Oct 2024 13:48:55 -0230 Subject: [PATCH 087/255] Chore/disk size helper (#29952) * Add disk size note * Capitalize * Include db size --- .../DiskManagement/DiskSpaceBar.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx index 1f3ff172e64..4c588d05af4 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx @@ -9,6 +9,10 @@ import { TooltipTrigger_Shadcn_, } from 'ui' import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants' +import { Info } from 'lucide-react' +import { formatBytes } from 'lib/helpers' +import { useDatabaseSizeQuery } from 'data/database/database-size-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' interface DiskSpaceBarProps { showNewBar: boolean @@ -29,6 +33,13 @@ export default function DiskSpaceBar({ const newUsedPercentage = (usedSize / newTotalSize) * 100 const newResizePercentage = AUTOSCALING_THRESHOLD * 100 + const { project } = useProjectContext() + const { data } = useDatabaseSizeQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const databaseSizeBytes = data?.result[0].db_size ?? 0 return (
    @@ -154,8 +165,8 @@ export default function DiskSpaceBar({ > -
    - Autoscaling +
    + Autoscaling
    @@ -186,6 +197,12 @@ export default function DiskSpaceBar({ Available space
    +

    + Note: Disk Size refers to the total space your + project occupies on disk, including the database itself (currently{' '} + {formatBytes(databaseSizeBytes, 2, 'GB')}), additional files like the + write-ahead log (WAL), and other internal resources. +

    ) } From 09757fcac45c477df965b1ffae1411ab9573730c Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Wed, 23 Oct 2024 02:33:26 +0800 Subject: [PATCH 088/255] fix: allow sms rate limit update even if autoconfirm is enabled (#30039) --- .../studio/components/interfaces/Auth/RateLimits/RateLimits.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index 5c8c22d1a85..bbb3210480f 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -53,7 +53,7 @@ const RateLimits = () => { }) const canUpdateEmailLimit = authConfig?.EXTERNAL_EMAIL_ENABLED && isSmtpEnabled(authConfig) - const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED && !authConfig?.SMS_AUTOCONFIRM + const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED const canUpdateAnonymousUsersRateLimit = authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED const FormSchema = z.object({ From 4d185109ab5f6bce15b80b5e63249eb835896130 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 23 Oct 2024 11:17:18 +0800 Subject: [PATCH 089/255] fix: allow developer role to update buckets (#30024) * fix: allow developer role to update buckets * Fix create bucket perms --------- Co-authored-by: Joshen Lim --- apps/studio/components/layouts/StorageLayout/BucketRow.tsx | 2 +- apps/studio/components/layouts/StorageLayout/StorageMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx index b44f233ebb7..1474325de01 100644 --- a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx +++ b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx @@ -34,7 +34,7 @@ const BucketRow = ({ onSelectDeleteBucket = noop, onSelectEditBucket = noop, }: BucketRowProps) => { - const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') return (
    { const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState() const [selectedBucketToDelete, setSelectedBucketToDelete] = useState() - const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const [sort, setSort] = useLocalStorage<'alphabetical' | 'created-at'>( 'storage-explorer-sort', From c9458d93cf38531e7dcd8baab3800a8111310c77 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 15:42:06 +0800 Subject: [PATCH 090/255] Chore/fix broken links wrappers (#30046) * Fix broken links wrappers * Refactor --- .../Database/Wrappers/CreateWrapper.tsx | 2 +- .../Database/Wrappers/WrapperRow.tsx | 107 +++++++----------- apps/studio/components/ui/ButtonTooltip.tsx | 2 +- .../[ref]/integrations/wrappers/index.tsx | 3 +- 4 files changed, 41 insertions(+), 73 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx index 685e05b56a2..98056249503 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/CreateWrapper.tsx @@ -142,7 +142,7 @@ const CreateWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - +

    Back

    diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 21c9fce17f9..ffdb8ae54d9 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -1,14 +1,14 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' +import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' import Image from 'next/legacy/image' import Link from 'next/link' -import { Button, Collapsible } from 'ui' -import { useParams } from 'common/hooks' +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' +import { Collapsible } from 'ui' import type { WrapperMeta } from './Wrappers.types' interface WrapperRowProps { @@ -131,72 +131,41 @@ const WrapperRow = ({
    -
    - {canManageWrappers ? ( - -
    ) diff --git a/apps/studio/components/ui/ButtonTooltip.tsx b/apps/studio/components/ui/ButtonTooltip.tsx index f1ff45d08e8..23372130b80 100644 --- a/apps/studio/components/ui/ButtonTooltip.tsx +++ b/apps/studio/components/ui/ButtonTooltip.tsx @@ -18,7 +18,7 @@ export const ButtonTooltip = forwardRef< {props.children} - {props.disabled && props.tooltip.content.text !== undefined && ( + {props.tooltip.content.text !== undefined && ( {props.tooltip.content.text} diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx index 6c0d2ea7566..6360dd51395 100644 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx @@ -3,7 +3,7 @@ import { ExternalLink } from 'lucide-react' import { Wrappers } from 'components/interfaces/Database' import WrappersDropdown from 'components/interfaces/Database/Wrappers/WrappersDropdown' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, @@ -19,7 +19,6 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' const DatabaseWrappers: NextPageWithLayout = () => { const { project } = useProjectContext() From cb5ae761f343c9b309c25f9b806b39c3cdff3e5e Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 16:36:09 +0800 Subject: [PATCH 091/255] Chore/add perms check in projects api query (#30025) * Add perms check in projects API query * Start deprecating use of projects-api-query, move to projects-settings-v2-query * PRETTY * Address feedback * Address feedback --- .../interfaces/App/CommandMenu/ApiKeys.tsx | 15 +++-- .../interfaces/App/CommandMenu/ApiUrl.tsx | 10 +-- .../interfaces/Auth/Users/BanUserModal.tsx | 29 +++++---- .../interfaces/Auth/Users/CreateUserModal.tsx | 21 +++++-- .../interfaces/Docs/Authentication.tsx | 28 +++++---- .../interfaces/Docs/GeneralContent.tsx | 27 +++----- .../interfaces/Docs/Introduction.tsx | 9 ++- .../interfaces/Docs/LangSelector.tsx | 26 ++++---- .../interfaces/Docs/Pages/UserManagement.tsx | 39 ++++++------ .../components/interfaces/Docs/RpcContent.tsx | 39 ++++++++---- .../interfaces/Home/Connect/Connect.tsx | 37 +++-------- .../Home/NewProjectPanel/APIKeys.tsx | 19 +++--- .../ProjectAPIDocs/Content/Introduction.tsx | 29 ++++----- .../layouts/StorageLayout/StorageLayout.tsx | 25 ++++---- .../Storage/StorageSettings/S3Connection.tsx | 7 ++- .../StorageSettings/StorageSettings.utils.ts | 8 +-- apps/studio/data/config/keys.ts | 1 + apps/studio/data/config/project-api-query.ts | 11 ++-- .../data/config/project-settings-query.ts | 1 + .../data/config/project-settings-v2-query.ts | 62 +++++++++++++++++++ apps/studio/pages/project/[ref]/api/index.tsx | 28 +++------ .../project/[ref]/integrations/graphiql.tsx | 11 ++-- 22 files changed, 272 insertions(+), 210 deletions(-) create mode 100644 apps/studio/data/config/project-settings-v2-query.ts diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index a3c3f2909a8..c86051bba3f 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,7 +1,7 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' @@ -23,14 +23,13 @@ export function useApiKeysCommands() { const setPage = useSetPage() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const ref = project?.ref || '_' + + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const ref = project?.ref || '_' - - const anonKey = settings?.autoApiService?.defaultApiKey ?? undefined - const serviceKey = settings?.autoApiService?.serviceApiKey ?? undefined + const { anonKey, serviceKey } = getAPIKeys(settings) const commands = useMemo( () => @@ -40,7 +39,7 @@ export function useApiKeysCommands() { id: 'anon-key', name: `Copy anonymous API key`, action: () => { - copyToClipboard(anonKey ?? '') + copyToClipboard(anonKey.api_key ?? '') setIsOpen(false) }, badge: () => ( @@ -56,7 +55,7 @@ export function useApiKeysCommands() { id: 'service-key', name: `Copy service API key`, action: () => { - copyToClipboard(serviceKey ?? '') + copyToClipboard(serviceKey.api_key ?? '') setIsOpen(false) }, badge: () => ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx index 28b36682981..1205a8f3333 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx @@ -1,10 +1,10 @@ import { Link } from 'lucide-react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { orderCommandSectionsByPriority } from './ordering' @@ -12,13 +12,13 @@ export function useApiUrlCommand() { const setIsOpen = useSetCommandMenuOpen() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const apiUrl = settings?.autoApiService.endpoint - ? `${settings.autoApiService.protocol ?? 'https'}://${settings.autoApiService.endpoint}` + const apiUrl = settings?.app_config?.endpoint + ? `https://${settings.app_config.endpoint}` : undefined useRegisterCommands( diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index 1ae878d8855..d246cd04cd5 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -1,10 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import dayjs from 'dayjs' -import { useForm } from 'react-hook-form' -import * as z from 'zod' -import { toast } from 'sonner' import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' +import { useParams } from 'common' +import { useUserUpdateMutation } from 'data/auth/user-update-mutation' +import { User } from 'data/auth/users-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -20,10 +24,6 @@ import { Separator, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { useUserUpdateMutation } from 'data/auth/user-update-mutation' -import { User } from 'data/auth/users-query' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useParams } from 'common' interface BanUserModalProps { visible: boolean @@ -34,7 +34,7 @@ interface BanUserModalProps { export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const { ref: projectRef } = useParams() - const { data: apiData } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({ onSuccess: (_, vars) => { const bannedUntil = dayjs() @@ -62,17 +62,24 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)') const onSubmit = (data: FormType) => { - if (!apiData) { + if (!settings) { return toast.error(`Failed to ban user: Error loading project config`) } else if (user.id === undefined) { return toast.error(`Failed to ban user: User ID not found`) } const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24 - const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + const endpoint = settings.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (x) => x.tags === 'service_role' + )?.api_key + + if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`) + if (!serviceApiKey) return toast.error(`Failed to ban user: Unable to retrieve API key`) + updateUser({ projectRef, - protocol, + protocol: 'https', endpoint, serviceApiKey, userId: user.id, diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 90d0e02b97e..3f5cdb41567 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -1,13 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { useParams } from 'common' import { useUserCreateMutation } from 'data/auth/user-create-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, @@ -40,7 +40,11 @@ const CreateUserFormSchema = z.object({ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const { ref: projectRef } = useParams() - const { data, isLoading, isSuccess } = useProjectApiQuery({ projectRef }, { enabled: visible }) + const { + data: settings, + isLoading, + isSuccess, + } = useProjectSettingsV2Query({ projectRef }, { enabled: visible }) const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') @@ -56,8 +60,15 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { if (!isSuccess) { return toast.error(`Failed to create user: Error loading project config`) } - const { protocol, endpoint, serviceApiKey } = data.autoApiService - createUser({ projectRef, endpoint, protocol, serviceApiKey, user: values }) + const endpoint = settings?.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (key) => key.tags === 'service_role' + )?.api_key + + if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`) + if (!serviceApiKey) return toast.error(`Failed to create user: Unable to retrieve API key`) + + createUser({ projectRef, endpoint, protocol: 'https', serviceApiKey, user: values }) } const form = useForm>({ diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx index d3a44692039..37d6840a7b0 100644 --- a/apps/studio/components/interfaces/Docs/Authentication.tsx +++ b/apps/studio/components/interfaces/Docs/Authentication.tsx @@ -1,24 +1,30 @@ import Link from 'next/link' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import Snippets from './Snippets' interface AuthenticationProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -const Authentication = ({ autoApiService, selectedLang, showApiKey }: AuthenticationProps) => { +const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + + const { anonKey, serviceKey } = getAPIKeys(settings) + const endpoint = settings?.app_config?.endpoint ?? '' + // [Joshen] ShowApiKey should really be a boolean, its confusing const defaultApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.defaultApiKey ?? 'SUPABASE_CLIENT_API_KEY' + ? anonKey?.api_key ?? 'SUPABASE_CLIENT_API_KEY' : 'SUPABASE_CLIENT_API_KEY' const serviceApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.serviceApiKey ?? 'SUPABASE_SERVICE_KEY' + ? serviceKey?.api_key ?? 'SUPABASE_SERVICE_KEY' : 'SUPABASE_SERVICE_KEY' return ( @@ -53,8 +59,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica

    We have provided you a Client Key to get started. You will soon be able to add as many keys as you like. You can find the anon key in the{' '} - API Settings{' '} - page. + API Settings page.

@@ -64,7 +69,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica /> @@ -85,8 +90,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica

We have provided you with a Service Key to get started. Soon you will be able to add as many keys as you like. You can find the service_role in the{' '} - API Settings{' '} - page. + API Settings page.

@@ -96,9 +100,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica />
diff --git a/apps/studio/components/interfaces/Docs/GeneralContent.tsx b/apps/studio/components/interfaces/Docs/GeneralContent.tsx index cb336120919..4be9860fdcb 100644 --- a/apps/studio/components/interfaces/Docs/GeneralContent.tsx +++ b/apps/studio/components/interfaces/Docs/GeneralContent.tsx @@ -4,26 +4,19 @@ import TablesIntroduction from 'components/interfaces/Docs/Pages/Tables/Introduc import UserManagement from 'components/interfaces/Docs/Pages/UserManagement' import RpcIntroduction from 'components/interfaces/Docs/Pages/Rpc/Introduction' -const GeneralContent = ({ autoApiService, selectedLang, page, showApiKey }: any) => { +interface GeneralContentProps { + page?: string + selectedLang: 'bash' | 'js' + showApiKey: string +} + +const GeneralContent = ({ selectedLang, page, showApiKey }: GeneralContentProps) => { let selected = page?.toLowerCase() - if (selected == 'intro' || selected == null) - return + if (selected == 'intro' || selected == null) return if (selected == 'auth') - return ( - - ) + return if (selected == 'users') - return ( - - ) + return if (selected == 'tables-intro') return if (selected == 'rpc-intro') return else diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index 6c4c68c167e..b274bc6f388 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -5,17 +5,20 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co import CodeSnippet from './CodeSnippet' import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' interface Props { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' } -export default function Introduction({ autoApiService, selectedLang }: Props) { +export default function Introduction({ selectedLang }: Props) { const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + const isPublicSchemaEnabled = config?.db_schema .split(',') .map((name) => name.trim()) @@ -24,7 +27,7 @@ export default function Introduction({ autoApiService, selectedLang }: Props) { return (
- + {!isPublicSchemaEnabled && }
diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index 53376c063c9..b523ded9d53 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,8 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - -import type { showApiKey } from 'components/interfaces/Docs/Docs.types' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Key } from 'lucide-react' + +import { useParams } from 'common' +import type { showApiKey } from 'components/interfaces/Docs/Docs.types' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -15,26 +17,26 @@ const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } interface LangSelectorProps { selectedLang: string - setSelectedLang: (selectedLang: string) => void showApiKey: showApiKey + setSelectedLang: (selectedLang: string) => void setShowApiKey: (showApiKey: showApiKey) => void - apiKey: string | undefined - autoApiService: any } const LangSelector = ({ selectedLang, - setSelectedLang, showApiKey, + setSelectedLang, setShowApiKey, - apiKey, - autoApiService, }: LangSelectorProps) => { + const { ref: projectRef } = useParams() const canReadServiceKey = useCheckPermissions( PermissionAction.READ, 'service_api_keys.service_role_key' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { anonKey: anonApiKey, serviceKey: serviceApiKey } = getAPIKeys(settings) + return (
@@ -75,12 +77,12 @@ const LangSelector = ({ setShowApiKey(DEFAULT_KEY)}> hide - {apiKey && ( + {anonApiKey && ( setShowApiKey({ - key: apiKey, + key: anonApiKey.api_key ?? '-', name: 'anon (public)', }) } @@ -93,7 +95,7 @@ const LangSelector = ({ key="service" onClick={() => setShowApiKey({ - key: autoApiService.serviceApiKey, + key: serviceApiKey?.api_key ?? '-', name: 'service_role (secret)', }) } diff --git a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx index 9cbf5f4ac2d..c0296107f1b 100644 --- a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx +++ b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { makeRandomString } from 'lib/helpers' import CodeSnippet from '../CodeSnippet' import Snippets from '../Snippets' @@ -9,20 +10,18 @@ import Snippets from '../Snippets' const randomPassword = makeRandomString(20) interface UserManagementProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -export default function UserManagement({ - autoApiService, - selectedLang, - showApiKey, -}: UserManagementProps) { +export default function UserManagement({ selectedLang, showApiKey }: UserManagementProps) { const router = useRouter() - + const { ref: projectRef } = useParams() const keyToShow = showApiKey ? showApiKey : 'SUPABASE_KEY' + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + return ( <>

User Management

@@ -53,7 +52,7 @@ export default function UserManagement({
@@ -70,7 +69,7 @@ export default function UserManagement({
@@ -87,7 +86,7 @@ export default function UserManagement({
@@ -111,7 +110,7 @@ export default function UserManagement({
@@ -131,7 +130,7 @@ export default function UserManagement({
@@ -151,7 +150,7 @@ export default function UserManagement({
@@ -218,7 +217,7 @@ export default function UserManagement({
@@ -231,7 +230,7 @@ export default function UserManagement({
@@ -247,7 +246,7 @@ export default function UserManagement({
@@ -263,7 +262,7 @@ export default function UserManagement({
@@ -279,7 +278,7 @@ export default function UserManagement({
@@ -300,7 +299,7 @@ export default function UserManagement({
diff --git a/apps/studio/components/interfaces/Docs/RpcContent.tsx b/apps/studio/components/interfaces/Docs/RpcContent.tsx index b7ba1558eb2..c749f6658cd 100644 --- a/apps/studio/components/interfaces/Docs/RpcContent.tsx +++ b/apps/studio/components/interfaces/Docs/RpcContent.tsx @@ -1,38 +1,53 @@ +import { useParams } from 'common' import CodeSnippet from 'components/interfaces/Docs/CodeSnippet' import Description from 'components/interfaces/Docs/Description' import Param from 'components/interfaces/Docs/Param' import Snippets from 'components/interfaces/Docs/Snippets' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { ProjectJsonSchemaPaths } from 'data/docs/project-json-schema-query' /** * TODO: need to support rpc with the same name and different params type */ + +interface RpcContentProps { + rpcId: string + rpcs: { [key: string]: any } + paths?: ProjectJsonSchemaPaths + selectedLang: 'bash' | 'js' + showApiKey: string + refreshDocs: () => void +} + const RpcContent = ({ - autoApiService, rpcId, rpcs, paths, selectedLang, - refreshDocs, showApiKey, -}: any) => { + refreshDocs, +}: RpcContentProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint ?? '' + const meta = rpcs[rpcId] const pathKey = `/rpc/${rpcId}` const path = paths && pathKey in paths ? paths[pathKey] : undefined const keyToShow = !!showApiKey ? showApiKey : 'SUPABASE_KEY' - if (!path) return null - - const { - post: { parameters, summary }, - } = path + const { post } = path! + const { parameters, summary } = post || {} const rpcParamsObject = - parameters && parameters[0] && parameters[0].schema && parameters[0].schema.properties - ? parameters[0].schema.properties + parameters && parameters[0] && parameters[0].schema && (parameters[0].schema as any).properties + ? (parameters[0].schema as any).properties : {} const rpcParams = Object.entries(rpcParamsObject) .map(([k, v]: any) => ({ name: k, ...v })) .filter((x) => !!x.name) + if (!path) return null + return ( <>

@@ -42,7 +57,7 @@ const RpcContent = ({
- +
diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.tsx b/apps/studio/components/interfaces/Home/Connect/Connect.tsx index d00330491b2..2478ade775a 100644 --- a/apps/studio/components/interfaces/Home/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Home/Connect/Connect.tsx @@ -5,10 +5,9 @@ import { useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import Panel from 'components/ui/Panel' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import { Button, DIALOG_PADDING_X, @@ -30,7 +29,6 @@ import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './C import { getContentFilePath } from './Connect.utils' import ConnectDropdown from './ConnectDropdown' import ConnectTabContent from './ConnectTabContent' -import Panel from 'components/ui/Panel' const Connect = () => { const { ref: projectRef } = useParams() @@ -46,6 +44,9 @@ const Connect = () => { ?.children.find((child) => child.key === selectedChild)?.children[0]?.key || '' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + const handleParentChange = (value: string) => { setSelectedParent(value) @@ -127,32 +128,12 @@ const Connect = () => { return [] } - const { data: projectSettings } = useProjectSettingsQuery({ projectRef }) - - const { data: apiSettings } = useProjectApiQuery({ - projectRef, - }) - - // Get the API service - const apiService = (projectSettings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - - const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - - // Get the API service - const apiHost = canReadAPIKeys - ? `${apiSettings?.autoApiService?.protocol ?? 'https'}://${ - apiSettings?.autoApiService?.endpoint ?? '-' - }` - : '' + const endpoint = settings?.app_config?.endpoint + const apiHost = canReadAPIKeys ? `https://${endpoint ?? '-'}` : '' const apiUrl = canReadAPIKeys ? apiHost : null - const anonKey = canReadAPIKeys - ? apiService?.service_api_keys.find((key) => key.tags === 'anon')?.api_key ?? null - : null - - const projectKeys = { apiUrl, anonKey } + const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } + const projectKeys = { apiUrl, anonKey: anonKey?.api_key ?? null } const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 93af608f4e7..afccbce6923 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -3,14 +3,14 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import Link from 'next/link' import { useState } from 'react' -import { useParams } from 'common/hooks' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' +import { useParams } from 'common/hooks' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Input } from 'ui' import { AlertCircle, Loader } from 'lucide-react' +import { Input } from 'ui' const generateInitSnippet = (endpoint: string) => ({ js: ` @@ -42,7 +42,7 @@ const APIKeys = () => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectApiQuery({ + } = useProjectSettingsV2Query({ projectRef, }) @@ -55,17 +55,16 @@ const APIKeys = () => { const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - // Get the API service - const apiService = settings?.autoApiService - const apiKeys = apiService?.service_api_keys ?? [] + const apiKeys = settings?.service_api_keys ?? [] // API keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` - const anonKey = apiKeys.find((key) => key.tags === 'anon') + const endpoint = settings?.app_config?.endpoint + const apiUrl = `https://${endpoint ?? '-'}` + const { anonKey } = getAPIKeys(settings) const clientInitSnippet: any = generateInitSnippet(apiUrl) const selectedLanguageSnippet = clientInitSnippet[selectedLanguage.key] ?? 'No snippet available' @@ -146,7 +145,7 @@ const APIKeys = () => { ? 'JWT secret update failed, new API key may have issues' : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating ? 'Updating JWT secret...' - : apiService?.defaultApiKey + : anonKey?.api_key } onChange={() => {}} descriptionText={ diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx index 4c50ce99911..10edf7179ef 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx @@ -1,17 +1,17 @@ import { useParams } from 'common' import { Button, Input } from 'ui' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { copyToClipboard } from 'lib/helpers' +import { Copy } from 'lucide-react' import { useEffect, useState } from 'react' import ContentSnippet from '../ContentSnippet' import { DOCS_CONTENT } from '../ProjectAPIDocs.constants' import type { ContentProps } from './Content.types' -import { Copy } from 'lucide-react' const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => { const { ref } = useParams() - const { data } = useProjectApiQuery({ projectRef: ref }) + const { data } = useProjectSettingsV2Query({ projectRef: ref }) const [copied, setCopied] = useState<'anon' | 'service'>() @@ -19,9 +19,10 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => if (copied !== undefined) setTimeout(() => setCopied(undefined), 2000) }, [copied]) - const serviceKey = showKeys - ? data?.autoApiService.serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' - : 'SUPABASE_CLIENT_SERVICE_KEY' + const anonApiKey = (data?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key + const serviceApiKey = + (data?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? + 'SUPABASE_CLIENT_SERVICE_KEY' return ( <> @@ -52,9 +53,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => icon={} onClick={() => { setCopied('anon') - copyToClipboard( - data?.autoApiService.defaultApiKey ?? 'SUPABASE_CLIENT_ANON_KEY' - ) + copyToClipboard(anonApiKey ?? 'SUPABASE_CLIENT_ANON_KEY') }} > {copied === 'anon' ? 'Copied' : 'Copy'} @@ -68,7 +67,11 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => disabled readOnly size="small" - value={showKeys ? serviceKey : 'Reveal API keys via dropdown in the header'} + value={ + showKeys + ? serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' + : 'Reveal API keys via dropdown in the header' + } className="w-full" descriptionText={

@@ -83,9 +86,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => icon={} onClick={() => { setCopied('service') - copyToClipboard( - data?.autoApiService.serviceApiKey ?? 'SUPABASE_CLIENT_SERVICE_KEY' - ) + copyToClipboard(serviceApiKey) }} > {copied === 'service' ? 'Copied' : 'Copy'} @@ -105,7 +106,7 @@ const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => diff --git a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx index aa97b8fa2b8..0df1670f64f 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { ReactNode, useEffect } from 'react' import { toast } from 'sonner' -import { AutoApiService, useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { PROJECT_STATUS } from 'lib/constants' @@ -20,25 +20,24 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { const project = useSelectedProject() const storageExplorerStore = useStorageStore() - const { data: settings, isLoading } = useProjectApiQuery({ projectRef }) - const apiService = settings?.autoApiService + const { data: settings, isLoading } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (key) => key.tags === 'service_role' + )?.api_key const isPaused = project?.status === PROJECT_STATUS.INACTIVE useEffect(() => { - if (!isLoading && apiService) initializeStorageStore(apiService) - }, [isLoading, projectRef]) + if (!isLoading && endpoint && serviceApiKey) initializeStorageStore(endpoint, serviceApiKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, projectRef, endpoint, serviceApiKey]) - const initializeStorageStore = async (apiService: AutoApiService) => { + const initializeStorageStore = async (endpoint: string, serviceApiKey: string) => { if (isPaused) return - if (apiService.endpoint) { - storageExplorerStore.initStore( - projectRef!, - apiService.endpoint, - apiService.serviceApiKey, - apiService.protocol - ) + if (endpoint) { + storageExplorerStore.initStore(projectRef!, endpoint, serviceApiKey, 'https') } else { toast.error( 'Failed to fetch project configuration. Try refreshing your browser, or reach out to us at support@supabase.io' diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx index 25a5c517e49..1e58b14c992 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx @@ -12,7 +12,7 @@ import Table from 'components/to-be-cleaned/Table' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' import Panel from 'components/ui/Panel' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, WarningIcon } from 'ui' @@ -35,14 +35,15 @@ export const S3Connection = () => { const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const { data: projectAPI } = useProjectApiQuery({ projectRef: projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery( { projectRef }, { enabled: canReadS3Credentials } ) + const endpoint = settings?.app_config?.endpoint const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0 - const s3connectionUrl = getConnectionURL(projectRef ?? '', projectAPI) + const s3connectionUrl = getConnectionURL(projectRef ?? '', endpoint) return ( <> diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts index 44cf98726af..3dab5cc0cd4 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts @@ -26,12 +26,8 @@ export const convertToBytes = (size: number, unit: StorageSizeUnits = StorageSiz return size * Math.pow(k, i) } -export function getConnectionURL(projectRef: string, projectAPI?: ProjectApiResponse) { - if (projectAPI === undefined) return '' - - const projUrl = projectAPI - ? `${projectAPI.autoApiService.protocol}://${projectAPI.autoApiService.endpoint}` - : `https://${projectRef}.supabase.co` +export function getConnectionURL(projectRef: string, endpoint?: string) { + const projUrl = endpoint ? `https://${endpoint}` : `https://${projectRef}.supabase.co` const url = new URL(projUrl) url.pathname = '/storage/v1/s3' diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index 6f4b61da217..a2aab7748f1 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -2,6 +2,7 @@ export const configKeys = { pgBouncerStatus: (projectRef: string | undefined) => ['projects', projectRef, 'pgbouncer'] as const, settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const, + settingsV2: (projectRef: string | undefined) => ['projects', projectRef, 'settings-v2'] as const, api: (projectRef: string | undefined) => ['projects', projectRef, 'settings', 'api'] as const, postgrest: (projectRef: string | undefined) => ['projects', projectRef, 'postgrest'] as const, jwtSecretUpdatingStatus: (projectRef: string | undefined) => diff --git a/apps/studio/data/config/project-api-query.ts b/apps/studio/data/config/project-api-query.ts index 8baaee61d0d..3826300f630 100644 --- a/apps/studio/data/config/project-api-query.ts +++ b/apps/studio/data/config/project-api-query.ts @@ -6,6 +6,8 @@ import { useProjectByRef } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import type { ResponseError } from 'types' import { configKeys } from './keys' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { PermissionAction } from '@supabase/shared-types/out/constants' export type ProjectApiVariables = { projectRef?: string @@ -32,24 +34,25 @@ export async function getProjectApi({ projectRef }: ProjectApiVariables, signal? export type ProjectApiData = Awaited> export type ProjectApiError = ResponseError +/** @deprecated */ export const useProjectApiQuery = ( { projectRef }: ProjectApiVariables, { enabled = true, ...options }: UseQueryOptions = {} ) => { + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + return useQuery( configKeys.api(projectRef), ({ signal }) => getProjectApi({ projectRef }, signal), { enabled: enabled && typeof projectRef !== 'undefined', refetchInterval(data) { - if (!data) { - return false - } + if (!data) return false const { autoApiService } = data as unknown as ProjectApiData const apiKeys = autoApiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 + const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 return interval }, diff --git a/apps/studio/data/config/project-settings-query.ts b/apps/studio/data/config/project-settings-query.ts index 96a3a82385d..7d9d642c132 100644 --- a/apps/studio/data/config/project-settings-query.ts +++ b/apps/studio/data/config/project-settings-query.ts @@ -42,6 +42,7 @@ export async function getProjectSettings( export type ProjectSettingsData = Awaited> export type ProjectSettingsError = ResponseError +/** @deprecated */ export const useProjectSettingsQuery = ( { projectRef }: ProjectSettingsVariables, { diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts new file mode 100644 index 00000000000..129d756c0bd --- /dev/null +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -0,0 +1,62 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { components } from 'data/api' +import { get, handleError } from 'data/fetchers' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import type { ResponseError } from 'types' +import { configKeys } from './keys' + +export type ProjectSettingsVariables = { projectRef?: string } +export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] + +export async function getProjectSettings( + { projectRef }: ProjectSettingsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/projects/{ref}/settings', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + return data as unknown as ProjectSettings +} + +type ProjectSettingsData = Awaited> +type ProjectSettingsError = ResponseError + +export const useProjectSettingsV2Query = ( + { projectRef }: ProjectSettingsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + // [Joshen] Sync with API perms checking here - shouldReturnApiKeys + // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/platform/projects/ref/settings.controller.ts#L92 + const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') + + return useQuery( + configKeys.settingsV2(projectRef), + ({ signal }) => getProjectSettings({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data) { + const apiKeys = (data as ProjectSettings)?.service_api_keys ?? [] + const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 + return interval + }, + ...options, + } + ) +} + +export const getAPIKeys = (settings?: ProjectSettings) => { + const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') + const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') + + return { anonKey, serviceKey } +} diff --git a/apps/studio/pages/project/[ref]/api/index.tsx b/apps/studio/pages/project/[ref]/api/index.tsx index d195fd6da37..a693135da06 100644 --- a/apps/studio/pages/project/[ref]/api/index.tsx +++ b/apps/studio/pages/project/[ref]/api/index.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { GeneralContent, ResourceContent, RpcContent } from 'components/interfaces/Docs' import LangSelector from 'components/interfaces/Docs/LangSelector' import DocsLayout from 'components/layouts/DocsLayout/DocsLayout' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' import { snakeToCamel } from 'lib/helpers' @@ -26,10 +26,7 @@ const DocView = () => { const [selectedLang, setSelectedLang] = useState('js') const [showApiKey, setShowApiKey] = useState(DEFAULT_KEY) - const { data, error } = useProjectApiQuery({ projectRef }) - const apiService = data?.autoApiService - const anonKey = apiService?.defaultApiKey ?? undefined - + const { data: settings, error: settingsError } = useProjectSettingsV2Query({ projectRef }) const { data: jsonSchema, error: jsonSchemaError, @@ -43,11 +40,10 @@ const DocView = () => { const endpoint = customDomainData?.customDomain?.status === 'active' ? `https://${customDomainData.customDomain?.hostname}` - : `${data?.autoApiService.protocol ?? 'https'}://${data?.autoApiService.endpoint ?? '-'}` + : `https://${settings?.app_config?.endpoint ?? '-'}` const { paths } = jsonSchema || {} const PAGE_KEY: any = resource || rpc || page || 'index' - const autoApiService = { ...(data?.autoApiService ?? {}), endpoint } const { resources, rpcs } = Object.entries(paths || {}).reduce( (a, [name]) => { @@ -84,18 +80,18 @@ const DocView = () => { { resources: {}, rpcs: {} } ) - if (error || jsonSchemaError) { + if (settingsError || jsonSchemaError) { return (

Error connecting to API

-

{`${error || jsonSchemaError}`}

+

{`${settingsError || jsonSchemaError}`}

) } - if (isLoading || !data || !jsonSchema) { + if (isLoading || !settings || !jsonSchema) { return (

Building docs ...

@@ -109,11 +105,9 @@ const DocView = () => {
@@ -128,7 +122,6 @@ const DocView = () => { /> ) : rpc ? ( { refreshDocs={refreshDocs} /> ) : ( - + )}
diff --git a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx index 3d34d908c1b..496ba9cb95c 100644 --- a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx @@ -10,8 +10,8 @@ import GraphiQL from 'components/interfaces/GraphQL/GraphiQL' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { Loading } from 'components/ui/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' -import { useProjectApiQuery } from 'data/config/project-api-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -32,12 +32,11 @@ const GraphiQLPage: NextPageWithLayout = () => { const pgGraphqlExtension = (data ?? []).find((ext) => ext.name === 'pg_graphql') const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) - const { data: settings, isFetched } = useProjectApiQuery({ projectRef }) + const { data: settings, isFetched } = useProjectSettingsV2Query({ projectRef }) - const apiService = settings?.autoApiService - const serviceRoleKey = apiService?.service_api_keys.find((x) => x.name === 'service_role key') - ? apiService.serviceApiKey - : undefined + const serviceRoleKey = (settings?.service_api_keys ?? []).find( + (x) => x.name === 'service_role key' + )?.api_key const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const jwtSecret = config?.jwt_secret From f521109ce276901d14322a027b6a6b3c8069d943 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 16:43:38 +0800 Subject: [PATCH 092/255] Chore/database functions v2 (#29969) * Init AiAssistantPanel * Temp * Temp * iterating on assistant ux * More spiking * Tweaking... * Moar tweaking.. * Even more tweaking * Tweek * More tweaks * Final tweaking... * TWEAK * Spelling * Fix * Assistant error handling and small bug fix in context for message * Add feature flag * Fix * Address all feedback * Minor --- .../FeaturePreview/APISidePanelPreview.tsx | 4 +- .../App/FeaturePreview/CLSPreview.tsx | 4 +- .../FeaturePreview/FeaturePreviewContext.tsx | 55 +- .../FeaturePreview/FeaturePreviewModal.tsx | 46 +- .../FunctionsAssistantPreview.tsx | 54 ++ .../AIPolicyEditorPanel/RLSCodeEditor.tsx | 3 - .../PolicyTableRow/PolicyTableRowHeader.tsx | 2 +- .../Functions/FunctionsList/FunctionsList.tsx | 65 +- .../Database/Indexes/CreateIndexSidePanel.tsx | 2 +- .../RealtimeFilterPopover/TableSelector.tsx | 2 +- .../AiAssistantPanel/AiMessagePre.tsx | 2 +- .../SQLEditor/AiAssistantPanel/Message.tsx | 2 +- .../interfaces/TableGridEditor/EmptyState.tsx | 2 +- .../TableEditorLayout/TableEditorMenu.tsx | 2 +- .../ui/AIAssistantPanel/AIAssistant.tsx | 696 ++++++++++++++++++ .../ui/AIAssistantPanel/AIAssistant.types.ts | 2 + .../ui/AIAssistantPanel/AIAssistant.utils.ts | 125 ++++ .../ui/AIAssistantPanel/AIAssistantPanel.tsx | 439 +++++++++++ .../AIAssistantPanel/AiAssistant.constants.ts | 10 + .../ui/AIAssistantPanel/ContextBadge.tsx | 33 + .../AIAssistantPanel/DropdownScrollArea.tsx | 36 + .../AIAssistantPanel/EntitiesDropdownMenu.tsx | 110 +++ .../ui/AIAssistantPanel/Message.tsx | 146 ++++ .../ui/AIAssistantPanel/MessagePre.tsx | 113 +++ .../AIAssistantPanel/SchemasDropdownMenu.tsx | 61 ++ .../components/ui/CodeEditor/CodeEditor.tsx | 215 ++++-- apps/studio/components/ui/SchemaComboBox.tsx | 3 +- .../data/database/entity-definitions-query.ts | 10 +- .../entity-types-infinite-query.ts | 21 +- apps/studio/data/entity-types/keys.ts | 2 +- apps/studio/lib/constants/index.ts | 3 + apps/studio/pages/_app.tsx | 2 + apps/studio/pages/api/ai/sql/generate-v2.ts | 6 +- .../project/[ref]/logs/explorer/index.tsx | 4 +- .../img/previews/functions-assistant.png | Bin 0 -> 275225 bytes apps/studio/state/app-state.ts | 12 + packages/ai-commands/src/sql/chat.ts | 44 +- .../AssistantChat/AssistantChatForm.tsx | 7 +- packages/ui-patterns/admonition.tsx | 4 +- 39 files changed, 2182 insertions(+), 167 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts create mode 100644 apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/Message.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx create mode 100644 apps/studio/public/img/previews/functions-assistant.png diff --git a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx index 5e96abfb824..9a4bde83334 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx @@ -3,7 +3,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import Image from 'next/image' -const APISidePanelPreview = () => { +export const APISidePanelPreview = () => { const { ref } = useParams() return ( @@ -38,5 +38,3 @@ const APISidePanelPreview = () => {
) } - -export default APISidePanelPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index 56293f7b9b0..a88c1eddc96 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -5,7 +5,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui' -const CLSPreview = () => { +export const CLSPreview = () => { const { ref } = useParams() return ( @@ -53,5 +53,3 @@ const CLSPreview = () => { ) } - -export default CLSPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a77d22b887d..84aa5f6c7fe 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -3,6 +3,33 @@ import { PropsWithChildren, createContext, useContext, useEffect, useState } fro import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' +import { APISidePanelPreview } from './APISidePanelPreview' +import { CLSPreview } from './CLSPreview' +import { FunctionsAssistantPreview } from './FunctionsAssistantPreview' + +export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT, + name: 'Database Functions Assistant', + content: , + discussionsUrl: undefined, + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, + name: 'Project API documentation', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', + isNew: false, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, + name: 'Column-level privileges', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', + isNew: false, + }, +] type FeaturePreviewContextType = { flags: { [key: string]: boolean } @@ -17,22 +44,19 @@ const FeaturePreviewContext = createContext({ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [flags, setFlags] = useState({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, - }) + const [flags, setFlags] = useState(() => + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: false } + }, {}) + ) useEffect(() => { if (typeof window !== 'undefined') { - setFlags({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', - }) + setFlags( + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' } + }, {}) + ) } }, []) @@ -61,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsDatabaseFunctionsAssistantEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index de6979679e8..01cbee09063 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -3,38 +3,23 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { Button, Modal, ScrollArea, cn } from 'ui' -import APISidePanelPreview from './APISidePanelPreview' -import CLSPreview from './CLSPreview' -import { useFeaturePreviewContext } from './FeaturePreviewContext' +import { Badge, Button, Modal, ScrollArea, cn } from 'ui' +import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext' +import { useFlag } from 'hooks/ui/useFlag' const FeaturePreviewModal = () => { - // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. - const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, - name: 'Project API documentation', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', - }, - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, - name: 'Column-level privileges', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', - }, - ] - const snap = useAppStateSnapshot() const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() + const enableFunctionsAssistant = useFlag('functionsAssistantV2') const selectedFeaturePreview = snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview const [selectedFeatureKey, setSelectedFeatureKey] = useState(selectedFeaturePreview) + const isNotReleased = + selectedFeatureKey === 'supabase-ui-functions-assistant' && !enableFunctionsAssistant // this modal can be triggered on other pages // Update local state when valtio state changes @@ -103,8 +88,11 @@ const FeaturePreviewModal = () => {
-

{selectedFeature?.name}

-
+
+

{selectedFeature?.name}

+ {selectedFeature?.isNew && New} +
+
{selectedFeature?.discussionsUrl !== undefined && ( )} - + {isNotReleased ? ( + + ) : ( + + )}
{selectedFeature?.content} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx new file mode 100644 index 00000000000..3b48059d6bd --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx @@ -0,0 +1,54 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import { detectOS } from 'lib/helpers' +import Image from 'next/image' + +export const FunctionsAssistantPreview = () => { + const os = detectOS() + const { ref } = useParams() + + return ( +
+

+ We're providing an additional alternative UX to creating database functions through the + dashboard with the integration of our AI Assistant that you might have seen in the Auth + Policies section. +

+ api-docs-side-panel-preview +

+ This preview also shares an improved Assistant interface where you may provide the Assistant + with contexts in hopes to generate more relevant and higher quality outputs. Contexts that + you may provide include specific schemas, and / or specific tables from your database. +

+

+ We'd hope to use this as a consistent pattern throughout the dashboard eventually if this + feature preview proves itself to benefit most of our users, so as usual please do feel free + to let us know what you think if the attached GitHub discussion above! +

+
+

Enabling this preview will:

+
    +
  • + +
  • +
  • + +
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx index 1323ec5c305..e27f8699a4d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx @@ -68,9 +68,6 @@ const RLSCodeEditor = ({ lineNumbersMinChars: 4, folding: undefined, scrollBeyondLastLine: false, - padding: { - top: editView === 'conversation' ? 20 : 0, - }, } const onMount: OnMount = async (editor, monaco) => { diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 87b385513f5..fafc704b6ea 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -111,7 +111,7 @@ const PolicyTableRowHeader = ({ - + {!canToggleRLS ? 'You need additional permissions to create RLS policies' : 'Create with Supabase Assistant'} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 759a4dac823..65f4bf35f2c 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -17,9 +17,18 @@ import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import { Input } from 'ui' +import { + AiIconAnimation, + Button, + Input, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' import ProtectedSchemaWarning from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' +import { useAppStateSnapshot } from 'state/app-state' +import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' interface FunctionsListProps { createFunction: () => void @@ -32,10 +41,13 @@ const FunctionsList = ({ editFunction = noop, deleteFunction = noop, }: FunctionsListProps) => { - const { project } = useProjectContext() const router = useRouter() const { search } = useParams() + const { project } = useProjectContext() + const { setAiAssistantPanel } = useAppStateSnapshot() + const enableFunctionsAssistant = useIsDatabaseFunctionsAssistantEnabled() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() + const filterString = search ?? '' const setFilterString = (str: string) => { @@ -122,20 +134,41 @@ const FunctionsList = ({ />
- {!isLocked && ( - createFunction()} - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to create functions', - }, - }} - > - Create a new function - - )} +
+ {!isLocked && ( + createFunction()} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to create functions', + }, + }} + > + Create a new function + + )} + {enableFunctionsAssistant && ( + + +
{isLocked && } diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index 9faecac311e..983bb4f4283 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -61,7 +61,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = connectionString: project?.connectionString, }) const { data: entities, isLoading: isLoadingEntities } = useEntityTypesQuery({ - schema: selectedSchema, + schemas: [selectedSchema], sort: 'alphabetical', search: searchTerm, projectRef: project?.ref, diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx index 921598b6d12..4161433b7b8 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeFilterPopover/TableSelector.tsx @@ -47,7 +47,7 @@ const TableSelector = ({ projectRef: project?.ref, search: searchInput, connectionString: project?.connectionString, - schema: selectedSchemaName, + schemas: [selectedSchemaName], }) useEffect(() => { if (!initiallyLoaded && isSuccess) { diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx index fa82c7a654f..8b2003a699a 100644 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/AiMessagePre.tsx @@ -54,7 +54,7 @@ export const AiMessagePre = ({ onDiff, children, className }: AiMessagePreProps) '!bg-transparent !py-3 !px-3.5 prose dark:prose-dark', // change the look of the code block. The flex hack is so that the code is wrapping since // every word is a separate span - '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap' + '[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block' )} hideCopy hideLineNumbers diff --git a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx index edd29cd1987..e7bbd9dd31c 100644 --- a/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx +++ b/apps/studio/components/interfaces/SQLEditor/AiAssistantPanel/Message.tsx @@ -30,7 +30,7 @@ const Message = memo(function Message({ isSelected = false, onDiff = noop, children, - action = <>, + action = null, }: PropsWithChildren) { const { profile } = useProfile() diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index bd8f0a90362..07a77e633d8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -27,7 +27,7 @@ const EmptyState = ({}: EmptyStateProps) => { const { data } = useEntityTypesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], sort, }) diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 1e985ecf34d..c859e571e41 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -67,7 +67,7 @@ const TableEditorMenu = () => { { projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], search: searchText.trim() || undefined, sort, filterTypes: visibleTypes, diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx new file mode 100644 index 00000000000..90a008b41b0 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -0,0 +1,696 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { AnimatePresence, motion } from 'framer-motion' +import { last } from 'lodash' +import { ExternalLink, FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +import type { Message as MessageType } from 'ai/react' +import { useChat } from 'ai/react' +import { useParams } from 'common' +import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' +import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' +import { + AiIconAnimation, + Alert_Shadcn_, + AlertDescription_Shadcn_, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + SheetHeader, + SheetSection, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition, AssistantChatForm } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' +import { generatePrompt, retrieveDocsUrl } from './AIAssistant.utils' +import { ContextBadge } from './ContextBadge' +import { EntitiesDropdownMenu } from './EntitiesDropdownMenu' +import { Message } from './Message' +import { SchemasDropdownMenu } from './SchemasDropdownMenu' + +const ANIMATION_DURATION = 0.3 + +interface AIAssistantProps { + id: string + className?: string + debugThread: MessageWithDebug[] + onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void + onResetConversation: () => void +} + +// [Joshen] For some reason I'm having issues working with dropdown menu and scroll area + +export const AIAssistant = ({ + id, + className, + debugThread, + onDiff, + onResetConversation, +}: AIAssistantProps) => { + const { ref } = useParams() + const project = useSelectedProject() + const isOptedInToAI = useOrgOptedIntoAi() + const selectedOrganization = useSelectedOrganization() + const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + + const { aiAssistantPanel } = useAppStateSnapshot() + const { editor } = aiAssistantPanel + + const inputRef = useRef(null) + const bottomRef = useRef(null) + + const [value, setValue] = useState('') + const [selectedDatabaseEntity, setSelectedDatabaseEntity] = useState< + SupportedAssistantEntities | '' + >('') + const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) + const [selectedTables, setSelectedTables] = useState<{ schema: string; name: string }[]>([]) + const [contextHistory, setContextHistory] = useState<{ + [key: string]: { entity: string; schemas: string[]; tables: string[] } + }>({}) + // [Joshen] Mainly for error handling on useChat - cause last sent messages will be voided + const [assistantError, setAssistantError] = useState() + const [lastSentMessage, setLastSentMessage] = useState() + const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) + + const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities) + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity) + const noContextAdded = + selectedDatabaseEntity.length === 0 && + selectedSchemas.length === 0 && + selectedTables.length === 0 + + const { data } = useEntityDefinitionsQuery( + { + schemas: selectedSchemas, + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: includeSchemaMetadata } + ) + + const entityDefinitions = includeSchemaMetadata + ? selectedTables.length === 0 + ? data?.map((def) => def.sql.trim()) + : data + ?.filter((def) => { + return selectedTables.some((table) => { + return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`) + }) + }) + .map((def) => def.sql.trim()) + : undefined + + const { + messages: chatMessages, + isLoading: isChatLoading, + append, + } = useChat({ + id, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + body: { entityDefinitions, context: selectedDatabaseEntity }, + onError: (error) => setAssistantError(JSON.parse(error.message).error), + }) + + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const { isLoading: isDebugSqlLoading } = useSqlDebugMutation() + const isLoading = isChatLoading || isDebugSqlLoading + + const messages = useMemo(() => { + const merged = [ + ...debugThread, + ...chatMessages.map((m) => ({ ...m, isDebug: false })), + ...(assistantError !== undefined && lastSentMessage !== undefined ? [lastSentMessage] : []), + ] + + return merged.sort( + (a, b) => + (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || + a.role.localeCompare(b.role) + ) + }, [chatMessages, debugThread, assistantError, lastSentMessage]) + + const hasMessages = messages.length > 0 + const lastMessage = last(messages) + const pendingChatReply = isLoading && lastMessage?.role === 'user' + const pendingDebugReply = + (lastMessage as MessageWithDebug)?.isDebug && + lastMessage?.role === 'assistant' && + lastMessage.content === 'Thinking...' + const pendingReply = pendingChatReply || pendingDebugReply + + const sendMessageToAssistant = (content: string) => { + const payload = { role: 'user', createdAt: new Date(), content } as MessageType + append(payload) + setAssistantError(undefined) + setLastSentMessage(payload) + } + + const toggleSchema = (schema: string) => { + if (selectedSchemas.includes(schema)) { + setSelectedSchemas(selectedSchemas.filter((s) => s !== schema)) + } else { + const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b)) + setSelectedSchemas(newSelectedSchemas) + } + } + + const toggleEntity = ({ schema, name }: { schema: string; name: string }) => { + const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name) + if (isExisting) { + setSelectedTables(selectedTables.filter((x) => !(x.schema === schema && x.name === name))) + } else { + const newselectedTables = [...selectedTables, { schema, name }].sort( + (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) + ) + setSelectedTables(newselectedTables) + } + } + + const onClickQuickPrompt = (type: SupportedAssistantQuickPromptTypes) => { + const prompt = generatePrompt({ + type, + context: selectedDatabaseEntity as any, + schemas: selectedSchemas, + tables: selectedTables, + }) + if (prompt) { + setValue(prompt) + sendMessageToAssistant(prompt) + } + } + + const confirmOptInToShareSchemaData = async () => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + + if (!selectedOrganization?.slug) return console.error('Organization slug is required') + + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + + const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) + ? existingOptInTags + : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] + + updateOrganization( + { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + toast.success('Successfully opted-in') + setIsConfirmOptInModalOpen(false) + }, + } + ) + } + + useEffect(() => { + if (editor) { + const mode = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + if (mode) setSelectedDatabaseEntity(mode.id) + } + }, [editor]) + + useEffect(() => { + if (!isLoading) { + setValue('') + if (inputRef.current) inputRef.current.focus() + } + + setTimeout( + () => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, + isLoading ? 100 : 500 + ) + }, [isLoading]) + + useEffect(() => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + useEffect(() => { + const lastMessage = last(messages) + if (lastMessage?.role === 'user') { + const entity = entityContext?.label ?? selectedDatabaseEntity + setContextHistory({ + ...contextHistory, + [lastMessage.id]: { + entity, + schemas: selectedSchemas, + tables: selectedTables.map((x) => + selectedSchemas.length > 1 ? `${x.schema}.${x.name}` : x.name + ), + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + return ( + <> +
+ +
+ +

Assistant

+
+ + {hasMessages && ( + + + + )} + +
+ + + {hasMessages && ( + + {messages.map((m, index) => { + const isFirstUserMessage = + m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') + + return ( + onDiff({ id: m.id, diffType, sql })} + > + {isFirstUserMessage && !includeSchemaMetadata && ( + + + + )} + {isFirstUserMessage && + includeSchemaMetadata && + selectedSchemas.length === 0 && ( + + )} + + ) + })} + {assistantError !== undefined && ( + + + + )} + {!isLoading && !pendingReply && assistantError === undefined && ( +

+ Please verify all responses as the Assistant can make mistakes +

+ )} + {pendingChatReply && ( + + )} +
+ + )} + +
+ + {!hasMessages && ( + +

+ How can I help you + {!!entityContext ? ( + <> + {' '} + with{' '} + + {entityContext.id === 'rls-policies' + ? entityContext.label + : `Database ${entityContext.label}`} + + + ) : ( + ' today' + )} + ? +

+
+ )} +
+
+
+
+ + + + + + + + Add context for the assistant + + + + + + Improve the output quality of the assistant by giving it context about what + you need help with + + + {editor === null && ( + + +
+

Database Entity

+

+ Inform about what you're working with +

+
+
+ + + setSelectedDatabaseEntity(value as SupportedAssistantEntities) + } + > + {ASSISTANT_SUPPORT_ENTITIES.map((x) => ( + + {x.label} + + ))} + + +
+ )} + {includeSchemaMetadata && ( + <> + + +
+

Schemas

+

+ Share table definitions in the selected schemas +

+
+
+ + + +
+ {selectedSchemas.length > 0 && ( + + +
+

Tables

+

+ Select specific tables to share definitions for +

+
+
+ + + +
+ )} + + )} +
+
+ + {!!entityContext && ( + setSelectedDatabaseEntity('') : undefined} + /> + )} + + {selectedSchemas.length > 0 && ( + 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => { + setSelectedSchemas([]) + setSelectedTables([]) + }} + tooltip={ + selectedSchemas.length > 2 ? ( + <> +

+ {selectedSchemas.length} schemas selected: +

+
    + {selectedSchemas.map((x) => ( +
  • {x}
  • + ))} +
+ + ) : undefined + } + /> + )} + + {selectedTables.length > 0 && ( + x.name) + .join( + ', ' + )}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => setSelectedTables([])} + tooltip={ + selectedTables.length > 2 ? ( + <> +

+ {selectedTables.length} tables selected: +

+
    + {selectedTables.map((x) => ( +
  • + {x.schema}.{x.name} +
  • + ))} +
+ + ) : undefined + } + /> + )} +
+ + textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0' + )} + loading={isLoading} + disabled={isLoading} + placeholder={ + hasMessages ? 'Reply to the assistant...' : 'How can we help you today?' + } + value={value} + onValueChange={(e) => setValue(e.target.value)} + onSubmit={(event) => { + event.preventDefault() + sendMessageToAssistant(value) + }} + /> +
+ + {!hasMessages && ( + +
+ + + + + + Suggest some{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + + + + + + + + Provide some examples of{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + + + + + + + + What are{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + ? + + + {docsUrl !== undefined && ( + + )} +
+
+ )} +
+
+
+ +
+ setIsConfirmOptInModalOpen(false)} + onConfirm={confirmOptInToShareSchemaData} + loading={isUpdating} + > +

+ By opting into sending anonymous data, Supabase AI can improve the answers it shows you. + This is an organization-wide setting, and affects all projects in your organization. +

+ + +
+ + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts new file mode 100644 index 00000000000..38fb580b306 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -0,0 +1,2 @@ +export type SupportedAssistantEntities = 'rls-policies' | 'functions' +export type SupportedAssistantQuickPromptTypes = 'suggest' | 'examples' | 'ask' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts new file mode 100644 index 00000000000..b0b28d12550 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -0,0 +1,125 @@ +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' + +const PLACEHOLDER_PREFIX = `-- Press tab to use this code +\n \n` + +const PLACEHOLDER_LIMIT = `Just three examples will do.` + +export const generateTitle = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'Create a new function' + case 'rls-policies': + return 'Create a new RLS policy' + default: + return 'SQL Scratch Pad' + } +} + +export const generateCTA = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'Save function' + case 'rls-policies': + return 'Save policy' + default: + return 'Run query' + } +} + +export const generatePlaceholder = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return `${PLACEHOLDER_PREFIX} +CREATE FUNCTION *schema*.*function_name*(*param1 type*, *param2 type*)\n +  RETURNS *return_type*\n +  LANGUAGE *plpgsql*\n +  SECURITY DEFINER\n +  SET *search_path = ''*\n +AS $$\n +DECLARE\n +  *-- Variable declarations*\n +BEGIN\n +  *-- Function logic*\n +END;\n +$$; +` + case 'rls-policies': + return `${PLACEHOLDER_PREFIX} +CREATE POLICY *name* ON *table_name*\n +AS PERMISSIVE -- PERMISSIVE | RESTRICTIVE\n +FOR ALL -- ALL | SELECT | INSERT | UPDATE | DELETE\n +TO *role_name* -- Default: public\n +USING ( *using_expression* )\n +WITH CHECK ( *check_expression* ); +` + default: + return undefined + } +} + +export const retrieveDocsUrl = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'https://supabase.com/docs/guides/database/functions' + case 'rls-policies': + return 'https://supabase.com/docs/guides/database/postgres/row-level-security' + default: + return undefined + } +} + +// [Joshen] This is just very basic validation, but possible can extend perhaps +export const validateQuery = (editor: SupportedAssistantEntities | null, query: string) => { + const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') + + switch (editor) { + case 'functions': + return ( + formattedQuery.includes('create function') || + formattedQuery.includes('create or replace function') + ) + case 'rls-policies': + return formattedQuery.includes('create policy') + default: + return true + } +} + +export const generatePrompt = ({ + type, + context, + schemas, + tables, +}: { + type: SupportedAssistantQuickPromptTypes + context: SupportedAssistantEntities + schemas: string[] + tables: { schema: string; name: string }[] +}) => { + if (type === 'examples') { + return `What are some common examples of user-defined database ${context}? ${PLACEHOLDER_LIMIT}` + } else if (type === 'ask') { + return `Could you explain to me what are used-defined database ${context}?` + } else if (type === 'suggest') { + const output = + context === 'functions' + ? 'user-defined database functions' + : context === 'rls-policies' + ? 'RLS policies' + : '' + + const suffix = + context === 'functions' ? 'Let me know for which tables each function will be useful' : '' + + const basePrompt = `Suggest some ${output} that might be useful` + + if (tables.length > 0 && schemas.length > 0) { + return `${basePrompt} for the following tables within this database: ${tables.map((x) => `${x.schema}.${x.name}`)}. ${PLACEHOLDER_LIMIT} ${suffix}`.trim() + } else if (schemas.length > 0) { + return `${basePrompt} for the tables in the following schemas within this database: ${schemas.join(', ')}. ${suffix}`.trim() + } + + return basePrompt + } +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx new file mode 100644 index 00000000000..c2d0379154a --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -0,0 +1,439 @@ +import { useQueryClient } from '@tanstack/react-query' +import { uniqBy } from 'lodash' +import { Command, CornerDownLeft, Loader2, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { useChat } from 'ai/react' +import { useParams } from 'common' +import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants' +import { DiffType, IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { sqlKeys } from 'data/sql/keys' +import { usePrevious } from 'hooks/deprecated' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { SqlEditor } from 'icons' +import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants' +import { detectOS, uuidv4 } from 'lib/helpers' +import { useAppStateSnapshot } from 'state/app-state' +import { + Button, + cn, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import CodeEditor from '../CodeEditor/CodeEditor' +import { AIAssistant } from './AIAssistant' +import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' + +export const AiAssistantPanel = () => { + const os = detectOS() + const router = useRouter() + const { ref } = useParams() + const project = useSelectedProject() + const queryClient = useQueryClient() + const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + + const { open, editor, content } = aiAssistantPanel + const previousEditor = usePrevious(editor) + + const [isAcknowledged, setIsAcknowledged] = useLocalStorage( + LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, + false + ) + + const [chatId, setChatId] = useState(() => uuidv4()) + const [error, setError] = useState() + const [results, setResults] = useState(undefined) + const [showResults, setShowResults] = useState(false) + const [showWarning, setShowWarning] = useState(false) + const [debugThread, setDebugThread] = useState([]) + + // [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able + // I don't really see any negatives of keeping it open (or benefits from hiding) tbh + // const [showAssistant, setShowAssistant] = useState(true) + const title = generateTitle(editor) + const placeholder = generatePlaceholder(editor) + const ctaText = generateCTA(editor) + const editorRef = useRef() + const editorModel = editorRef.current?.getModel() + + const numResults = (results ?? []).length + const [errorHeader, ...errorContent] = + (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + + const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async (res) => { + // [Joshen] If in a specific editor context mode, assume that intent was to create/update + // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we + // show the results. Currently though it assumes we're "creating", thinking need to support "updating" too + if (editor !== null) { + switch (editor) { + case 'functions': + await queryClient.invalidateQueries(sqlKeys.query(ref, ['functions-list'])) + break + case 'rls-policies': + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + break + } + + toast.success(`Successfully created ${entityContext?.name}!`) + setAiAssistantPanel({ open: false }) + } else { + setShowResults(true) + setResults(res.result) + } + }, + onError: (error) => { + setError(error) + setResults([]) + }, + }) + + const { append } = useChat({ + id: chatId, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + // [Joshen] Don't need entity definitions if calling here cause support action + // via AI here is just to explain code segment, no need for context + body: {}, + }) + + const { mutateAsync: debugSql } = useSqlDebugMutation({ onError: () => {} }) + + const onExecuteSql = (skipValidation = false) => { + setError(undefined) + setShowWarning(false) + + const query = editorRef.current?.getValue() ?? '' + if (query.length === 0) return + + if (editor !== undefined && !skipValidation) { + // [Joshen] Some basic validation logic + const validated = validateQuery(editor, query) + if (!validated) { + return setShowWarning(true) + } + } + + executeSql({ + sql: suffixWithLimit(query, 100), + projectRef: project?.ref, + connectionString: project?.connectionString, + handleError: (error) => { + throw error + }, + }) + } + + const onFixWithAssistant = async () => { + if (!error) return + + const messageId = uuidv4() + const query = editorRef.current?.getValue() ?? '' + + const assistantMessageBefore = generateThreadMessage({ + id: messageId, + content: 'Thinking...', + isDebug: true, + }) + + setDebugThread([...debugThread, assistantMessageBefore]) + + try { + const { solution, sql } = await debugSql({ + sql: query, + errorMessage: error.message, + }) + + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } catch (error) { + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `Hmm, Sorry but I'm unable to find a solution for the error that you're facing`, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } + } + + const onExplainSql = (value: string) => { + append({ + role: 'user', + createdAt: new Date(), + content: ` + Can you explain this section to me in more detail?\n + ${value} + `.trim(), + }) + } + + const updateEditorWithCheckForDiff = useCallback( + ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => { + if (!editorModel) return + + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + // if the editor is empty, just copy over the code + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + const currentSql = editorRef.current?.getValue() + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${currentSql}\n\n${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } + }, + [editorModel] + ) + + useEffect(() => { + if (open) { + // [Joshen] Only reset the assistant if the editor changed + if (previousEditor !== editor) { + setChatId(uuidv4()) + setError(undefined) + setShowWarning(false) + setDebugThread([]) + setResults(undefined) + setShowResults(false) + } else { + // [Joshen] Repopulate the code editor with the content from where the user left off + // setTimeout is just to give time for the editorRef to get associated + setTimeout(() => { + const editorModel = editorRef.current?.getModel() + if (content && editorModel) { + editorRef.current?.executeEdits('apply-ai-message', [ + { text: content, range: editorModel.getFullModelRange() }, + ]) + } + }, 100) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // [Joshen] Just to test the concept of a universal assistant of sorts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.code === 'KeyI') { + setAiAssistantPanel({ open: true, editor: null }) + } + } + if (project !== undefined) window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project]) + + // [Joshen] Whenever the deps change recalculate the height of the editor + useEffect(() => { + editorRef.current?.layout({ width: 0, height: 0 }) + window.requestAnimationFrame(() => editorRef.current?.layout()) + }, [error, showWarning, isExecuting, numResults, showResults]) + + return ( + { + if (open) { + // [Joshen] Save the code content when closing - should allow users to continue from + // where they left off IF they are opening the assistant again in the same "editor" context + const existingValue = editorRef.current?.getValue() ?? '' + setAiAssistantPanel({ open: false, content: existingValue }) + } else { + setAiAssistantPanel({ open: true }) + } + }} + > + + {/* Assistant */} + setChatId(uuidv4())} + /> + + {/* Editor */} +
+ + {title} + + + + + } + /> + )} + {showWarning && ( + +

Are you sure you want to run this query?

+ + + } + /> + )} + {results !== undefined && results.length > 0 && ( + <> +
+ +
+
+

+ {results.length} rows + {results.length >= 100 && ` (Limited to only 100 rows)`} +

+ +
+ + )} + {results !== undefined && results.length === 0 && ( +
+

Success. No rows returned.

+
+ )} + + + + +
+
+ + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts new file mode 100644 index 00000000000..e9bc5bdeb55 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts @@ -0,0 +1,10 @@ +import { SupportedAssistantEntities } from './AIAssistant.types' + +export const ASSISTANT_SUPPORT_ENTITIES: { + id: SupportedAssistantEntities + label: string + name: string +}[] = [ + { id: 'rls-policies', label: 'RLS Policies', name: 'RLS policy' }, + { id: 'functions', label: 'Functions', name: 'database function' }, +] diff --git a/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx new file mode 100644 index 00000000000..ba205b176e0 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx @@ -0,0 +1,33 @@ +import { X } from 'lucide-react' +import { ReactNode } from 'react' +import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' + +interface ContextBadgeProps { + label: string + value: string + tooltip?: ReactNode + onRemove?: () => void +} + +export const ContextBadge = ({ label, value, tooltip, onRemove }: ContextBadgeProps) => { + return ( + + +
+
+ {label} + {value} +
+ {onRemove !== undefined && ( + onRemove()} + /> + )} +
+
+ {tooltip !== undefined && {tooltip}} +
+ ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx new file mode 100644 index 00000000000..1bbe09c0190 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren, useEffect } from 'react' +import { ScrollArea } from 'ui' + +interface DropdownScrollAreaProps { + id?: string + className?: string +} + +// [Joshen] This is a temporary solution to a really odd behavior i've observed with the components in Shadcn +// Specifically if we're using a ScrollArea (or even just anything with overflow) within a DropdownMenu, within a Sheet +// The mouse wheel event to scroll in the ScrollArea is somehow getting ignored in this specific context, hence the fix here + +export const DropdownScrollArea = ({ + id, + className, + children, +}: PropsWithChildren) => { + useEffect(() => { + const scrollFix = (e: WheelEvent) => e.stopPropagation() + + if (id) { + const el = document.getElementById(id) + if (el) { + el.addEventListener('wheel', scrollFix, { passive: false }) + return () => el.removeEventListener('wheel', scrollFix) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx new file mode 100644 index 00000000000..d301fb5814e --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx @@ -0,0 +1,110 @@ +import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { debounce } from 'lodash' +import { Check, Loader2, Search } from 'lucide-react' +import { useCallback, useState } from 'react' +import { + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + ScrollArea, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Admonition } from 'ui-patterns' + +interface EntitiesDropdownMenuProps { + selectedSchemas: string[] + selectedEntities: { schema: string; name: string }[] + onToggleEntity: (value: { schema: string; name: string }) => void +} + +export const EntitiesDropdownMenu = ({ + selectedSchemas, + selectedEntities, + onToggleEntity, +}: EntitiesDropdownMenuProps) => { + const project = useSelectedProject() + const [search, setSearch] = useState('') + + // [Joshen] Not gonna do infinite loading here, but rather just try to load sufficient tables + // I reckon users will opt to just search if there's many tables here given the small real estate + const { + data: entitiesData, + error, + isLoading, + isFetching, + isError, + } = useEntityTypesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: selectedSchemas, + limit: 100, + search: search.length === 0 ? undefined : search, + }) + const entities = ( + entitiesData?.pages[0].data.entities ? [...entitiesData?.pages[0].data.entities] : [] + ).sort((a, b) => (a.name > b.name ? 0 : -1)) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const searchInputHandler = useCallback(debounce(setSearch, 1000), []) + + if (isError) { + return ( + + ) + } + + return ( + + + {/* Separate input for async searching */} + } + className="text-xs outline-none rounded-b-none border-0 border-b pl-9 pr-7" + onChange={(e) => searchInputHandler(e.target.value)} + actions={ + (isLoading || isFetching) && ( + + ) + } + /> + No tables found + + 7 ? 'h-[210px]' : ''} + > + {entities?.map((entity) => ( + onToggleEntity({ schema: entity.schema, name: entity.name })} + onClick={() => onToggleEntity({ schema: entity.schema, name: entity.name })} + > + + {entity.schema} + {entity.name} + + {selectedEntities.find( + (x) => x.name === entity.name && x.schema === entity.schema + ) && } + + ))} + + + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx new file mode 100644 index 00000000000..3d20ff3c6c6 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -0,0 +1,146 @@ +import dayjs from 'dayjs' +import { noop } from 'lodash' +import Image from 'next/image' +import { PropsWithChildren, memo, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { AiIconAnimation, Badge, cn, markdownComponents, WarningIcon } from 'ui' + +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useProfile } from 'lib/profile' +import { MessagePre } from './MessagePre' + +interface MessageProps { + name?: string + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' + content?: string + createdAt?: number + isDebug?: boolean + isSelected?: boolean + action?: React.ReactNode + context?: { entity: string; schemas: string[]; tables: string[] } + onDiff?: (type: DiffType, s: string) => void + variant?: 'default' | 'warning' +} + +export const Message = memo(function Message({ + name, + role, + content, + createdAt, + isDebug, + isSelected = false, + context, + children, + action = null, + variant = 'default', + onDiff = noop, +}: PropsWithChildren) { + const { profile } = useProfile() + const isUser = role === 'user' + + const icon = useMemo(() => { + return role === 'assistant' ? ( + + ) : ( +
+ avatar +
+ ) + }, [content, profile?.username, role]) + + const formattedContext = + context !== undefined + ? Object.entries(context) + .filter(([_, value]) => value.length > 0) + .map(([key, value]) => { + return `${key.charAt(0).toUpperCase() + key.slice(1)}: ${Array.isArray(value) ? value.join(', ') : value}` + }) + .join(' • ') + : undefined + + if (!content) return null + + return ( +
+
+
+ {variant === 'warning' ? : icon} + +
+
+ {!isUser ? 'Assistant' : name ? name : 'You'} + {createdAt && ( + + {dayjs(createdAt).fromNow()} + + )} +
+ {role === 'user' && context !== undefined && ( + {formattedContext} + )} +
+ + {isDebug && Debug request} +
{' '} + {action} +
+ + { + return ( + div>pre]:max-w-full', + isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' + )} + > + {props.children[0].props.children} + + ) + }, + ol: (props: any) => { + return
    {props.children}
+ }, + li: (props: any) => { + return
  • {props.children}
  • + }, + h3: (props: any) => { + return

    {props.children}

    + }, + code: (props: any) => { + return {props.children} + }, + }} + > + {content} +
    + {children} +
    + ) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx new file mode 100644 index 00000000000..414b5fc0917 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx @@ -0,0 +1,113 @@ +import { InsertCode, ReplaceCode } from 'icons' +import { Check, Copy } from 'lucide-react' +import { useEffect, useState } from 'react' +import { format } from 'sql-formatter' +import { + Button, + CodeBlock, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, + cn, +} from 'ui' + +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' + +interface MessagePreProps { + onDiff: (type: DiffType, s: string) => void + children: string[] + className?: string +} + +export const MessagePre = ({ onDiff, children, className }: MessagePreProps) => { + const [copied, setCopied] = useState(false) + + const { mutate: sendEvent } = useSendEventMutation() + + useEffect(() => { + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + }, [copied]) + + let formatted = (children || [''])[0] + try { + formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) + } catch {} + + if (formatted.length === 0) { + return null + } + + function handleCopy(formatted: string) { + navigator.clipboard.writeText(formatted).then() + setCopied(true) + } + + return ( +
    +      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block'
    +        )}
    +        hideCopy
    +        hideLineNumbers
    +      />
    +      
    + + + + + + Insert code + + + + + + + + + Copy code + + +
    +
    + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx new file mode 100644 index 00000000000..6db2472077c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx @@ -0,0 +1,61 @@ +import { + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, +} from 'ui' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Check } from 'lucide-react' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' + +interface SchemasDropdownMenuProps { + selectedSchemas: string[] + onToggleSchema: (schema: string) => void +} + +export const SchemasDropdownMenu = ({ + selectedSchemas, + onToggleSchema, +}: SchemasDropdownMenuProps) => { + const project = useSelectedProject() + + const { data: schemaDatas } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const schemas = (schemaDatas || []).sort((a, b) => a.name.localeCompare(b.name)) + + return ( + + + + No schemas found + + 7 ? 'h-[210px]' : '')} + > + {schemas?.map((schema) => ( + onToggleSchema(schema.name)} + onClick={() => onToggleSchema(schema.name)} + > + {schema.name} + {selectedSchemas.includes(schema.name) && ( + + )} + + ))} + + + + + ) +} diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx index 24eeb0d677d..62c08d73b6b 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.tsx +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.tsx @@ -1,13 +1,23 @@ -import Editor, { EditorProps, Monaco, OnChange, OnMount } from '@monaco-editor/react' +import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react' import { merge, noop } from 'lodash' import { editor } from 'monaco-editor' -import { useEffect, useRef, useState } from 'react' +import { MutableRefObject, useEffect, useRef, useState } from 'react' import { Markdown } from 'components/interfaces/Markdown' import { timeout } from 'lib/helpers' import { cn } from 'ui' import { Loading } from '../Loading' import { alignEditor } from './CodeEditor.utils' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { formatQuery } from 'data/sql/format-sql-query' + +type CodeEditorActions = { enabled: boolean; callback: (value: any) => void } +const DEFAULT_ACTIONS = { + runQuery: { enabled: false, callback: noop }, + explainCode: { enabled: false, callback: noop }, + formatDocument: { enabled: true, callback: noop }, + placeholderFill: { enabled: true }, +} interface CodeEditorProps { id: string @@ -15,15 +25,21 @@ interface CodeEditorProps { autofocus?: boolean defaultValue?: string isReadOnly?: boolean - onInputChange?: (value?: string) => void - onInputRun?: (value: string) => void hideLineNumbers?: boolean className?: string loading?: boolean options?: EditorProps['options'] value?: string placeholder?: string - disableTabToUsePlaceholder?: boolean + /* Determines what actions to add for code editor context menu */ + actions?: Partial<{ + runQuery: CodeEditorActions + formatDocument: CodeEditorActions + placeholderFill: Omit + explainCode: CodeEditorActions + }> + editorRef?: MutableRefObject + onInputChange?: (value?: string) => void } const CodeEditor = ({ @@ -33,83 +49,31 @@ const CodeEditor = ({ autofocus = true, isReadOnly = false, hideLineNumbers = false, - onInputChange = noop, - onInputRun = noop, className, loading, options, value, placeholder, - disableTabToUsePlaceholder = false, + actions = DEFAULT_ACTIONS, + editorRef: editorRefProps, + onInputChange = noop, }: CodeEditorProps) => { - const hasValue = useRef() + const monaco = useMonaco() + const project = useSelectedProject() - const editorRef = useRef() + const hasValue = useRef() + const ref = useRef() + const editorRef = editorRefProps || ref const monacoRef = useRef() + const { runQuery, placeholderFill, formatDocument, explainCode } = { + ...DEFAULT_ACTIONS, + ...actions, + } + const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0 const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault) - const onMount: OnMount = async (editor, monaco) => { - editorRef.current = editor - monacoRef.current = monaco - alignEditor(editor) - - hasValue.current = editor.createContextKey('hasValue', false) - hasValue.current.set(value !== undefined && value.trim().length > 0) - setShowPlaceholder(showPlaceholderDefault) - - if (!disableTabToUsePlaceholder) { - editor.addCommand( - monaco.KeyCode.Tab, - () => { - editor.executeEdits('source', [ - { - // @ts-ignore - identifier: 'add-placeholder', - range: new monaco.Range(1, 1, 1, 1), - text: (placeholder ?? '') - .split('\n\n') - .join('\n') - .replaceAll('*', '') - .replaceAll(' ', ''), - }, - ]) - }, - '!hasValue' - ) - } - - editor.addAction({ - id: 'supabase', - label: 'Run Query', - keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], - contextMenuGroupId: 'operation', - contextMenuOrder: 0, - run: async () => { - const selectedValue = (editorRef?.current as any) - .getModel() - .getValueInRange((editorRef?.current as any)?.getSelection()) - onInputRun(selectedValue || (editorRef?.current as any)?.getValue()) - }, - }) - - const model = editor.getModel() - if (model) { - const position = model.getPositionAt((value ?? '').length) - editor.setPosition(position) - } - - await timeout(500) - if (autofocus) editor?.focus() - } - - const onChangeContent: OnChange = (value) => { - hasValue.current.set((value ?? '').length > 0) - setShowPlaceholder(!value) - onInputChange(value) - } - const optionsMerged = merge( { tabSize: 2, @@ -128,13 +92,105 @@ const CodeEditor = ({ options ) + const formatPgsql = async (value: string) => { + try { + if (!project) throw new Error('No project') + const formatted = await formatQuery({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: value, + }) + return formatted.result + } catch (error) { + console.error('formatPgsql error:', error) + return value + } + } + + const onMount: OnMount = async (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco + alignEditor(editor) + + hasValue.current = editor.createContextKey('hasValue', false) + hasValue.current.set(value !== undefined && value.trim().length > 0) + setShowPlaceholder(showPlaceholderDefault) + + if (placeholderFill.enabled) { + editor.addCommand( + monaco.KeyCode.Tab, + () => { + editor.executeEdits('source', [ + { + // @ts-ignore + identifier: 'add-placeholder', + range: new monaco.Range(1, 1, 1, 1), + text: (placeholder ?? '') + .split('\n\n') + .join('\n') + .replaceAll('*', '') + .replaceAll(' ', ' '), + }, + ]) + }, + '!hasValue' + ) + } + + if (runQuery.enabled) { + editor.addAction({ + id: 'run-query', + label: 'Run Query', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + contextMenuGroupId: 'operation', + contextMenuOrder: 0, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + runQuery.callback(selectedValue || (editorRef?.current as any)?.getValue()) + }, + }) + } + + if (explainCode.enabled) { + editor.addAction({ + id: 'explain-code', + label: 'Explain Code', + contextMenuGroupId: 'operation', + contextMenuOrder: 1, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + explainCode.callback(selectedValue) + }, + }) + } + + const model = editor.getModel() + if (model) { + const position = model.getPositionAt((value ?? '').length) + editor.setPosition(position) + } + + await timeout(500) + if (autofocus) editor?.focus() + } + + const onChangeContent: OnChange = (value) => { + hasValue.current.set((value ?? '').length > 0) + setShowPlaceholder(!value) + onInputChange(value) + } + useEffect(() => { setShowPlaceholder(showPlaceholderDefault) }, [showPlaceholderDefault]) useEffect(() => { if ( - !disableTabToUsePlaceholder && + placeholderFill.enabled && editorRef.current !== undefined && monacoRef.current !== undefined ) { @@ -160,7 +216,22 @@ const CodeEditor = ({ '!hasValue' ) } - }, [placeholder, disableTabToUsePlaceholder]) + }, [placeholder, placeholderFill.enabled]) + + useEffect(() => { + if (monaco && project && formatDocument.enabled) { + const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { + async provideDocumentFormattingEdits(model: any) { + const value = model.getValue() + const formatted = await formatPgsql(value) + formatDocument.callback(formatted) + return [{ range: model.getFullModelRange(), text: formatted }] + }, + }) + return () => formatProvider.dispose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [monaco, project, formatDocument.enabled]) return ( <> diff --git a/apps/studio/components/ui/SchemaComboBox.tsx b/apps/studio/components/ui/SchemaComboBox.tsx index dc93562dc9c..f4810f6df7c 100644 --- a/apps/studio/components/ui/SchemaComboBox.tsx +++ b/apps/studio/components/ui/SchemaComboBox.tsx @@ -19,6 +19,7 @@ import { Popover_Shadcn_, ScrollArea, } from 'ui' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' interface SchemaComboBoxProps { className?: string @@ -44,7 +45,7 @@ export const SchemaComboBox = ({ }: SchemaComboBoxProps) => { const [open, setOpen] = useState(false) - const { project } = useProjectContext() + const project = useSelectedProject() const { data, isLoading: isSchemasLoading, diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index 0e820869fcf..e288f81fa0e 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -3,7 +3,13 @@ import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/exec import { CREATE_PG_GET_TABLEDEF_SQL } from './database-query-constants' import { databaseKeys } from './keys' -export const getEntityDefinitionsQuery = (schemas: string[], limit = 100) => { +export const getEntityDefinitionsQuery = ({ + schemas, + limit = 100, +}: { + schemas: string[] + limit?: number +}) => { const sql = /* SQL */ ` ${CREATE_PG_GET_TABLEDEF_SQL} @@ -99,7 +105,7 @@ export const useEntityDefinitionsQuery = < { projectRef, connectionString, - sql: getEntityDefinitionsQuery(schemas, limit), + sql: getEntityDefinitionsQuery({ schemas, limit }), queryKey: databaseKeys.entityDefinitions(projectRef, schemas), }, { diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index 470bd56933b..3931fb892e9 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -5,7 +5,7 @@ import { entityTypeKeys } from './keys' export type EntityTypesVariables = { projectRef?: string - schema?: string + schemas?: string[] search?: string limit?: number page?: number @@ -24,7 +24,7 @@ export async function getEntityTypes( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, page = 0, @@ -67,7 +67,7 @@ export async function getEntityTypes( ) or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') ) - and nc.nspname = '${schema}' + and nc.nspname in (${schemas.map((x) => `'${x}'`)}) ${search ? `and c.relname ilike '%${search}%'` : ''} order by ${innerOrderBy} limit ${limit} @@ -111,7 +111,7 @@ export const useEntityTypesQuery = ( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, sort, @@ -123,10 +123,19 @@ export const useEntityTypesQuery = ( }: UseInfiniteQueryOptions = {} ) => useInfiniteQuery( - entityTypeKeys.list(projectRef, { schema, search, sort, limit, filterTypes }), + entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), ({ signal, pageParam }) => getEntityTypes( - { projectRef, connectionString, schema, search, limit, page: pageParam, sort, filterTypes }, + { + projectRef, + connectionString, + schemas, + search, + limit, + page: pageParam, + sort, + filterTypes, + }, signal ), { diff --git a/apps/studio/data/entity-types/keys.ts b/apps/studio/data/entity-types/keys.ts index c04fc7dbd12..7e3505fdffc 100644 --- a/apps/studio/data/entity-types/keys.ts +++ b/apps/studio/data/entity-types/keys.ts @@ -2,7 +2,7 @@ export const entityTypeKeys = { list: ( projectRef: string | undefined, params?: { - schema?: string + schemas?: string[] search?: string sort?: 'alphabetical' | 'grouped-alphabetical' limit?: number diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index da484ef951d..c1e359bda6f 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -36,8 +36,11 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', + UI_PREVIEW_FUNCTIONS_ASSISTANT: 'supabase-ui-functions-assistant', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', + SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', + DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, SQL_EDITOR_INTELLISENSE: 'supabase_sql-editor-intellisense-enabled', diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 6c7899c806b..63fa4d1a825 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -41,6 +41,7 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' +import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' import FlagProvider from 'components/ui/Flag/FlagProvider' import PageTelemetry from 'components/ui/PageTelemetry' @@ -147,6 +148,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts index d529568da0b..d21b384a479 100644 --- a/apps/studio/pages/api/ai/sql/generate-v2.ts +++ b/apps/studio/pages/api/ai/sql/generate-v2.ts @@ -1,5 +1,6 @@ import { StreamingTextResponse } from 'ai' import { chatSql } from 'ai-commands/edge' +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' import { NextRequest } from 'next/server' import OpenAI from 'openai' @@ -70,12 +71,13 @@ async function handlePost(request: NextRequest) { messages: { content: string; role: 'user' | 'assistant' }[] existingSql?: string entityDefinitions: string[] + context?: SupportedAssistantEntities }>) - const { messages, existingSql, entityDefinitions } = body + const { messages, existingSql, entityDefinitions, context } = body try { - const stream = await chatSql(openai, messages, existingSql, entityDefinitions) + const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context) return new StreamingTextResponse(stream) } catch (error) { if (error instanceof Error) { diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index cb349d96b56..f2640fc356a 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -366,7 +366,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { language="pgsql" // its bq sql but monaco doesn't have a language for it defaultValue={warehouseEditorValue} onInputChange={(v) => setWarehouseEditorValue(v || '')} - onInputRun={handleRun} + actions={{ runQuery: { enabled: true, callback: handleRun } }} /> ) : ( { language="pgsql" defaultValue={editorValue} onInputChange={(v) => setEditorValue(v || '')} - onInputRun={handleRun} + actions={{ runQuery: { enabled: true, callback: handleRun } }} /> )} diff --git a/apps/studio/public/img/previews/functions-assistant.png b/apps/studio/public/img/previews/functions-assistant.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc3f94c8f7dcd783f5d1d3908815b3626f576e9 GIT binary patch literal 275225 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%0De@(@!a7|qAhBitr2vu&c0<^S7w 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%0De(s<%(&33(;NL)VNegmr!Z2t+8VA$2C&`fC}b2 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

    $If@R%~T1rW)EOV^Gx{o3kRz2|U4pbc^v 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 { appState.navigationPanelJustClosed = value }, + + aiAssistantPanel: { open: false, editor: null, content: '' } as AiAssistantPanelType, + setAiAssistantPanel: (value: AiAssistantPanelType) => { + appState.aiAssistantPanel = { ...appState.aiAssistantPanel, ...value } + }, }) export const getAppStateSnapshot = () => snapshot(appState) diff --git a/packages/ai-commands/src/sql/chat.ts b/packages/ai-commands/src/sql/chat.ts index 3b8deb258f0..4b8457cd74f 100644 --- a/packages/ai-commands/src/sql/chat.ts +++ b/packages/ai-commands/src/sql/chat.ts @@ -13,26 +13,55 @@ export async function chatSql( openai: OpenAI, messages: Message[], existingSql?: string, - entityDefinitions?: string[] + entityDefinitions?: string[], + context?: 'rls-policies' | 'functions' ): Promise> { const initMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ { role: 'system', content: stripIndent` - The generated SQL (must be valid SQL). - - For primary keys, always use "id bigint primary key generated always as identity" (not serial) - - Prefer creating foreign key references in the create statement - - Prefer 'text' over 'varchar' - - Prefer 'timestamp with time zone' over 'date' - - Use vector(384) data type for any embedding/vector related query + The generated SQL (must be valid SQL), and must adhere to the following: - Always use double apostrophe in SQL strings (eg. 'Night''s watch') - Always use semicolons - Output as markdown - Always include code snippets if available + - Use vector(384) data type for any embedding/vector related query + + When generating tables, do the following: + - For primary keys, always use "id bigint primary key generated always as identity" (not serial) + - Prefer creating foreign key references in the create statement + - Prefer 'text' over 'varchar' + - Prefer 'timestamp with time zone' over 'date' + + Feel free to suggest corrections for suspected typos. `, }, ] + if (context) { + // [Joshen] Thinking how we can prompt-engineer to give even better results, what I have below + // is definitely not optimal at all, but just to get an idea started + const generateInstructionsBasedOnContext = () => { + switch (context) { + case 'rls-policies': + return stripIndent` + You're a Supabase Postgres expert in writing row level security policies. Your purpose is to + generate a policy with the constraints given by the user. You will be provided a schema + on which the policy should be applied.` + case 'functions': + return stripIndent` + You're a Supabase Postgres expert in writing database functions. Your purpose is to generate a + database function with the constraints given by the user. The output may also include a database trigger + if the function returns a type of trigger. When generating functions, do the following: + - If the function returns a trigger type, ensure that it uses security definer, otherwise default to security invoker. Include this in the create functions SQL statement. + - Ensure to set the search_path configuration parameter as '', include this in the create functions SQL statement. + Please make sure that all queries are valid Postgres SQL queries. + ` + } + } + initMessages.push({ role: 'user', content: generateInstructionsBasedOnContext() }) + } + if (entityDefinitions) { const definitions = codeBlock`${entityDefinitions.join('\n\n')}` initMessages.push({ @@ -48,6 +77,7 @@ export async function chatSql( content: codeBlock` Here is the existing SQL I wrote for reference: ${sqlBlock} + Any SQL output should follow the casing of the existing SQL that I've written. `.trim(), }) } diff --git a/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx index 71885449bd0..4e384513e0b 100644 --- a/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx +++ b/packages/ui-patterns/AssistantChat/AssistantChatForm.tsx @@ -68,7 +68,12 @@ const AssistantChatForm = React.forwardRef( } return ( - + {icon && (

    {icon} diff --git a/packages/ui-patterns/admonition.tsx b/packages/ui-patterns/admonition.tsx index 0754ec46867..eb4943d35b2 100644 --- a/packages/ui-patterns/admonition.tsx +++ b/packages/ui-patterns/admonition.tsx @@ -110,9 +110,9 @@ export const Admonition = forwardRef< > {(showIcon && typeMapped === 'warning') || typeMapped === 'destructive' ? ( - ) : ( + ) : showIcon ? ( - )} + ) : null} {label || title ? ( <> Date: Wed, 23 Oct 2024 13:50:33 +0200 Subject: [PATCH 093/255] add loki log drain form (#29848) * add loki log drain form * update form to use headers * add cursed icons * regen api types * add ff * update api client & fix icons * update api types --- .../LogDrainDestinationSheetForm.tsx | 181 +++++++++++------- .../LogDrains/LogDrains.constants.tsx | 18 +- .../interfaces/LogDrains/LogDrains.tsx | 28 +-- packages/api-types/types/api.d.ts | 113 ++--------- packages/icons/__registry__/index.tsx | 20 ++ packages/icons/src/icons/datadog.ts | 23 +++ packages/icons/src/icons/grafana.ts | 23 +++ packages/icons/src/icons/index.ts | 41 ++-- packages/icons/src/raw-icons/datadog.svg | 1 + packages/icons/src/raw-icons/grafana.svg | 12 ++ 10 files changed, 250 insertions(+), 210 deletions(-) create mode 100644 packages/icons/src/icons/datadog.ts create mode 100644 packages/icons/src/icons/grafana.ts create mode 100644 packages/icons/src/raw-icons/datadog.svg create mode 100644 packages/icons/src/raw-icons/grafana.svg diff --git a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx index c0c26f9a51d..a2d1e8a53f9 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrainDestinationSheetForm.tsx @@ -38,6 +38,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { InfoTooltip } from 'ui-patterns/info-tooltip' import { DATADOG_REGIONS, LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' import { urlRegex } from '../Auth/Auth.constants' +import { useFlag } from 'hooks/ui/useFlag' const FORM_ID = 'log-drain-destination-form' @@ -63,6 +64,11 @@ const formUnion = z.discriminatedUnion('type', [ z.object({ type: z.literal('bigquery'), }), + z.object({ + type: z.literal('loki'), + url: z.string().min(1, { message: 'Loki URL is required' }), + headers: z.record(z.string(), z.string()), + }), ]) const formSchema = z @@ -128,6 +134,7 @@ export function LogDrainDestinationSheetForm({ onSubmit: (values: z.infer) => void mode: 'create' | 'update' }) { + const lokiLogDrainsEnabled = useFlag('lokilogdrains') const CREATE_DEFAULT_HEADERS = { 'Content-Type': 'application/json', } @@ -197,6 +204,16 @@ export function LogDrainDestinationSheetForm({ } }, [mode, open, form]) + function getHeadersSectionDescription() { + if (type === 'webhook') { + return 'Set custom headers when draining logs to the Endpoint URL' + } + if (type === 'loki') { + return 'Set custom headers when draining logs to the Loki HTTP(S) endpoint' + } + return '' + } + return ( t.value === type)?.name} - {LOG_DRAIN_TYPES.map((type) => ( - - {type.name} - - ))} + {LOG_DRAIN_TYPES.map((type) => + type.value === 'loki' && !lokiLogDrainsEnabled ? null : ( + + {type.name} + + ) + )} @@ -309,7 +328,6 @@ export function LogDrainDestinationSheetForm({ - )} /> @@ -332,36 +350,6 @@ export function LogDrainDestinationSheetForm({ )} /> - -
    -
    - Custom Headers -

    - Set custom headers when draining logs to the Endpoint URL -

    -
    -
    - {hasHeaders && - Object.keys(headers || {})?.map((headerKey) => ( -
    -
    {headerKey}
    -
    - {headers?.[headerKey]} -
    - -
    - ))} -
    -
    )} {type === 'datadog' && ( @@ -429,45 +417,90 @@ export function LogDrainDestinationSheetForm({ />
    )} + {type === 'loki' && ( +
    + +
    + )} + {/* This form needs to be outside the */} - {type === 'webhook' && ( -
    { - e.preventDefault() - e.stopPropagation() - addHeader() - }} - className="flex border-t py-4 gap-4 items-center px-content" - > - - setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} - /> - - setNewCustomHeader({ ...newCustomHeader, value: e.target.value })} - /> + {(type === 'webhook' || type === 'loki') && ( + <> +
    +
    +

    Custom Headers

    +

    + {getHeadersSectionDescription()} +

    +
    +
    + {hasHeaders && + Object.keys(headers || {})?.map((headerKey) => ( +
    +
    {headerKey}
    +
    + {headers?.[headerKey]} +
    + +
    + ))} +
    +
    + { + e.preventDefault() + e.stopPropagation() + addHeader() + }} + className="flex border-t py-4 gap-4 items-center px-content" + > + + setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} + /> + + + setNewCustomHeader({ ...newCustomHeader, value: e.target.value }) + } + /> - - + + + )} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index 8a00b1e3e30..e73608d482d 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -1,6 +1,11 @@ -import { BracesIcon, DogIcon } from 'lucide-react' +import { BracesIcon } from 'lucide-react' +import { Datadog, Grafana } from 'icons' -const iconProps = { size: 24, strokeWidth: 1.5, className: 'text-foreground-light' } +const iconProps = { + height: 24, + width: 24, + className: 'text-foreground-light', +} export const LOG_DRAIN_TYPES = [ { @@ -13,7 +18,14 @@ export const LOG_DRAIN_TYPES = [ value: 'datadog', name: 'Datadog', description: 'Datadog is a monitoring service for cloud-scale applications', - icon: , + icon: , + }, + { + value: 'loki', + name: 'Loki', + description: + 'Loki is an open-source log aggregation system designed to store and query logs from multiple sources', + icon: , }, ] as const diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index ce41a14e033..894832547f7 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -27,6 +27,7 @@ import { import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' +import { useFlag } from 'hooks/ui/useFlag' export function LogDrains({ onNewDrainClick, @@ -39,6 +40,7 @@ export function LogDrains({ const { isLoading: orgPlanLoading, plan } = useCurrentOrgPlan() const logDrainsEnabled = !orgPlanLoading && (plan?.id === 'team' || plan?.id === 'enterprise') + const lokiLogDrainsEnabled = useFlag('lokilogdrains') const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [selectedLogDrain, setSelectedLogDrain] = useState(null) @@ -90,18 +92,20 @@ export function LogDrains({ if (!isLoading && logDrains?.length === 0) { return ( -
    - {LOG_DRAIN_TYPES.map((src) => ( - { - onNewDrainClick(src.value) - }} - /> - ))} +
    + {LOG_DRAIN_TYPES.map((src) => + src.value === 'loki' && !lokiLogDrainsEnabled ? null : ( + { + onNewDrainClick(src.value) + }} + /> + ) + )}
    ) } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 3a85142fd88..b9504458a6e 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -39,10 +39,6 @@ export interface paths { /** Gets details of the organization linked to the provided Fly organization id */ get: operations['FlyOrganizationsController_getOrganization'] } - '/partners/flyio/organizations/{organization_id}/billing': { - /** Gets the organizations current unbilled charges */ - get: operations['FlyBillingController_getResourceBilling'] - } '/partners/flyio/organizations/{organization_id}/extensions': { /** Gets all databases that belong to the given Fly organization id */ get: operations['FlyOrganizationsController_getOrgExtensions'] @@ -1189,10 +1185,6 @@ export interface paths { /** Processes Orb events */ post: operations['OrbWebhooksController_processEvent'] } - '/system/organizations/{slug}/billing/partner/usage-and-costs': { - /** Gets the partner usage and costs */ - get: operations['PartnerBillingSystemController_getPartnerUsageAndCosts'] - } '/system/organizations/{slug}/billing/subscription': { /** Gets the current subscription */ get: operations['OrgSubscriptionSystemController_getSubscription'] @@ -1241,7 +1233,7 @@ export interface paths { * Create a function * @description Creates a function and adds it to the specified project. */ - post: operations['SystemFunctionsController_createFunction'] + post: operations['v1-create-a-function'] /** Deletes all Edge Functions from a project */ delete: operations['SystemFunctionsController_systemDeleteAllFunctions'] } @@ -2137,7 +2129,7 @@ export interface paths { * Create a function * @description Creates a function and adds it to the specified project. */ - post: operations['FunctionsController_createFunction'] + post: operations['v1-create-a-function'] } '/v1/projects/{ref}/functions/{function_slug}': { /** @@ -2763,7 +2755,7 @@ export interface components { description?: string name: string /** @enum {string} */ - type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' + type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' | 'loki' } CreateBranchBody: { branch_name: string @@ -4023,7 +4015,7 @@ export interface components { name: string token: string /** @enum {string} */ - type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' + type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' | 'loki' user_id: number } LFEndpoint: { @@ -5095,47 +5087,6 @@ export interface components { ResizeBody: { volume_size_gb: number } - ResourceBillingItem: { - /** - * @description Costs of the item in cents - * @example 100 - */ - costs: number - /** - * @description In case of a usage item, the free usage included in the customers Plan - * @example 100 - */ - freeUnitsInPlan?: number - /** - * @description Non-Unique identifier of the item - * @example usage_egress - */ - itemIdentifier: string - /** - * @description Descriptive name of the billing item - * @example Pro Plan - */ - itemName: string - /** @enum {string} */ - type: 'usage' | 'plan' | 'addon' | 'proration' | 'compute_credits' - /** - * @description In case of a usage item, the billable usage amount, free usage has been deducted - * @example 100 - */ - usageBillable?: number - /** - * @description In case of a usage item, the total usage - * @example 100 - */ - usageTotal?: number - } - ResourceBillingResponse: { - /** @description Whether the user is exceeding the included quotas in the Plan - only relevant for users on usage-capped Plans. */ - exceedsPlanLimits: boolean - items: components['schemas']['ResourceBillingItem'][] - /** @description Whether the user is can have over-usage, which will be billed - this will be false on usage-capped Plans. */ - overusageAllowed: boolean - } ResourceProvisioningConfigResponse: { /** * @description Pooler connection string @@ -7042,21 +6993,6 @@ export interface operations { } } } - /** Gets the organizations current unbilled charges */ - FlyBillingController_getResourceBilling: { - parameters: { - path: { - organization_id: string - } - } - responses: { - 200: { - content: { - 'application/json': components['schemas']['ResourceBillingResponse'] - } - } - } - } /** Gets all databases that belong to the given Fly organization id */ FlyOrganizationsController_getOrgExtensions: { parameters: { @@ -10074,19 +10010,11 @@ export interface operations { } } } - /** - * Create a function - * @description Creates a function and adds it to the specified project. - */ + /** Creates project pg.function */ FunctionsController_createFunction: { parameters: { - query?: { - slug?: string - name?: string - verify_jwt?: boolean - import_map?: boolean - entrypoint_path?: string - import_map_path?: string + header: { + 'x-connection-encrypted': string } path: { /** @description Project ref */ @@ -10095,20 +10023,19 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['V1CreateFunctionBody'] - 'application/vnd.denoland.eszip': components['schemas']['V1CreateFunctionBody'] + 'application/json': components['schemas']['CreateFunctionBody'] } } responses: { 201: { content: { - 'application/json': components['schemas']['FunctionResponse'] + 'application/json': components['schemas']['PostgresFunction'] } } 403: { content: never } - /** @description Failed to create project's function */ + /** @description Failed to create pg.function */ 500: { content: never } @@ -14497,24 +14424,6 @@ export interface operations { } } } - /** Gets the partner usage and costs */ - PartnerBillingSystemController_getPartnerUsageAndCosts: { - parameters: { - path: { - /** @description Organization slug */ - slug: string - } - } - responses: { - 200: { - content: never - } - /** @description Failed to retrieve subscription */ - 500: { - content: never - } - } - } /** Gets the current subscription */ OrgSubscriptionSystemController_getSubscription: { parameters: { @@ -14751,7 +14660,7 @@ export interface operations { * Create a function * @description Creates a function and adds it to the specified project. */ - SystemFunctionsController_createFunction: { + 'v1-create-a-function': { parameters: { query?: { slug?: string diff --git a/packages/icons/__registry__/index.tsx b/packages/icons/__registry__/index.tsx index 7dc292a1252..3d22f40f027 100644 --- a/packages/icons/__registry__/index.tsx +++ b/packages/icons/__registry__/index.tsx @@ -54,6 +54,16 @@ export const Index: Record = [ svg: "\n \n \n \n", jsx: "import { Database } from \"icons\"\n \n " }, +{ + name: "datadog", + componentName: "Datadog", + deprecated: false, + raw: "import createSupabaseIcon from '../createSupabaseIcon';\n\n/**\n * @component @name Datadog\n * @description Supabase SVG icon component, renders SVG Element with children.\n *\n * @preview ![img](data:image/svg+xml;base64,PHN2ZyBzdHJva2U9IiMwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjZmZmOyBib3JkZXItcmFkaXVzOiAycHgiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIwIiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgaGVpZ2h0PSIyNCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0xOS41NyAxNy4wNGwtMS45OTctMS4zMTYtMS42NjUgMi43ODItMS45MzctLjU2Ny0xLjcwNiAyLjYwNC4wODcuODIgOS4yNzQtMS43MS0uNTM4LTUuNzk0em0tOC42NDktMi40OThsMS40ODgtLjIwNGMuMjQxLjEwOC40MDkuMTUuNjk3LjIyMy40NS4xMTcuOTcuMjMgMS43NDEtLjE2LjE4LS4wODguNTUzLS40My43MDQtLjYyNWw2LjA5Ni0xLjEwNi42MjIgNy41MjctMTAuNDQ0IDEuODgyem0xMS4zMjUtMi43MTJsLS42MDIuMTE1TDIwLjQ4OCAwIC43ODkgMi4yODVsMi40MjcgMTkuNjkzIDIuMzA2LS4zMzRjLS4xODQtLjI2My0uNDcxLS41ODEtLjk2LS45ODktLjY4LS41NjQtLjQ0LTEuNTIyLS4wMzktMi4xMjcuNTMtMS4wMjIgMy4yNi0yLjMyMiAzLjEwNi0zLjk1Ni0uMDU2LS41OTQtLjE1LTEuMzY4LS43MDItMS44OTgtLjAyLjIyLjAxNy40MzIuMDE3LjQzMnMtLjIyNy0uMjg5LS4zNC0uNjgzYy0uMTEyLS4xNS0uMi0uMTk5LS4zMTktLjQtLjA4NS4yMzMtLjA3My41MDMtLjA3My41MDNzLS4xODYtLjQzNy0uMjE2LS44MDdjLS4xMS4xNjYtLjEzNy40OC0uMTM3LjQ4cy0uMjQxLS42OS0uMTg2LTEuMDYyYy0uMTEtLjMyMy0uNDM2LS45NjUtLjM0My0yLjQyNC42LjQyMSAxLjkyNC4zMjEgMi40NC0uNDM5LjE3MS0uMjUxLjI4OC0uOTM5LS4wODYtMi4yOTMtLjI0LS44NjgtLjgzNS0yLjE2LTEuMDY2LTIuNjUxbC0uMDI4LjAyYy4xMjIuMzk1LjM3NCAxLjIyMy40NyAxLjYyNS4yOTMgMS4yMTguMzcyIDEuNjQyLjIzNCAyLjIwNC0uMTE2LjQ4OC0uMzk3LjgwOC0xLjEwNyAxLjE2NS0uNzEuMzU4LTEuNjUzLS41MTQtMS43MTMtLjU2Mi0uNjktLjU1LTEuMjI0LTEuNDQ3LTEuMjg0LTEuODgzLS4wNjItLjQ3Ny4yNzUtLjc2My40NDUtMS4xNTMtLjI0My4wNy0uNTE0LjE5Mi0uNTE0LjE5MnMuMzIzLS4zMzQuNzIyLS42MjRjLjE2NS0uMTA5LjI2Mi0uMTc4LjQzNi0uMzIzYTkuNzYyIDkuNzYyIDAgMCAwLS40NTYuMDAzcy40Mi0uMjI3Ljg1NS0uMzkyYy0uMzE4LS4wMTQtLjYyMy0uMDAzLS42MjMtLjAwM3MuOTM3LS40MTkgMS42NzgtLjcyN2MuNTA5LS4yMDggMS4wMDYtLjE0NyAxLjI4Ni4yNTcuMzY3LjUzLjc1Mi44MTcgMS41NjkuOTk2LjUwMS0uMjIzLjY1My0uMzM3IDEuMjg0LS41MDkuNTU0LS42MS45OS0uNjg4Ljk5LS42ODhzLS4yMTYuMTk4LS4yNzQuNTFjLjMxNC0uMjQ5LjY2LS40NTUuNjYtLjQ1NXMtLjEzNC4xNjQtLjI1OS40MjZsLjAzLjA0M2MuMzY2LS4yMi43OTctLjM5NC43OTctLjM5NHMtLjEyMy4xNTYtLjI2OC4zNThjLjI3Ny0uMDAyLjgzOC4wMTIgMS4wNTYuMDM3IDEuMjg1LjAyOCAxLjU1Mi0xLjM3NCAyLjA0NS0xLjU1LjYxOC0uMjIuODk0LS4zNTMgMS45NDcuNjguOTAzLjg4OCAxLjYwOSAyLjQ3NyAxLjI1OSAyLjgzMy0uMjk0LjI5NS0uODc0LS4xMTUtMS41MTYtLjkxNmEzLjQ2NiAzLjQ2NiAwIDAgMS0uNzE2LTEuNTYyIDEuNTMzIDEuNTMzIDAgMCAwLS40OTctLjg1cy4yMy41MS4yMy45NmMwIC4yNDYuMDMgMS4xNjUuNDI0IDEuNjgtLjAzOS4wNzYtLjA1Ny4zNzQtLjEuNDMtLjQ1OC0uNTU0LTEuNDQzLS45NS0xLjYwNC0xLjA2Ny41NDQuNDQ1IDEuNzkzIDEuNDY4IDIuMjczIDIuNDQ5LjQ1My45MjcuMTg2IDEuNzc3LjQxNiAxLjk5Ny4wNjUuMDYzLjk3NiAxLjE5NyAxLjE1IDEuNzY3LjMwNi45OTQuMDE5IDIuMDM4LS4zODEgMi42ODVsLTEuMTE3LjE3NGMtLjE2My0uMDQ1LS4yNzMtLjA2OC0uNDItLjE1My4wOC0uMTQzLjI0MS0uNS4yNDMtLjU3MmwtLjA2My0uMTExYy0uMzQ4LjQ5Mi0uOTMuOTctMS40MTQgMS4yNDUtLjYzMy4zNTktMS4zNjMuMzA0LTEuODM4LjE1Ni0xLjM0OC0uNDE1LTIuNjIzLTEuMzI3LTIuOTMtMS41NjYgMCAwLS4wMS4xOTEuMDQ4LjIzNC4zNC4zODMgMS4xMTkgMS4wNzcgMS44NzIgMS41NmwtMS42MDUuMTc3Ljc1OSA1LjkwOGMtLjMzNy4wNDgtLjM5LjA3MS0uNzU3LjEyNC0uMzI1LTEuMTQ3LS45NDYtMS44OTUtMS42MjQtMi4zMzItLjU5OS0uMzg0LTEuNDI0LS40Ny0yLjIxNC0uMzE0bC0uMDUuMDU5YTIuODUxIDIuODUxIDAgMCAxIDEuODYzLjQ0NGMuNjU0LjQxMyAxLjE4MSAxLjQ4MSAxLjM3NSAyLjEyNC4yNDguODIyLjQyIDEuNy0uMjQ4IDIuNjMyLS40NzYuNjYyLTEuODY0IDEuMDI4LTIuOTg2LjIzNy4zLjQ4MS43MDUuODc2IDEuMjUuOTUuODA5LjExIDEuNTc3LS4wMyAyLjEwNi0uNTc0LjQ1Mi0uNDY0LjY5LTEuNDM0LjYyOC0yLjQ1NmwuNzE0LS4xMDQuMjU4IDEuODM0IDExLjgyNy0xLjQyNHpNMTUuMDUgNi44NDhjLS4wMzQuMDc1LS4wODUuMTI1LS4wMDcuMzdsLjAwNC4wMTQuMDEzLjAzMi4wMzIuMDczYy4xNC4yODcuMjk1LjU1OC41NTIuNjk2LjA2Ny0uMDExLjEzNi0uMDE5LjIwNy0uMDIzLjI0Mi0uMDEuMzk1LjAyOC40OTIuMDguMDA5LS4wNDguMDEtLjExOS4wMDUtLjIyMi0uMDE4LS4zNjQuMDcyLS45ODItLjYyNi0xLjMwOC0uMjY0LS4xMjItLjYzNC0uMDg0LS43NTcuMDY4YS4zMDIuMzAyIDAgMCAxIC4wNTguMDEzYy4xODYuMDY2LjA2LjEzLjAyNy4yMDdtMS45NTggMy4zOTJjLS4wOTItLjA1LS41Mi0uMDMtLjgyMS4wMDUtLjU3NC4wNjgtMS4xOTMuMjY3LTEuMzI4LjM3Mi0uMjQ3LjE5MS0uMTM1LjUyMy4wNDcuNjYuNTExLjM4Mi45Ni42MzggMS40MzIuNTc1LjI5LS4wMzguNTQ2LS40OTcuNzI4LS45MTQuMTI0LS4yODguMTI0LS41OTgtLjA1OC0uNjk4bS01LjA3Ny0yLjk0MmMuMTYyLS4xNTQtLjgwNS0uMzU1LTEuNTU2LjE1Ni0uNTU0LjM3OC0uNTcxIDEuMTg3LS4wNDEgMS42NDYuMDUzLjA0Ni4wOTYuMDc4LjEzNy4xMDRhNC43NyA0Ljc3IDAgMCAxIDEuMzk2LS40MTJjLjExMy0uMTI1LjI0My0uMzQ1LjIxLS43NDUtLjA0NC0uNTQyLS40NTUtLjQ1Ni0uMTQ2LS43NDkiPjwvcGF0aD48L3N2Zz4=)\n *\n * @param {Object} props - Supabase icons props and any valid SVG attribute\n * @returns {JSX.Element} JSX Element\n *\n */\nconst Datadog = createSupabaseIcon('Datadog', [\n [\n 'path',\n {\n d: 'M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749',\n key: '13qbml',\n },\n ],\n]);\n\nexport default Datadog;\n", + component: React.lazy(() => import('icons/src/icons/datadog')), + import: "import { Datadog } from 'icons'", + svg: "", + jsx: "import { Datadog } from \"icons\"\n \n " +}, { name: "edge-functions", componentName: "EdgeFunctions", @@ -64,6 +74,16 @@ export const Index: Record = [ svg: "\n \n \n \n \n", jsx: "import { EdgeFunctions } from \"icons\"\n \n " }, +{ + name: "grafana", + componentName: "Grafana", + deprecated: false, + raw: "import createSupabaseIcon from '../createSupabaseIcon';\n\n/**\n * @component @name Grafana\n * @description Supabase SVG icon component, renders SVG Element with children.\n *\n * @preview ![img](data:image/svg+xml;base64,PHN2ZyAgICBzdHJva2U9IiMwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjZmZmOyBib3JkZXItcmFkaXVzOiAycHgiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgICBzdHJva2Utd2lkdGg9IjAiCiAgICByb2xlPSJpbWciCiAgICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgICBoZWlnaHQ9IjI0IgogICAgd2lkdGg9IjI0IgogICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogID4KICAgIDxwYXRoIGQ9Ik0yMy4wMiAxMC41OWE4LjU3OCA4LjU3OCAwIDAgMC0uODYyLTMuMDM0IDguOTExIDguOTExIDAgMCAwLTEuNzg5LTIuNDQ1Yy4zMzctMS4zNDItLjQxMy0yLjUwNS0uNDEzLTIuNTA1LTEuMjkyLS4wOC0yLjExMy40LTIuNDE2LjYyLS4wNTItLjAyLS4xMDItLjA0NC0uMTU0LS4wNjQtLjIyLS4wODktLjQ0Ni0uMTcyLS42NzctLjI0Ny0uMjMxLS4wNzMtLjQ3LS4xNC0uNzExLS4xOTdhOS44NjcgOS44NjcgMCAwIDAtLjg3NS0uMTYxQzE0LjU1Ny43NTMgMTIuOTQgMCAxMi45NCAwYy0xLjgwNCAxLjE0NS0yLjE0NyAyLjc0NC0yLjE0NyAyLjc0NGwtLjAxOC4wOTNjLS4wOTguMDI5LS4yLjA1Ny0uMjk4LjA4OC0uMTM4LjA0Mi0uMjc1LjA5NC0uNDEzLjE0My0uMTM4LjA1NS0uMjc1LjEwNy0uNDEuMTY2YTguODY5IDguODY5IDAgMCAwLTEuNTU3Ljg3bC0uMDYzLS4wMjljLTIuNDk3LS45NTUtNC43MTYuMTk1LTQuNzE2LjE5NS0uMjAzIDIuNjU4Ljk5NiA0LjMzIDEuMjM1IDQuNjM2YTExLjYwOCAxMS42MDggMCAwIDAtLjYwNyAyLjYzNUMxLjYzNiAxMi42NzcuOTUzIDE1LjAxNC45NTMgMTUuMDE0YzEuOTI2IDIuMjE0IDQuMTcxIDIuMzUxIDQuMTcxIDIuMzUxLjAwMy0uMDAyLjAwNi0uMDAyLjAwNi0uMDA1LjI4NS41MDkuNjE1Ljk5NC45ODYgMS40NDYuMTU2LjE5LjMyLjM3MS40ODguNTQ4LS43MDQgMi4wMDkuMDk5IDMuNjguMDk5IDMuNjggMi4xNDQuMDggMy41NTMtLjkzNyAzLjg0OS0xLjE3M2E5Ljc4NCA5Ljc4NCAwIDAgMCAzLjE2NC41MDFoLjA4bC4wNTUtLjAwMy4xMDctLjAwMi4xMDMtLjAwNS4wMDMuMDAyYzEuMDEgMS40NCAyLjc4OCAxLjY0NiAyLjc4OCAxLjY0NiAxLjI2NC0xLjMzMiAxLjMzNy0yLjY1MyAxLjMzNy0yLjk0di0uMDU4YzAtLjAyLS4wMDMtLjAzOS0uMDAzLS4wNi4yNjUtLjE4Ny41Mi0uMzg3Ljc1OC0uNmE3Ljg3NSA3Ljg3NSAwIDAgMCAxLjQxNS0xLjdjMS40My4wODMgMi40MzctLjg4NSAyLjQzNy0uODg1LS4yMzYtMS40OS0xLjA4NS0yLjIxNi0xLjI2NC0yLjM1NGwtLjAxOC0uMDEzLS4wMTYtLjAxM2EuMjE3LjIxNyAwIDAgMS0uMDMxLS4wMmMuMDA4LS4wOTIuMDE2LS4xOC4wMi0uMjcuMDExLS4xNjIuMDE2LS4zMjMuMDE2LS40OHYtLjI1M2wtLjAwNS0uMDk4LS4wMDgtLjEzNWExLjg5MSAxLjg5MSAwIDAgMC0uMDEtLjEzYy0uMDAzLS4wNDItLjAwOC0uMDgzLS4wMTMtLjEyNWwtLjAxNi0uMTI0LS4wMTgtLjEyMmE2LjIxNSA2LjIxNSAwIDAgMC0yLjAzMi0zLjczIDYuMDE1IDYuMDE1IDAgMCAwLTMuMjIyLTEuNDYgNi4yOTIgNi4yOTIgMCAwIDAtLjg1LS4wNDhsLS4xMDcuMDAyaC0uMDYzbC0uMDQ0LjAwMy0uMTA0LjAwOGE0Ljc3NyA0Ljc3NyAwIDAgMC0zLjMzNSAxLjY5NWMtLjMzMi40LS41OTIuODQtLjc2OCAxLjI5N2E0LjU5NCA0LjU5NCAwIDAgMC0uMzEyIDEuODE3bC4wMDMuMDkxYy4wMDUuMDU1LjAwNy4xMS4wMTMuMTY0YTMuNjE1IDMuNjE1IDAgMCAwIC42OTggMS44MiAzLjUzIDMuNTMgMCAwIDAgMS44MjcgMS4yODJjLjMzLjA5OC42Ni4xNC45NzEuMTM3LjAzOSAwIC4wNzggMCAuMTE0LS4wMDJsLjA2My0uMDAzYy4wMiAwIC4wNDEtLjAwMy4wNjItLjAwMy4wMzQtLjAwMi4wNjUtLjAwNy4wOTktLjAxLjAwNyAwIC4wMTgtLjAwMy4wMjgtLjAwM2wuMDMxLS4wMDUuMDYtLjAwOGExLjE4IDEuMTggMCAwIDAgLjExMi0uMDJjLjAzNi0uMDA4LjA3Mi0uMDEzLjEwOS0uMDI0YTIuNjM0IDIuNjM0IDAgMCAwIC45MTQtLjQxNWMuMDI4LS4wMi4wNTYtLjA0MS4wODUtLjA2NWEuMjQ4LjI0OCAwIDAgMCAuMDM5LS4zNS4yNDQuMjQ0IDAgMCAwLS4zMDktLjA2bC0uMDc4LjA0MmMtLjA5LjA0NC0uMTg0LjA4My0uMjgzLjExNmEyLjQ3NiAyLjQ3NiAwIDAgMS0uNDc1LjA5NmMtLjAyOC4wMDMtLjA1NC4wMDYtLjA4My4wMDZsLS4wODMuMDAyYy0uMDI2IDAtLjA1NCAwLS4wOC0uMDAybC0uMTAyLS4wMDZoLS4wMTJsLS4wMjQuMDA2Yy0uMDE2LS4wMDMtLjAzMS0uMDAzLS4wNDQtLjAwNi0uMDMxLS4wMDItLjA2LS4wMDctLjA5MS0uMDFhMi41OSAyLjU5IDAgMCAxLS43MjQtLjIxMyAyLjU1NyAyLjU1NyAwIDAgMS0uNjY3LS40MzggMi41MiAyLjUyIDAgMCAxLS44MDUtMS40NzUgMi4zMDYgMi4zMDYgMCAwIDEtLjAyOS0uNDQ0bC4wMDYtLjEyMnYtLjAyM2wuMDAyLS4wMzFjLjAwMy0uMDIxLjAwMy0uMDQuMDA1LS4wNmEzLjE2MyAzLjE2MyAwIDAgMSAxLjM1Mi0yLjI5IDMuMTIgMy4xMiAwIDAgMSAuOTM3LS40MyAyLjk0NiAyLjk0NiAwIDAgMSAuNzc2LS4xMDFoLjA2bC4wNy4wMDIuMDQ1LjAwM2guMDI2bC4wNy4wMDVhNC4wNDEgNC4wNDEgMCAwIDEgMS42MzUuNDkgMy45NCAzLjk0IDAgMCAxIDEuNjAyIDEuNjYyIDMuNzcgMy43NyAwIDAgMSAuMzk3IDEuNDE0bC4wMDUuMDc2LjAwMy4wNzVjLjAwMi4wMjYuMDAyLjA1LjAwMi4wNzUgMCAuMDI0LjAwMy4wNTIgMCAuMDd2LjA2NWwtLjAwMi4wNzMtLjAwOC4xNzRhNi4xOTUgNi4xOTUgMCAwIDEtLjA4LjYzOSA1LjEgNS4xIDAgMCAxLS4yNjcuOTI3IDUuMzEgNS4zMSAwIDAgMS0uNjI0IDEuMTMgNS4wNTIgNS4wNTIgMCAwIDEtMy4yMzcgMi4wMTQgNC44MiA0LjgyIDAgMCAxLS42NDkuMDY2bC0uMDM5LjAwM2gtLjI4N2E2LjYwNyA2LjYwNyAwIDAgMS0xLjcxNi0uMjY1IDYuNzc2IDYuNzc2IDAgMCAxLTMuNC0yLjI3NCA2Ljc1IDYuNzUgMCAwIDEtLjc0Ni0xLjE1IDYuNjE2IDYuNjE2IDAgMCAxLS43MTQtMi41OTZsLS4wMDUtLjA4My0uMDAyLS4wMnYtLjA1NmwtLjAwMy0uMDczdi0uMDk2bC0uMDAzLS4xMDR2LS4wN2wuMDAzLS4xNjNjLjAwOC0uMjIuMDI2LS40NS4wNTQtLjY3OGE4LjcwNyA4LjcwNyAwIDAgMSAuMjgtMS4zNTVjLjEyOC0uNDQ0LjI4Ni0uODcyLjQ3My0xLjI3N2E3LjA0IDcuMDQgMCAwIDEgMS40NTYtMi4xIDUuOTI1IDUuOTI1IDAgMCAxIC45NTMtLjc2M2MuMTY5LS4xMTEuMzQzLS4yMTMuNTI0LS4zMDYuMDg5LS4wNS4xODItLjA5MS4yNzMtLjEzNS4wNDctLjAyLjA5My0uMDQyLjEzOC0uMDYyYTcuMTc3IDcuMTc3IDAgMCAxIC43MTQtLjI2N2wuMTQ1LS4wNDVjLjA0OS0uMDE1LjA5OC0uMDI2LjE0OC0uMDQxLjA5OC0uMDI5LjE5Ny0uMDUyLjI5Ni0uMDc2LjA0OS0uMDEzLjEtLjAyLjE1LS4wMzNsLjE1LS4wMzIuMTUxLS4wMjguMDc2LS4wMTMuMDc1LS4wMS4xNTMtLjAyNGMuMDU3LS4wMS4xMTQtLjAxMy4xNzEtLjAyM2wuMTY5LS4wMjFjLjAzNi0uMDAzLjA3My0uMDA4LjEwNi0uMDFsLjA3My0uMDA4LjAzNi0uMDAzLjA0Mi0uMDAyYy4wNTctLjAwMy4xMTQtLjAwOC4xNzEtLjAxbC4wODYtLjAwNmguMDIzbC4wMzctLjAwMy4xNDUtLjAwN2E3Ljk5OSA3Ljk5OSAwIDAgMSAxLjcwOC4xMjUgNy45MTcgNy45MTcgMCAwIDEgMi4wNDguNjggOC4yNTMgOC4yNTMgMCAwIDEgMS42NzIgMS4wOWwuMDkuMDc3LjA4OS4wNzhjLjA2LjA1Mi4xMTQuMTA3LjE3MS4xNTkuMDU3LjA1Mi4xMTIuMTA2LjE2Ni4xNi4wNTIuMDU1LjEwNy4xMDcuMTU5LjE2NGE4LjY3MSA4LjY3MSAwIDAgMSAxLjQxIDEuOTc4Yy4wMTIuMDI2LjAyOC4wNTIuMDQuMDc4bC4wNC4wNzguMDc1LjE1NmMuMDIzLjA1MS4wNS4xLjA3LjE1M2wuMDY1LjE1YTguODQ4IDguODQ4IDAgMCAxIC40NSAxLjM0LjE5LjE5IDAgMCAwIC4yMDEuMTQyLjE4Ni4xODYgMCAwIDAgLjE3Mi0uMTg0Yy4wMS0uMjQ2LjAwMi0uNTMyLS4wMjQtLjg1NnoiPjwvcGF0aD4KICA8L3N2Zz4=)\n *\n * @param {Object} props - Supabase icons props and any valid SVG attribute\n * @returns {JSX.Element} JSX Element\n *\n */\nconst Grafana = createSupabaseIcon('Grafana', [\n [\n 'path',\n {\n d: 'M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z',\n key: 'hopart',\n },\n ],\n]);\n\nexport default Grafana;\n", + component: React.lazy(() => import('icons/src/icons/grafana')), + import: "import { Grafana } from 'icons'", + svg: "\n \n ", + jsx: "import { Grafana } from \"icons\"\n \n " +}, { name: "home", componentName: "Home", diff --git a/packages/icons/src/icons/datadog.ts b/packages/icons/src/icons/datadog.ts new file mode 100644 index 00000000000..bdce8a6abae --- /dev/null +++ b/packages/icons/src/icons/datadog.ts @@ -0,0 +1,23 @@ +import createSupabaseIcon from '../createSupabaseIcon'; + +/** + * @component @name Datadog + * @description Supabase SVG icon component, renders SVG Element with children. + * + * @preview ![img](data:image/svg+xml;base64,PHN2ZyBzdHJva2U9IiMwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjZmZmOyBib3JkZXItcmFkaXVzOiAycHgiIGZpbGw9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIwIiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgaGVpZ2h0PSIyNCIgd2lkdGg9IjI0IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0xOS41NyAxNy4wNGwtMS45OTctMS4zMTYtMS42NjUgMi43ODItMS45MzctLjU2Ny0xLjcwNiAyLjYwNC4wODcuODIgOS4yNzQtMS43MS0uNTM4LTUuNzk0em0tOC42NDktMi40OThsMS40ODgtLjIwNGMuMjQxLjEwOC40MDkuMTUuNjk3LjIyMy40NS4xMTcuOTcuMjMgMS43NDEtLjE2LjE4LS4wODguNTUzLS40My43MDQtLjYyNWw2LjA5Ni0xLjEwNi42MjIgNy41MjctMTAuNDQ0IDEuODgyem0xMS4zMjUtMi43MTJsLS42MDIuMTE1TDIwLjQ4OCAwIC43ODkgMi4yODVsMi40MjcgMTkuNjkzIDIuMzA2LS4zMzRjLS4xODQtLjI2My0uNDcxLS41ODEtLjk2LS45ODktLjY4LS41NjQtLjQ0LTEuNTIyLS4wMzktMi4xMjcuNTMtMS4wMjIgMy4yNi0yLjMyMiAzLjEwNi0zLjk1Ni0uMDU2LS41OTQtLjE1LTEuMzY4LS43MDItMS44OTgtLjAyLjIyLjAxNy40MzIuMDE3LjQzMnMtLjIyNy0uMjg5LS4zNC0uNjgzYy0uMTEyLS4xNS0uMi0uMTk5LS4zMTktLjQtLjA4NS4yMzMtLjA3My41MDMtLjA3My41MDNzLS4xODYtLjQzNy0uMjE2LS44MDdjLS4xMS4xNjYtLjEzNy40OC0uMTM3LjQ4cy0uMjQxLS42OS0uMTg2LTEuMDYyYy0uMTEtLjMyMy0uNDM2LS45NjUtLjM0My0yLjQyNC42LjQyMSAxLjkyNC4zMjEgMi40NC0uNDM5LjE3MS0uMjUxLjI4OC0uOTM5LS4wODYtMi4yOTMtLjI0LS44NjgtLjgzNS0yLjE2LTEuMDY2LTIuNjUxbC0uMDI4LjAyYy4xMjIuMzk1LjM3NCAxLjIyMy40NyAxLjYyNS4yOTMgMS4yMTguMzcyIDEuNjQyLjIzNCAyLjIwNC0uMTE2LjQ4OC0uMzk3LjgwOC0xLjEwNyAxLjE2NS0uNzEuMzU4LTEuNjUzLS41MTQtMS43MTMtLjU2Mi0uNjktLjU1LTEuMjI0LTEuNDQ3LTEuMjg0LTEuODgzLS4wNjItLjQ3Ny4yNzUtLjc2My40NDUtMS4xNTMtLjI0My4wNy0uNTE0LjE5Mi0uNTE0LjE5MnMuMzIzLS4zMzQuNzIyLS42MjRjLjE2NS0uMTA5LjI2Mi0uMTc4LjQzNi0uMzIzYTkuNzYyIDkuNzYyIDAgMCAwLS40NTYuMDAzcy40Mi0uMjI3Ljg1NS0uMzkyYy0uMzE4LS4wMTQtLjYyMy0uMDAzLS42MjMtLjAwM3MuOTM3LS40MTkgMS42NzgtLjcyN2MuNTA5LS4yMDggMS4wMDYtLjE0NyAxLjI4Ni4yNTcuMzY3LjUzLjc1Mi44MTcgMS41NjkuOTk2LjUwMS0uMjIzLjY1My0uMzM3IDEuMjg0LS41MDkuNTU0LS42MS45OS0uNjg4Ljk5LS42ODhzLS4yMTYuMTk4LS4yNzQuNTFjLjMxNC0uMjQ5LjY2LS40NTUuNjYtLjQ1NXMtLjEzNC4xNjQtLjI1OS40MjZsLjAzLjA0M2MuMzY2LS4yMi43OTctLjM5NC43OTctLjM5NHMtLjEyMy4xNTYtLjI2OC4zNThjLjI3Ny0uMDAyLjgzOC4wMTIgMS4wNTYuMDM3IDEuMjg1LjAyOCAxLjU1Mi0xLjM3NCAyLjA0NS0xLjU1LjYxOC0uMjIuODk0LS4zNTMgMS45NDcuNjguOTAzLjg4OCAxLjYwOSAyLjQ3NyAxLjI1OSAyLjgzMy0uMjk0LjI5NS0uODc0LS4xMTUtMS41MTYtLjkxNmEzLjQ2NiAzLjQ2NiAwIDAgMS0uNzE2LTEuNTYyIDEuNTMzIDEuNTMzIDAgMCAwLS40OTctLjg1cy4yMy41MS4yMy45NmMwIC4yNDYuMDMgMS4xNjUuNDI0IDEuNjgtLjAzOS4wNzYtLjA1Ny4zNzQtLjEuNDMtLjQ1OC0uNTU0LTEuNDQzLS45NS0xLjYwNC0xLjA2Ny41NDQuNDQ1IDEuNzkzIDEuNDY4IDIuMjczIDIuNDQ5LjQ1My45MjcuMTg2IDEuNzc3LjQxNiAxLjk5Ny4wNjUuMDYzLjk3NiAxLjE5NyAxLjE1IDEuNzY3LjMwNi45OTQuMDE5IDIuMDM4LS4zODEgMi42ODVsLTEuMTE3LjE3NGMtLjE2My0uMDQ1LS4yNzMtLjA2OC0uNDItLjE1My4wOC0uMTQzLjI0MS0uNS4yNDMtLjU3MmwtLjA2My0uMTExYy0uMzQ4LjQ5Mi0uOTMuOTctMS40MTQgMS4yNDUtLjYzMy4zNTktMS4zNjMuMzA0LTEuODM4LjE1Ni0xLjM0OC0uNDE1LTIuNjIzLTEuMzI3LTIuOTMtMS41NjYgMCAwLS4wMS4xOTEuMDQ4LjIzNC4zNC4zODMgMS4xMTkgMS4wNzcgMS44NzIgMS41NmwtMS42MDUuMTc3Ljc1OSA1LjkwOGMtLjMzNy4wNDgtLjM5LjA3MS0uNzU3LjEyNC0uMzI1LTEuMTQ3LS45NDYtMS44OTUtMS42MjQtMi4zMzItLjU5OS0uMzg0LTEuNDI0LS40Ny0yLjIxNC0uMzE0bC0uMDUuMDU5YTIuODUxIDIuODUxIDAgMCAxIDEuODYzLjQ0NGMuNjU0LjQxMyAxLjE4MSAxLjQ4MSAxLjM3NSAyLjEyNC4yNDguODIyLjQyIDEuNy0uMjQ4IDIuNjMyLS40NzYuNjYyLTEuODY0IDEuMDI4LTIuOTg2LjIzNy4zLjQ4MS43MDUuODc2IDEuMjUuOTUuODA5LjExIDEuNTc3LS4wMyAyLjEwNi0uNTc0LjQ1Mi0uNDY0LjY5LTEuNDM0LjYyOC0yLjQ1NmwuNzE0LS4xMDQuMjU4IDEuODM0IDExLjgyNy0xLjQyNHpNMTUuMDUgNi44NDhjLS4wMzQuMDc1LS4wODUuMTI1LS4wMDcuMzdsLjAwNC4wMTQuMDEzLjAzMi4wMzIuMDczYy4xNC4yODcuMjk1LjU1OC41NTIuNjk2LjA2Ny0uMDExLjEzNi0uMDE5LjIwNy0uMDIzLjI0Mi0uMDEuMzk1LjAyOC40OTIuMDguMDA5LS4wNDguMDEtLjExOS4wMDUtLjIyMi0uMDE4LS4zNjQuMDcyLS45ODItLjYyNi0xLjMwOC0uMjY0LS4xMjItLjYzNC0uMDg0LS43NTcuMDY4YS4zMDIuMzAyIDAgMCAxIC4wNTguMDEzYy4xODYuMDY2LjA2LjEzLjAyNy4yMDdtMS45NTggMy4zOTJjLS4wOTItLjA1LS41Mi0uMDMtLjgyMS4wMDUtLjU3NC4wNjgtMS4xOTMuMjY3LTEuMzI4LjM3Mi0uMjQ3LjE5MS0uMTM1LjUyMy4wNDcuNjYuNTExLjM4Mi45Ni42MzggMS40MzIuNTc1LjI5LS4wMzguNTQ2LS40OTcuNzI4LS45MTQuMTI0LS4yODguMTI0LS41OTgtLjA1OC0uNjk4bS01LjA3Ny0yLjk0MmMuMTYyLS4xNTQtLjgwNS0uMzU1LTEuNTU2LjE1Ni0uNTU0LjM3OC0uNTcxIDEuMTg3LS4wNDEgMS42NDYuMDUzLjA0Ni4wOTYuMDc4LjEzNy4xMDRhNC43NyA0Ljc3IDAgMCAxIDEuMzk2LS40MTJjLjExMy0uMTI1LjI0My0uMzQ1LjIxLS43NDUtLjA0NC0uNTQyLS40NTUtLjQ1Ni0uMTQ2LS43NDkiPjwvcGF0aD48L3N2Zz4=) + * + * @param {Object} props - Supabase icons props and any valid SVG attribute + * @returns {JSX.Element} JSX Element + * + */ +const Datadog = createSupabaseIcon('Datadog', [ + [ + 'path', + { + d: 'M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749', + key: '13qbml', + }, + ], +]); + +export default Datadog; diff --git a/packages/icons/src/icons/grafana.ts b/packages/icons/src/icons/grafana.ts new file mode 100644 index 00000000000..25c9cfdf25b --- /dev/null +++ b/packages/icons/src/icons/grafana.ts @@ -0,0 +1,23 @@ +import createSupabaseIcon from '../createSupabaseIcon'; + +/** + * @component @name Grafana + * @description Supabase SVG icon component, renders SVG Element with children. + * + * @preview ![img](data:image/svg+xml;base64,PHN2ZyAgICBzdHJva2U9IiMwMDAiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiAjZmZmOyBib3JkZXItcmFkaXVzOiAycHgiCiAgICBmaWxsPSJjdXJyZW50Q29sb3IiCiAgICBzdHJva2Utd2lkdGg9IjAiCiAgICByb2xlPSJpbWciCiAgICB2aWV3Qm94PSIwIDAgMjQgMjQiCiAgICBoZWlnaHQ9IjI0IgogICAgd2lkdGg9IjI0IgogICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogID4KICAgIDxwYXRoIGQ9Ik0yMy4wMiAxMC41OWE4LjU3OCA4LjU3OCAwIDAgMC0uODYyLTMuMDM0IDguOTExIDguOTExIDAgMCAwLTEuNzg5LTIuNDQ1Yy4zMzctMS4zNDItLjQxMy0yLjUwNS0uNDEzLTIuNTA1LTEuMjkyLS4wOC0yLjExMy40LTIuNDE2LjYyLS4wNTItLjAyLS4xMDItLjA0NC0uMTU0LS4wNjQtLjIyLS4wODktLjQ0Ni0uMTcyLS42NzctLjI0Ny0uMjMxLS4wNzMtLjQ3LS4xNC0uNzExLS4xOTdhOS44NjcgOS44NjcgMCAwIDAtLjg3NS0uMTYxQzE0LjU1Ny43NTMgMTIuOTQgMCAxMi45NCAwYy0xLjgwNCAxLjE0NS0yLjE0NyAyLjc0NC0yLjE0NyAyLjc0NGwtLjAxOC4wOTNjLS4wOTguMDI5LS4yLjA1Ny0uMjk4LjA4OC0uMTM4LjA0Mi0uMjc1LjA5NC0uNDEzLjE0My0uMTM4LjA1NS0uMjc1LjEwNy0uNDEuMTY2YTguODY5IDguODY5IDAgMCAwLTEuNTU3Ljg3bC0uMDYzLS4wMjljLTIuNDk3LS45NTUtNC43MTYuMTk1LTQuNzE2LjE5NS0uMjAzIDIuNjU4Ljk5NiA0LjMzIDEuMjM1IDQuNjM2YTExLjYwOCAxMS42MDggMCAwIDAtLjYwNyAyLjYzNUMxLjYzNiAxMi42NzcuOTUzIDE1LjAxNC45NTMgMTUuMDE0YzEuOTI2IDIuMjE0IDQuMTcxIDIuMzUxIDQuMTcxIDIuMzUxLjAwMy0uMDAyLjAwNi0uMDAyLjAwNi0uMDA1LjI4NS41MDkuNjE1Ljk5NC45ODYgMS40NDYuMTU2LjE5LjMyLjM3MS40ODguNTQ4LS43MDQgMi4wMDkuMDk5IDMuNjguMDk5IDMuNjggMi4xNDQuMDggMy41NTMtLjkzNyAzLjg0OS0xLjE3M2E5Ljc4NCA5Ljc4NCAwIDAgMCAzLjE2NC41MDFoLjA4bC4wNTUtLjAwMy4xMDctLjAwMi4xMDMtLjAwNS4wMDMuMDAyYzEuMDEgMS40NCAyLjc4OCAxLjY0NiAyLjc4OCAxLjY0NiAxLjI2NC0xLjMzMiAxLjMzNy0yLjY1MyAxLjMzNy0yLjk0di0uMDU4YzAtLjAyLS4wMDMtLjAzOS0uMDAzLS4wNi4yNjUtLjE4Ny41Mi0uMzg3Ljc1OC0uNmE3Ljg3NSA3Ljg3NSAwIDAgMCAxLjQxNS0xLjdjMS40My4wODMgMi40MzctLjg4NSAyLjQzNy0uODg1LS4yMzYtMS40OS0xLjA4NS0yLjIxNi0xLjI2NC0yLjM1NGwtLjAxOC0uMDEzLS4wMTYtLjAxM2EuMjE3LjIxNyAwIDAgMS0uMDMxLS4wMmMuMDA4LS4wOTIuMDE2LS4xOC4wMi0uMjcuMDExLS4xNjIuMDE2LS4zMjMuMDE2LS40OHYtLjI1M2wtLjAwNS0uMDk4LS4wMDgtLjEzNWExLjg5MSAxLjg5MSAwIDAgMC0uMDEtLjEzYy0uMDAzLS4wNDItLjAwOC0uMDgzLS4wMTMtLjEyNWwtLjAxNi0uMTI0LS4wMTgtLjEyMmE2LjIxNSA2LjIxNSAwIDAgMC0yLjAzMi0zLjczIDYuMDE1IDYuMDE1IDAgMCAwLTMuMjIyLTEuNDYgNi4yOTIgNi4yOTIgMCAwIDAtLjg1LS4wNDhsLS4xMDcuMDAyaC0uMDYzbC0uMDQ0LjAwMy0uMTA0LjAwOGE0Ljc3NyA0Ljc3NyAwIDAgMC0zLjMzNSAxLjY5NWMtLjMzMi40LS41OTIuODQtLjc2OCAxLjI5N2E0LjU5NCA0LjU5NCAwIDAgMC0uMzEyIDEuODE3bC4wMDMuMDkxYy4wMDUuMDU1LjAwNy4xMS4wMTMuMTY0YTMuNjE1IDMuNjE1IDAgMCAwIC42OTggMS44MiAzLjUzIDMuNTMgMCAwIDAgMS44MjcgMS4yODJjLjMzLjA5OC42Ni4xNC45NzEuMTM3LjAzOSAwIC4wNzggMCAuMTE0LS4wMDJsLjA2My0uMDAzYy4wMiAwIC4wNDEtLjAwMy4wNjItLjAwMy4wMzQtLjAwMi4wNjUtLjAwNy4wOTktLjAxLjAwNyAwIC4wMTgtLjAwMy4wMjgtLjAwM2wuMDMxLS4wMDUuMDYtLjAwOGExLjE4IDEuMTggMCAwIDAgLjExMi0uMDJjLjAzNi0uMDA4LjA3Mi0uMDEzLjEwOS0uMDI0YTIuNjM0IDIuNjM0IDAgMCAwIC45MTQtLjQxNWMuMDI4LS4wMi4wNTYtLjA0MS4wODUtLjA2NWEuMjQ4LjI0OCAwIDAgMCAuMDM5LS4zNS4yNDQuMjQ0IDAgMCAwLS4zMDktLjA2bC0uMDc4LjA0MmMtLjA5LjA0NC0uMTg0LjA4My0uMjgzLjExNmEyLjQ3NiAyLjQ3NiAwIDAgMS0uNDc1LjA5NmMtLjAyOC4wMDMtLjA1NC4wMDYtLjA4My4wMDZsLS4wODMuMDAyYy0uMDI2IDAtLjA1NCAwLS4wOC0uMDAybC0uMTAyLS4wMDZoLS4wMTJsLS4wMjQuMDA2Yy0uMDE2LS4wMDMtLjAzMS0uMDAzLS4wNDQtLjAwNi0uMDMxLS4wMDItLjA2LS4wMDctLjA5MS0uMDFhMi41OSAyLjU5IDAgMCAxLS43MjQtLjIxMyAyLjU1NyAyLjU1NyAwIDAgMS0uNjY3LS40MzggMi41MiAyLjUyIDAgMCAxLS44MDUtMS40NzUgMi4zMDYgMi4zMDYgMCAwIDEtLjAyOS0uNDQ0bC4wMDYtLjEyMnYtLjAyM2wuMDAyLS4wMzFjLjAwMy0uMDIxLjAwMy0uMDQuMDA1LS4wNmEzLjE2MyAzLjE2MyAwIDAgMSAxLjM1Mi0yLjI5IDMuMTIgMy4xMiAwIDAgMSAuOTM3LS40MyAyLjk0NiAyLjk0NiAwIDAgMSAuNzc2LS4xMDFoLjA2bC4wNy4wMDIuMDQ1LjAwM2guMDI2bC4wNy4wMDVhNC4wNDEgNC4wNDEgMCAwIDEgMS42MzUuNDkgMy45NCAzLjk0IDAgMCAxIDEuNjAyIDEuNjYyIDMuNzcgMy43NyAwIDAgMSAuMzk3IDEuNDE0bC4wMDUuMDc2LjAwMy4wNzVjLjAwMi4wMjYuMDAyLjA1LjAwMi4wNzUgMCAuMDI0LjAwMy4wNTIgMCAuMDd2LjA2NWwtLjAwMi4wNzMtLjAwOC4xNzRhNi4xOTUgNi4xOTUgMCAwIDEtLjA4LjYzOSA1LjEgNS4xIDAgMCAxLS4yNjcuOTI3IDUuMzEgNS4zMSAwIDAgMS0uNjI0IDEuMTMgNS4wNTIgNS4wNTIgMCAwIDEtMy4yMzcgMi4wMTQgNC44MiA0LjgyIDAgMCAxLS42NDkuMDY2bC0uMDM5LjAwM2gtLjI4N2E2LjYwNyA2LjYwNyAwIDAgMS0xLjcxNi0uMjY1IDYuNzc2IDYuNzc2IDAgMCAxLTMuNC0yLjI3NCA2Ljc1IDYuNzUgMCAwIDEtLjc0Ni0xLjE1IDYuNjE2IDYuNjE2IDAgMCAxLS43MTQtMi41OTZsLS4wMDUtLjA4My0uMDAyLS4wMnYtLjA1NmwtLjAwMy0uMDczdi0uMDk2bC0uMDAzLS4xMDR2LS4wN2wuMDAzLS4xNjNjLjAwOC0uMjIuMDI2LS40NS4wNTQtLjY3OGE4LjcwNyA4LjcwNyAwIDAgMSAuMjgtMS4zNTVjLjEyOC0uNDQ0LjI4Ni0uODcyLjQ3My0xLjI3N2E3LjA0IDcuMDQgMCAwIDEgMS40NTYtMi4xIDUuOTI1IDUuOTI1IDAgMCAxIC45NTMtLjc2M2MuMTY5LS4xMTEuMzQzLS4yMTMuNTI0LS4zMDYuMDg5LS4wNS4xODItLjA5MS4yNzMtLjEzNS4wNDctLjAyLjA5My0uMDQyLjEzOC0uMDYyYTcuMTc3IDcuMTc3IDAgMCAxIC43MTQtLjI2N2wuMTQ1LS4wNDVjLjA0OS0uMDE1LjA5OC0uMDI2LjE0OC0uMDQxLjA5OC0uMDI5LjE5Ny0uMDUyLjI5Ni0uMDc2LjA0OS0uMDEzLjEtLjAyLjE1LS4wMzNsLjE1LS4wMzIuMTUxLS4wMjguMDc2LS4wMTMuMDc1LS4wMS4xNTMtLjAyNGMuMDU3LS4wMS4xMTQtLjAxMy4xNzEtLjAyM2wuMTY5LS4wMjFjLjAzNi0uMDAzLjA3My0uMDA4LjEwNi0uMDFsLjA3My0uMDA4LjAzNi0uMDAzLjA0Mi0uMDAyYy4wNTctLjAwMy4xMTQtLjAwOC4xNzEtLjAxbC4wODYtLjAwNmguMDIzbC4wMzctLjAwMy4xNDUtLjAwN2E3Ljk5OSA3Ljk5OSAwIDAgMSAxLjcwOC4xMjUgNy45MTcgNy45MTcgMCAwIDEgMi4wNDguNjggOC4yNTMgOC4yNTMgMCAwIDEgMS42NzIgMS4wOWwuMDkuMDc3LjA4OS4wNzhjLjA2LjA1Mi4xMTQuMTA3LjE3MS4xNTkuMDU3LjA1Mi4xMTIuMTA2LjE2Ni4xNi4wNTIuMDU1LjEwNy4xMDcuMTU5LjE2NGE4LjY3MSA4LjY3MSAwIDAgMSAxLjQxIDEuOTc4Yy4wMTIuMDI2LjAyOC4wNTIuMDQuMDc4bC4wNC4wNzguMDc1LjE1NmMuMDIzLjA1MS4wNS4xLjA3LjE1M2wuMDY1LjE1YTguODQ4IDguODQ4IDAgMCAxIC40NSAxLjM0LjE5LjE5IDAgMCAwIC4yMDEuMTQyLjE4Ni4xODYgMCAwIDAgLjE3Mi0uMTg0Yy4wMS0uMjQ2LjAwMi0uNTMyLS4wMjQtLjg1NnoiPjwvcGF0aD4KICA8L3N2Zz4=) + * + * @param {Object} props - Supabase icons props and any valid SVG attribute + * @returns {JSX.Element} JSX Element + * + */ +const Grafana = createSupabaseIcon('Grafana', [ + [ + 'path', + { + d: 'M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z', + key: 'hopart', + }, + ], +]); + +export default Grafana; diff --git a/packages/icons/src/icons/index.ts b/packages/icons/src/icons/index.ts index 55051580381..4eab2e678fa 100644 --- a/packages/icons/src/icons/index.ts +++ b/packages/icons/src/icons/index.ts @@ -1,19 +1,22 @@ -export { default as RESTApi } from './REST-api' -export { default as ExampleTemplate } from './_example-template' -export { default as ApiDocs } from './api-docs' -export { default as Auth } from './auth' -export { default as Database } from './database' -export { default as EdgeFunctions } from './edge-functions' -export { default as Home } from './home' -export { default as InsertCode } from './insert-code' -export { default as Integrations } from './integrations' -export { default as Logs } from './logs' -export { default as Postgres } from './postgres' -export { default as Realtime } from './realtime' -export { default as ReplaceCode } from './replace-code' -export { default as Reports } from './reports' -export { default as Settings } from './settings' -export { default as SqlEditor } from './sql-editor' -export { default as Storage } from './storage' -export { default as TableEditor } from './table-editor' -export { default as User } from './user' +export { default as RESTApi } from './REST-api'; +export { default as ExampleTemplate } from './_example-template'; +export { default as ApiDocs } from './api-docs'; +export { default as Auth } from './auth'; +export { default as Database } from './database'; +export { default as Datadog } from './datadog'; +export { default as EdgeFunctions } from './edge-functions'; +export { default as Grafana } from './grafana'; +export { default as Home } from './home'; +export { default as InsertCode } from './insert-code'; +export { default as Integrations } from './integrations'; +export { default as Logs } from './logs'; +export { default as Postgres } from './postgres'; +export { default as Realtime } from './realtime'; +export { default as ReplaceCode } from './replace-code'; +export { default as Reports } from './reports'; +export { default as Settings } from './settings'; +export { default as SqlEditor } from './sql-editor'; +export { default as Storage } from './storage'; +export { default as TableEditor } from './table-editor'; +export { default as User } from './user'; + diff --git a/packages/icons/src/raw-icons/datadog.svg b/packages/icons/src/raw-icons/datadog.svg new file mode 100644 index 00000000000..fb0a6f9b643 --- /dev/null +++ b/packages/icons/src/raw-icons/datadog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/raw-icons/grafana.svg b/packages/icons/src/raw-icons/grafana.svg new file mode 100644 index 00000000000..d82a970009c --- /dev/null +++ b/packages/icons/src/raw-icons/grafana.svg @@ -0,0 +1,12 @@ + + + \ No newline at end of file From e6c249d9b506a1cffb72831ed3e6df3cf26dc131 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 20:02:20 +0800 Subject: [PATCH 094/255] Shift all uses of project api query to project settings v2 query, and deprecate project api query (#30029) * Add perms check in projects API query * Start deprecating use of projects-api-query, move to projects-settings-v2-query * PRETTY * Shift all uses of project api query to project settings v2 query, and deprecate project api query * Fix * Fix * Small unrelated styling fix on join page * Fix * Update apps/studio/components/interfaces/Auth/Users/UserOverview.tsx Co-authored-by: Alaister Young --------- Co-authored-by: Alaister Young --- .../FunctionsAssistantPreview.tsx | 5 +- .../Auth/AuthProvidersForm/ProviderForm.tsx | 10 +-- .../interfaces/Auth/Users/UserOverview.tsx | 18 ++++-- .../Database/Hooks/HTTPRequestFields.tsx | 13 ++-- .../interfaces/Docs/Introduction.tsx | 3 +- .../EdgeFunctionDetails.tsx | 18 +++--- .../Functions/TerminalInstructions.tsx | 13 ++-- .../Home/NewProjectPanel/APIKeys.tsx | 4 +- .../CronJobs/HttpHeaderFieldsSection.tsx | 9 +-- .../OrganizationInviteError.tsx | 6 +- .../ProjectAPIDocs/ProjectAPIDocs.tsx | 9 +-- .../Inspector/RealtimeTokensPopover/index.tsx | 21 +++---- .../Realtime/Inspector/useRealtimeMessages.ts | 8 +-- .../interfaces/Settings/API/ServiceList.tsx | 6 +- .../CustomDomainActivate.tsx | 8 +-- .../CustomDomainConfig/CustomDomainConfig.tsx | 14 ++--- .../CustomDomainConfig/CustomDomainVerify.tsx | 12 ++-- .../CustomDomainsConfigureHostname.tsx | 8 +-- .../StorageSettings/StorageSettings.utils.ts | 1 - apps/studio/data/config/project-api-query.ts | 62 ------------------- .../[slug]/deploy-button/new-project.tsx | 8 +-- 21 files changed, 97 insertions(+), 159 deletions(-) delete mode 100644 apps/studio/data/config/project-api-query.ts diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx index 3b48059d6bd..513715133e7 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx @@ -1,8 +1,9 @@ +import Image from 'next/image' + import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import { detectOS } from 'lib/helpers' -import Image from 'next/image' export const FunctionsAssistantPreview = () => { const os = detectOS() @@ -32,7 +33,7 @@ export const FunctionsAssistantPreview = () => { feature preview proves itself to benefit most of our users, so as usual please do feel free to let us know what you think if the attached GitHub discussion above!

    -
    +

    Enabling this preview will:

    • diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 69fa28ff76a..57fe2333163 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -1,18 +1,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Check, ChevronUp, ExternalLink } from 'lucide-react' +import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' -import Link from 'next/link' import { Alert, Alert_Shadcn_, @@ -109,8 +109,8 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { } } - const { data: settings } = useProjectApiQuery({ projectRef }) - const apiUrl = `${settings?.autoApiService.protocol}://${settings?.autoApiService.endpoint}` + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const apiUrl = `https://${settings?.app_config?.endpoint}` const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 0facbc23093..692ba172806 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -16,7 +16,7 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation' import { useUserUpdateMutation } from 'data/auth/user-update-mutation' import { User } from 'data/auth/users-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { timeout } from 'lib/helpers' @@ -76,7 +76,8 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false) const { data } = useAuthConfigQuery({ projectRef }) - const { data: apiData } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0 const minutes = Math.floor(mailerOtpExpiry / 60) const seconds = Math.floor(mailerOtpExpiry % 60) @@ -142,16 +143,23 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { } const handleUnban = () => { - if (!apiData) { + if (!settings) { return toast.error(`Failed to ban user: Error loading project config`) } else if (user.id === undefined) { return toast.error(`Failed to ban user: User ID not found`) } - const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + const endpoint = settings.app_config?.endpoint + const serviceApiKey = (settings?.service_api_keys ?? []).find( + (key) => key.tags === 'service_role' + )?.api_key + + if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`) + if (!serviceApiKey) return toast.error(`Failed to unban user: Unable to retrieve API key`) + updateUser({ projectRef, - protocol, + protocol: 'https', endpoint, serviceApiKey, userId: user.id, diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index 53103b49630..b0077027af9 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -1,10 +1,11 @@ import clsx from 'clsx' +import { ChevronDown, Plus, Trash } from 'lucide-react' import Link from 'next/link' -import { useParams } from 'common/hooks' +import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -19,7 +20,6 @@ import { SidePanel, } from 'ui' import { HTTPArgument } from './EditHookPanel' -import { Trash, Plus, ChevronDown } from 'lucide-react' interface HTTPRequestFieldsProps { type: 'http_request' | 'supabase_function' @@ -48,12 +48,13 @@ const HTTPRequestFields = ({ }: HTTPRequestFieldsProps) => { const { project: selectedProject } = useProjectContext() const { ref } = useParams() - const { data: settings } = useProjectApiQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) const edgeFunctions = functions ?? [] - const apiService = settings?.autoApiService - const apiKey = apiService?.serviceApiKey ?? '[YOUR API KEY]' + const apiKey = + (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? + '[YOUR API KEY]' return ( <> diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index b274bc6f388..ecff98bb356 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -1,11 +1,10 @@ import { useParams } from 'common' import Snippets from 'components/interfaces/Docs/Snippets' -import type { AutoApiService } from 'data/config/project-api-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' interface Props { selectedLang: 'bash' | 'js' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 73bb231b853..671ff7657c1 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import clsx from 'clsx' -import { useParams } from 'common' import dayjs from 'dayjs' import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react' import Link from 'next/link' @@ -8,12 +7,13 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormActions } from 'components/ui/Forms/FormActions' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation' @@ -39,7 +39,7 @@ const EdgeFunctionDetails = () => { const [showDeleteModal, setShowDeleteModal] = useState(false) const [showInstructions, setShowInstructions] = useState(false) - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug }) const { mutate: updateEdgeFunction, isLoading: isUpdating } = useEdgeFunctionUpdateMutation() @@ -53,15 +53,15 @@ const EdgeFunctionDetails = () => { const formId = 'edge-function-update-form' const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') - // Get the API service - const apiService = settings?.autoApiService - const anonKey = apiService?.defaultApiKey ?? '[YOUR ANON KEY]' + const anonKey = + (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? + '[YOUR ANON KEY]' - const endpoint = apiService?.app_config.endpoint ?? '' + const endpoint = settings?.app_config?.endpoint ?? '' const functionUrl = customDomainData?.customDomain?.status === 'active' - ? `${apiService?.protocol}://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}` - : `${apiService?.protocol}://${endpoint}/functions/v1/${selectedFunction?.slug}` + ? `https://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}` + : `https://${endpoint}/functions/v1/${selectedFunction?.slug}` const { managementCommands, secretCommands, invokeCommands } = generateCLICommands( selectedFunction, diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index 6ae3158ed18..0ce1ee4ad30 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -5,7 +5,7 @@ import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from 'reac import { useParams } from 'common' import CommandRender from 'components/interfaces/Functions/CommandRender' import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { Button, @@ -29,19 +29,20 @@ const TerminalInstructions = forwardRef< const [showInstructions, setShowInstructions] = useState(!closable) const { data: tokens } = useAccessTokensQuery() - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const apiService = settings?.autoApiService - const anonKey = apiService?.defaultApiKey ?? '[YOUR ANON KEY]' - const endpoint = settings?.autoApiService.app_config?.endpoint ?? '' + const anonKey = + (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? + '[YOUR ANON KEY]' + const endpoint = settings?.app_config?.endpoint ?? '' const functionsEndpoint = customDomainData?.customDomain?.status === 'active' ? `https://${customDomainData.customDomain.hostname}/functions/v1` : `https://${endpoint}/functions/v1` // get the .co or .net TLD from the restUrl - const restUrl = settings?.autoApiService.restUrl + const restUrl = `https://${endpoint}` const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' const commands: Commands[] = [ diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index afccbce6923..bfbee79906d 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -1,15 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { AlertCircle, Loader } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' -import { useParams } from 'common/hooks' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { AlertCircle, Loader } from 'lucide-react' import { Input } from 'ui' const generateInitSnippet = (endpoint: string) => ({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index c44d3bc3eba..cc3c1bade5f 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -3,7 +3,7 @@ import { useFieldArray } from 'react-hook-form' import { useParams } from 'common' import { FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -32,9 +32,10 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp }) const { ref } = useParams() - const { data: settings } = useProjectApiQuery({ projectRef: ref }) - const apiService = settings?.autoApiService - const apiKey = apiService?.serviceApiKey ?? '[YOUR API KEY]' + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) + const apiKey = + (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role') ?? + '[YOUR API KEY]' return ( diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx index b746aa7fb69..cf757202fc9 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx @@ -24,16 +24,16 @@ export const OrganizationInviteError = ({ data, error, isError }: OrganizationIn ) : data?.token_does_not_exist ? ( - <> +

      The invite token is invalid.

      Try copying and pasting the link from the invite email, or ask the organization owner to invite you again.

      - +
      ) : !data?.email_match ? (

      diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx index fd6a143b758..3fbacbc4651 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, SidePanel } from 'ui' import { useParams } from 'common' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useAppStateSnapshot } from 'state/app-state' import { @@ -42,16 +42,17 @@ const ProjectAPIDocs = () => { const [showKeys, setShowKeys] = useState(false) const language = snap.docsLanguage - const { data } = useProjectApiQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref }) const apikey = showKeys - ? data?.autoApiService.defaultApiKey ?? 'SUPABASE_CLIENT_ANON_KEY' + ? (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? + 'SUPABASE_CLIENT_ANON_KEY' : 'SUPABASE_CLIENT_ANON_KEY' const endpoint = customDomainData?.customDomain?.status === 'active' ? `https://${customDomainData.customDomain?.hostname}` - : `https://${data?.autoApiService.endpoint ?? ''}` + : `https://${settings?.app_config?.endpoint ?? ''}` return ( { - const { data: settings } = useProjectApiQuery({ projectRef: config.projectRef }) + const snap = useRoleImpersonationStateSnapshot() - const { mutate: sendEvent } = useSendEventMutation() + const { data: settings } = useProjectSettingsV2Query({ projectRef: config.projectRef }) + const anonRoleKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon')?.api_key + const serviceRoleKey = (settings?.service_api_keys ?? []).find( + (x) => x.tags === 'service_role' + )?.api_key - const apiService = settings?.autoApiService - const serviceRoleKey = apiService?.service_api_keys.find((x) => x.name === 'service_role key') - ? apiService.serviceApiKey - : undefined - - const anonRoleKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') - ? apiService.defaultApiKey - : undefined const { data: postgrestConfig } = useProjectPostgrestConfigQuery( { projectRef: config.projectRef, @@ -36,10 +32,11 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens ) const jwtSecret = postgrestConfig?.jwt_secret - const snap = useRoleImpersonationStateSnapshot() + const { mutate: sendEvent } = useSendEventMutation() // only send a telemetry event if the user changes the role. Don't send an event during initial render. const isMounted = useRef(false) + useEffect(() => { if (isMounted.current) { sendEvent({ diff --git a/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts b/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts index fc785187645..2397b6b1d5d 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts +++ b/apps/studio/components/interfaces/Realtime/Inspector/useRealtimeMessages.ts @@ -7,7 +7,7 @@ import { merge, sortBy, take } from 'lodash' import { Dispatch, SetStateAction, useCallback, useEffect, useReducer, useState } from 'react' import { toast } from 'sonner' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { uuidv4 } from 'lib/helpers' import { EMPTY_ARR } from 'lib/void' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' @@ -76,11 +76,11 @@ export const useRealtimeMessages = ( enableBroadcast, } = config - const { data } = useProjectApiQuery({ projectRef: projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: projectRef }) // the default host is prod until the correct one comes through an API call. - const host = data - ? `${data.autoApiService.protocol}://${data.autoApiService.endpoint}` + const host = settings + ? `https://${settings.app_config?.endpoint}` : `https://${projectRef}.supabase.co` const realtimeUrl = `${host}/realtime/v1`.replace(/^http/i, 'ws') diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 2f338814316..ae1f0c621c3 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -12,7 +12,6 @@ import { DisplayApiSettings } from 'components/ui/ProjectSettings' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' -import { useProjectApiQuery } from 'data/config/project-api-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' @@ -29,11 +28,8 @@ const ServiceList = () => { const { ref: projectRef, source } = useParams() const state = useDatabaseSelectorStateSnapshot() - const { data: settings, isError } = useProjectApiQuery({ - projectRef, - }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const { data: databases } = useReadReplicasQuery({ projectRef }) + const { data: databases, isError } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index eee3f944a8b..000a387666a 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -1,16 +1,16 @@ +import { AlertCircle, ExternalLink } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' import Panel from 'components/ui/Panel' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation' import { useCustomDomainActivateMutation } from 'data/custom-domains/custom-domains-activate-mutation' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { AlertCircle, ExternalLink } from 'lucide-react' export type CustomDomainActivateProps = { projectRef?: string @@ -20,7 +20,7 @@ export type CustomDomainActivateProps = { const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivateProps) => { const [isActivateConfirmModalVisible, setIsActivateConfirmModalVisible] = useState(false) - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { mutate: checkCNAMERecord, isLoading: isCheckingRecord } = useCheckCNAMERecordMutation() const { mutate: activateCustomDomain, isLoading: isActivating } = useCustomDomainActivateMutation( { @@ -32,7 +32,7 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate ) const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() - const endpoint = settings?.autoApiService.endpoint + const endpoint = settings?.app_config?.endpoint const onActivateCustomDomain = async () => { if (!projectRef) return console.error('Project ref is required') diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index bcde78bace3..fcfe9c4f4f3 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -5,7 +5,7 @@ import { useParams } from 'common' import { FormHeader } from 'components/ui/Forms/FormHeader' import Panel from 'components/ui/Panel' import UpgradeToPro from 'components/ui/UpgradeToPro' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' @@ -23,11 +23,9 @@ const CustomDomainConfig = () => { const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota') const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - const plan = subscription?.plan?.id - const { isLoading: isSettingsLoading, data: settings } = useProjectApiQuery({ - projectRef: ref, - }) + + const { isLoading: isSettingsLoading } = useProjectSettingsV2Query({ projectRef: ref }) const { isLoading: isCustomDomainsLoading, @@ -101,11 +99,7 @@ const CustomDomainConfig = () => { {(data.status === '1_not_started' || data.status === '2_initiated' || data.status === '3_challenge_verified') && ( - + )} {data.status === '4_origin_setup_completed' && ( diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index 244664f456d..fe20e53a8dd 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -2,8 +2,9 @@ import { AlertCircle, ExternalLink, HelpCircle, RefreshCw } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' +import { useParams } from 'common' import Panel from 'components/ui/Panel' -import type { ProjectApiResponse } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation' @@ -19,14 +20,15 @@ import DNSRecord from './DNSRecord' import { DNSTableHeaders } from './DNSTableHeaders' export type CustomDomainVerifyProps = { - projectRef?: string customDomain: CustomDomainResponse - settings?: ProjectApiResponse } -const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomainVerifyProps) => { +const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { + const { ref: projectRef } = useParams() const [isNotVerifiedYet, setIsNotVerifiedYet] = useState(false) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { mutate: reverifyCustomDomain, isLoading: isReverifyLoading } = useCustomDomainReverifyMutation({ onSuccess: (res) => { @@ -152,7 +154,7 @@ const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomain )} diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx index 23105004928..1eb5b4536ed 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx @@ -1,4 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import * as yup from 'yup' @@ -7,12 +8,11 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { FormActions } from 'components/ui/Forms/FormActions' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation' import { useCustomDomainCreateMutation } from 'data/custom-domains/custom-domains-create-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, Form, Input } from 'ui' -import { ExternalLink } from 'lucide-react' const schema = yup.object({ domain: yup.string().required('A value for your custom domain is required'), @@ -24,10 +24,10 @@ const CustomDomainsConfigureHostname = () => { const { mutate: checkCNAMERecord, isLoading: isCheckingRecord } = useCheckCNAMERecordMutation() const { mutate: createCustomDomain, isLoading: isCreating } = useCustomDomainCreateMutation() - const { data: settings } = useProjectApiQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const FORM_ID = 'custom-domains-form' - const endpoint = settings?.autoApiService.endpoint + const endpoint = settings?.app_config?.endpoint const canConfigureCustomDomain = useCheckPermissions(PermissionAction.UPDATE, 'projects', { resource: { project_id: project?.id, diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts index 3dab5cc0cd4..d04aa865ea0 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts @@ -1,4 +1,3 @@ -import { ProjectApiResponse } from 'data/config/project-api-query' import { StorageSizeUnits } from './StorageSettings.constants' const k = 1024 diff --git a/apps/studio/data/config/project-api-query.ts b/apps/studio/data/config/project-api-query.ts deleted file mode 100644 index 3826300f630..00000000000 --- a/apps/studio/data/config/project-api-query.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import { components } from 'api-types' -import { get, handleError } from 'data/fetchers' -import { useProjectByRef } from 'hooks/misc/useSelectedProject' -import { PROJECT_STATUS } from 'lib/constants' -import type { ResponseError } from 'types' -import { configKeys } from './keys' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { PermissionAction } from '@supabase/shared-types/out/constants' - -export type ProjectApiVariables = { - projectRef?: string -} - -export type AutoApiService = components['schemas']['AutoApiService'] - -export type ProjectApiResponse = { - autoApiService: AutoApiService -} - -export async function getProjectApi({ projectRef }: ProjectApiVariables, signal?: AbortSignal) { - if (!projectRef) throw new Error('projectRef is required') - - const { data, error } = await get('/platform/props/project/{ref}/api', { - params: { path: { ref: projectRef } }, - signal, - }) - - if (error) handleError(error) - return data -} - -export type ProjectApiData = Awaited> -export type ProjectApiError = ResponseError - -/** @deprecated */ -export const useProjectApiQuery = ( - { projectRef }: ProjectApiVariables, - { enabled = true, ...options }: UseQueryOptions = {} -) => { - const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - - return useQuery( - configKeys.api(projectRef), - ({ signal }) => getProjectApi({ projectRef }, signal), - { - enabled: enabled && typeof projectRef !== 'undefined', - refetchInterval(data) { - if (!data) return false - - const { autoApiService } = data as unknown as ProjectApiData - - const apiKeys = autoApiService?.service_api_keys ?? [] - const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 - - return interval - }, - ...options, - } - ) -} diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 0334469cb30..aa0903d6141 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -10,7 +10,7 @@ import { Markdown } from 'components/interfaces/Markdown' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useIntegrationsQuery } from 'data/integrations/integrations-query' import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' import { useIntegrationVercelConnectionsCreateMutation } from 'data/integrations/integrations-vercel-connections-create-mutation' @@ -165,16 +165,16 @@ const CreateProject = () => { } // Wait for the new project to be created before creating the connection - useProjectApiQuery( + useProjectSettingsV2Query( { projectRef: newProjectRef }, { enabled: newProjectRef !== undefined, // refetch until the project is created refetchInterval: (data) => { - return (data?.autoApiService.service_api_keys.length ?? 0) > 0 ? false : 1000 + return ((data?.service_api_keys ?? []).length ?? 0) > 0 ? false : 1000 }, async onSuccess(data) { - const isReady = data.autoApiService.service_api_keys.length > 0 + const isReady = (data?.service_api_keys ?? []).length > 0 if (!isReady || !organizationIntegration || !foreignProjectId || !newProjectRef) { return From 361d429c9f31d3d8f0b329f6ada34558cdb074b7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 23 Oct 2024 21:09:34 +0800 Subject: [PATCH 095/255] Shift all uses of project-settings-query to project-settings-v2-query and deprecate project-settings-query (#30031) * Add perms check in projects API query * Start deprecating use of projects-api-query, move to projects-settings-v2-query * PRETTY * Shift all uses of project api query to project settings v2 query, and deprecate project api query * Shift all uses of project-settings-query to project-settings-v2-query and deprecate project-settings-query * Fix --- .../interfaces/Auth/Users/BanUserModal.tsx | 10 ++- .../interfaces/Auth/Users/CreateUserModal.tsx | 17 +++-- .../interfaces/Auth/Users/UserOverview.tsx | 10 ++- .../Database/Hooks/HTTPRequestFields.tsx | 7 +- .../EdgeFunctionDetails.tsx | 9 ++- .../Functions/TerminalInstructions.tsx | 9 ++- .../Home/Connect/ConnectTabContent.tsx | 12 ++-- .../Home/NewProjectPanel/APIKeys.tsx | 8 +-- .../CronJobs/HttpHeaderFieldsSection.tsx | 7 +- .../ProjectAPIDocs/ProjectAPIDocs.tsx | 6 +- .../Inspector/RealtimeTokensPopover/index.tsx | 13 ++-- .../interfaces/Settings/Addons/Addons.tsx | 14 ++-- .../Settings/Database/SSLConfiguration.tsx | 7 +- .../Settings/Database/UsePoolerCheckbox.tsx | 6 +- .../layouts/StorageLayout/StorageLayout.tsx | 12 ++-- .../Storage/StorageExplorer/useCopyUrl.tsx | 11 +-- .../ui/ProjectSettings/DisplayApiSettings.tsx | 17 ++--- .../ProjectSettings/DisplayConfigSettings.tsx | 30 ++++---- .../data/config/project-settings-query.ts | 69 ------------------- .../project/[ref]/integrations/graphiql.tsx | 10 ++- 20 files changed, 96 insertions(+), 188 deletions(-) delete mode 100644 apps/studio/data/config/project-settings-query.ts diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index d246cd04cd5..cb203fcc064 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -8,7 +8,7 @@ import * as z from 'zod' import { useParams } from 'common' import { useUserUpdateMutation } from 'data/auth/user-update-mutation' import { User } from 'data/auth/users-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -70,18 +70,16 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24 const endpoint = settings.app_config?.endpoint - const serviceApiKey = (settings?.service_api_keys ?? []).find( - (x) => x.tags === 'service_role' - )?.api_key + const { serviceKey } = getAPIKeys(settings) if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`) - if (!serviceApiKey) return toast.error(`Failed to ban user: Unable to retrieve API key`) + if (!serviceKey?.api_key) return toast.error(`Failed to ban user: Unable to retrieve API key`) updateUser({ projectRef, protocol: 'https', endpoint, - serviceApiKey, + serviceApiKey: serviceKey.api_key, userId: user.id, banDuration: durationHours, }) diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 3f5cdb41567..41a421ddcd6 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -7,7 +7,7 @@ import * as z from 'zod' import { useParams } from 'common' import { useUserCreateMutation } from 'data/auth/user-create-mutation' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, @@ -61,14 +61,19 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { return toast.error(`Failed to create user: Error loading project config`) } const endpoint = settings?.app_config?.endpoint - const serviceApiKey = (settings?.service_api_keys ?? []).find( - (key) => key.tags === 'service_role' - )?.api_key + const { serviceKey } = getAPIKeys(settings) if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`) - if (!serviceApiKey) return toast.error(`Failed to create user: Unable to retrieve API key`) + if (!serviceKey?.api_key) + return toast.error(`Failed to create user: Unable to retrieve API key`) - createUser({ projectRef, endpoint, protocol: 'https', serviceApiKey, user: values }) + createUser({ + projectRef, + endpoint, + protocol: 'https', + serviceApiKey: serviceKey.api_key, + user: values, + }) } const form = useForm>({ diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 692ba172806..7dd8d768955 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -16,7 +16,7 @@ import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mut import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation' import { useUserUpdateMutation } from 'data/auth/user-update-mutation' import { User } from 'data/auth/users-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' import { timeout } from 'lib/helpers' @@ -150,18 +150,16 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { } const endpoint = settings.app_config?.endpoint - const serviceApiKey = (settings?.service_api_keys ?? []).find( - (key) => key.tags === 'service_role' - )?.api_key + const { serviceKey } = getAPIKeys(settings) if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`) - if (!serviceApiKey) return toast.error(`Failed to unban user: Unable to retrieve API key`) + if (!serviceKey?.api_key) return toast.error(`Failed to unban user: Unable to retrieve API key`) updateUser({ projectRef, protocol: 'https', endpoint, - serviceApiKey, + serviceApiKey: serviceKey.api_key, userId: user.id, banDuration: 'none', }) diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index b0077027af9..590a3caeee3 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -52,9 +52,8 @@ const HTTPRequestFields = ({ const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) const edgeFunctions = functions ?? [] - const apiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ?? - '[YOUR API KEY]' + const { serviceKey } = getAPIKeys(settings) + const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' return ( <> diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 671ff7657c1..3936710c68e 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -13,7 +13,7 @@ import { FormActions } from 'components/ui/Forms/FormActions' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation' @@ -53,9 +53,8 @@ const EdgeFunctionDetails = () => { const formId = 'edge-function-update-form' const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') - const anonKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? - '[YOUR ANON KEY]' + const { anonKey } = getAPIKeys(settings) + const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' const endpoint = settings?.app_config?.endpoint ?? '' const functionUrl = @@ -66,7 +65,7 @@ const EdgeFunctionDetails = () => { const { managementCommands, secretCommands, invokeCommands } = generateCLICommands( selectedFunction, functionUrl, - anonKey + apiKey ) const onUpdateFunction = async (values: any, { resetForm }: any) => { diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index 0ce1ee4ad30..1c100782e7f 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -5,7 +5,7 @@ import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from 'reac import { useParams } from 'common' import CommandRender from 'components/interfaces/Functions/CommandRender' import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { Button, @@ -32,9 +32,8 @@ const TerminalInstructions = forwardRef< const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const anonKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? - '[YOUR ANON KEY]' + const { anonKey } = getAPIKeys(settings) + const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' const endpoint = settings?.app_config?.endpoint ?? '' const functionsEndpoint = customDomainData?.customDomain?.status === 'active' @@ -73,7 +72,7 @@ const TerminalInstructions = forwardRef< }, { command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${ - anonKey ?? '[YOUR ANON KEY]' + apiKey ?? '[YOUR ANON KEY]' }' --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', jsx: () => { diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx index 59eac3804d6..005b806419e 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx @@ -1,14 +1,14 @@ -import { useParams } from 'common' import dynamic from 'next/dynamic' import { forwardRef, HTMLAttributes } from 'react' +import { useParams } from 'common' import { getConnectionStrings } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { pluckObjectFields } from 'lib/helpers' -import type { projectKeys } from './Connect.types' import { cn } from 'ui' +import type { projectKeys } from './Connect.types' interface ConnectContentTabProps extends HTMLAttributes { projectKeys: projectKeys @@ -23,13 +23,13 @@ interface ConnectContentTabProps extends HTMLAttributes { const ConnectTabContentNew = forwardRef( ({ projectKeys, filePath, ...props }, ref) => { const { ref: projectRef } = useParams() - const { data } = useProjectSettingsQuery({ projectRef }) + + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef }) - const { project } = data ?? {} const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } - const connectionInfo = pluckObjectFields(project || emptyState, DB_FIELDS) + const connectionInfo = pluckObjectFields(settings || emptyState, DB_FIELDS) const poolingConfiguration = poolingInfo?.find((x) => x.database_type === 'PRIMARY') const connectionStringsPooler = diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index bfbee79906d..7265f3c38f5 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -55,17 +55,17 @@ const APIKeys = () => { const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - const apiKeys = settings?.service_api_keys ?? [] - - // API keys should not be empty. However it can be populated with a delay on project creation - const isApiKeysEmpty = apiKeys.length === 0 const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated const endpoint = settings?.app_config?.endpoint const apiUrl = `https://${endpoint ?? '-'}` + const apiKeys = settings?.service_api_keys ?? [] const { anonKey } = getAPIKeys(settings) + // API keys should not be empty. However it can be populated with a delay on project creation + const isApiKeysEmpty = apiKeys.length === 0 + const clientInitSnippet: any = generateInitSnippet(apiUrl) const selectedLanguageSnippet = clientInitSnippet[selectedLanguage.key] ?? 'No snippet available' diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx index cc3c1bade5f..bfd46415d0b 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx @@ -3,7 +3,7 @@ import { useFieldArray } from 'react-hook-form' import { useParams } from 'common' import { FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -33,9 +33,8 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp const { ref } = useParams() const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) - const apiKey = - (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role') ?? - '[YOUR API KEY]' + const { serviceKey } = getAPIKeys(settings) + const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' return ( diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx index 3fbacbc4651..ee8e926145a 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Button, SidePanel } from 'ui' import { useParams } from 'common' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useAppStateSnapshot } from 'state/app-state' import { @@ -45,9 +45,9 @@ const ProjectAPIDocs = () => { const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref }) + const { anonKey } = getAPIKeys(settings) const apikey = showKeys - ? (settings?.service_api_keys ?? []).find((key) => key.tags === 'anon')?.api_key ?? - 'SUPABASE_CLIENT_ANON_KEY' + ? anonKey?.api_key ?? 'SUPABASE_CLIENT_ANON_KEY' : 'SUPABASE_CLIENT_ANON_KEY' const endpoint = customDomainData?.customDomain?.status === 'active' diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx index 65c8fa5317c..09a861e5a3c 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -19,10 +19,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens const snap = useRoleImpersonationStateSnapshot() const { data: settings } = useProjectSettingsV2Query({ projectRef: config.projectRef }) - const anonRoleKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon')?.api_key - const serviceRoleKey = (settings?.service_api_keys ?? []).find( - (x) => x.tags === 'service_role' - )?.api_key + const { anonKey, serviceKey } = getAPIKeys(settings) const { data: postgrestConfig } = useProjectPostgrestConfigQuery( { @@ -59,12 +56,12 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens snap.role !== undefined && snap.role.type === 'postgrest' ) { - token = anonRoleKey + token = anonKey?.api_key await getRoleImpersonationJWT(config.projectRef, jwtSecret, snap.role) .then((b) => (bearer = b)) .catch((err) => toast.error(`Failed to get JWT for role: ${err.message}`)) } else { - token = serviceRoleKey + token = serviceKey?.api_key } if (token) { onChangeConfig({ ...config, token, bearer }) @@ -72,7 +69,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens } triggerUpdateTokenBearer() - }, [snap.role, anonRoleKey, serviceRoleKey]) + }, [snap.role, anonKey, serviceKey]) return } diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 76c0d237bcd..f695cfff4c0 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -1,5 +1,4 @@ import * as Tooltip from '@radix-ui/react-tooltip' -import { useParams } from 'common' import dayjs from 'dayjs' import { AlertCircle, ChevronRight, ExternalLink, Info } from 'lucide-react' import { useTheme } from 'next-themes' @@ -7,6 +6,7 @@ import Image from 'next/image' import Link from 'next/link' import { useMemo } from 'react' +import { useParams } from 'common' import { getAddons, subscriptionHasHipaaAddon, @@ -26,7 +26,7 @@ import { import AlertError from 'components/ui/AlertError' import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import type { ProjectAddonVariantMeta } from 'data/subscriptions/types' @@ -48,15 +48,15 @@ const Addons = () => { const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() const { setPanel } = useAddonsPagePanel() - const { project: selectedProject, isLoading: isLoadingProject } = useProjectContext() - const { data: projectSettings } = useProjectSettingsQuery({ projectRef }) const selectedOrg = useSelectedOrganization() - const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrg?.slug }) - + const { project: selectedProject, isLoading: isLoadingProject } = useProjectContext() const parentProject = useProjectByRef(selectedProject?.parent_project_ref) const isBranch = parentProject !== undefined const isProjectActive = useIsProjectActive() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrg?.slug }) + const computeSizeChangesDisabled = useFlag('disableComputeSizeChanges') const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') @@ -105,7 +105,7 @@ const Addons = () => { return computeMeta }, [selectedProject, computeInstance]) - const canUpdateIPv4 = projectSettings?.project.db_ip_addr_config === 'ipv6' + const canUpdateIPv4 = settings?.db_ip_addr_config === 'ipv6' return ( <> diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index b9621087efb..88a0ac96998 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -10,7 +10,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSSLEnforcementQuery } from 'data/ssl-enforcement/ssl-enforcement-query' import { useSSLEnforcementUpdateMutation } from 'data/ssl-enforcement/ssl-enforcement-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -27,7 +27,7 @@ const SSLConfiguration = () => { const { ref } = useParams() const [isEnforced, setIsEnforced] = useState(false) - const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: sslEnforcementConfiguration, isLoading, @@ -65,8 +65,7 @@ const SSLConfiguration = () => { ) const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging' const hasSSLCertificate = - projectSettings?.project !== undefined && - new Date(projectSettings.project.inserted_at) >= new Date('2021-04-30') + settings?.inserted_at !== undefined && new Date(settings.inserted_at) >= new Date('2021-04-30') useEffect(() => { if (!isLoading && sslEnforcementConfiguration) { diff --git a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx index 9f163c335db..6990f9f60d0 100644 --- a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx +++ b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { usePgBouncerStatus } from 'data/config/pgbouncer-enabled-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' @@ -51,12 +51,12 @@ export const UsePoolerCheckbox = ({ const state = useDatabaseSelectorStateSnapshot() const { data, isLoading, isSuccess } = usePoolingConfigurationQuery({ projectRef }) - const { data: settings, isSuccess: isSuccessSettings } = useProjectSettingsQuery({ projectRef }) + const { data: settings, isSuccess: isSuccessSettings } = useProjectSettingsV2Query({ projectRef }) const { data: pgBouncerStatus } = usePgBouncerStatus({ projectRef: projectRef }) const isDatabaseSettingsPage = router.pathname.endsWith('/settings/database') const poolingConfiguration = data?.find((x) => x.identifier === state.selectedDatabaseId) - const resolvesToIpV6 = settings?.project.db_ip_addr_config === 'ipv6' + const resolvesToIpV6 = settings?.db_ip_addr_config === 'ipv6' const onSelectOption = (value: 'session' | 'transaction') => { onSelectPoolingMode(value) diff --git a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx index 0df1670f64f..cacfb57f7e3 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageLayout.tsx @@ -2,7 +2,7 @@ import { useParams } from 'common' import { ReactNode, useEffect } from 'react' import { toast } from 'sonner' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { PROJECT_STATUS } from 'lib/constants' @@ -22,16 +22,16 @@ const StorageLayout = ({ title, children }: StorageLayoutProps) => { const { data: settings, isLoading } = useProjectSettingsV2Query({ projectRef }) const endpoint = settings?.app_config?.endpoint - const serviceApiKey = (settings?.service_api_keys ?? []).find( - (key) => key.tags === 'service_role' - )?.api_key + const { serviceKey } = getAPIKeys(settings) const isPaused = project?.status === PROJECT_STATUS.INACTIVE useEffect(() => { - if (!isLoading && endpoint && serviceApiKey) initializeStorageStore(endpoint, serviceApiKey) + if (!isLoading && endpoint && serviceKey?.api_key) { + initializeStorageStore(endpoint, serviceKey.api_key) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoading, projectRef, endpoint, serviceApiKey]) + }, [isLoading, projectRef, endpoint, serviceKey?.api_key]) const initializeStorageStore = async (endpoint: string, serviceApiKey: string) => { if (isPaused) return diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx index 925eea0cfdf..7543eb48899 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx @@ -1,19 +1,14 @@ import { toast } from 'sonner' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants/infrastructure' import { copyToClipboard } from 'lib/helpers' export const useCopyUrl = (ref: string) => { const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref }) - const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) - const apiService = (projectSettings?.services ?? []).find( - (x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiConfig = apiService?.app_config - const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}` + const apiUrl = `https://${settings?.app_config?.endpoint ?? '-'}` const onCopyUrl = (name: string, url: string | Promise) => { const formattedUrl = Promise.resolve(url).then((url) => { diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 60df961219c..9cf249a7ca3 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,14 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { Button, Input } from 'ui' +import { AlertCircle, BookOpen, Loader2 } from 'lucide-react' -import { useParams } from 'common/hooks' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import { AlertCircle, BookOpen, Loader2 } from 'lucide-react' +import { Button, Input } from 'ui' const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { const { ref: projectRef } = useParams() @@ -17,7 +16,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettingsQuery({ projectRef }) + } = useProjectSettingsV2Query({ projectRef }) const { data, isError: isJwtSecretUpdateStatusError, @@ -29,11 +28,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - // Get the API service - const apiService = (settings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] + const apiKeys = settings?.service_api_keys ?? [] // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 diff --git a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx index 55ca8c52fce..cb9929f26f1 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -1,13 +1,13 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { useParams } from 'common' +import { AlertCircle, Loader } from 'lucide-react' import { PropsWithChildren } from 'react' -import { Input } from 'ui' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import { AlertCircle, Loader } from 'lucide-react' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { Input } from 'ui' const DisplayConfigSettings = () => { const { ref: projectRef } = useParams() @@ -15,9 +15,10 @@ const DisplayConfigSettings = () => { data: settings, isLoading: isProjectSettingsLoading, isError: isProjectSettingsError, - } = useProjectSettingsQuery({ + } = useProjectSettingsV2Query({ projectRef, }) + const { data: config, isError: isPostgrestError } = useProjectPostgrestConfigQuery({ projectRef }) const { data, @@ -25,29 +26,24 @@ const DisplayConfigSettings = () => { isLoading: isJwtSecretUpdateStatusLoading, } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - // Get the API service - const jwtSecret = settings?.project?.jwt_secret ?? '' - const apiService = (settings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiConfig = apiService?.app_config - const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}` + + const jwtSecret = config?.jwt_secret ?? '' + const apiUrl = `https://${settings?.app_config?.endpoint ?? '-'}` return ( - {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( + {isProjectSettingsError || isPostgrestError || isJwtSecretUpdateStatusError ? (

      - {isProjectSettingsError + {isProjectSettingsError || isPostgrestError ? 'Failed to retrieve configuration' : 'Failed to update JWT secret'}

      - ) : isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( + ) : isProjectSettingsLoading || isPostgrestError || isJwtSecretUpdateStatusLoading ? (

      diff --git a/apps/studio/data/config/project-settings-query.ts b/apps/studio/data/config/project-settings-query.ts deleted file mode 100644 index 7d9d642c132..00000000000 --- a/apps/studio/data/config/project-settings-query.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import type { components } from 'data/api' -import { get, handleError } from 'data/fetchers' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import type { ResponseError } from 'types' -import { configKeys } from './keys' - -export type ProjectSettingsVariables = { - projectRef?: string -} - -// [Joshen] API typing needs to be fixed - this is completely wrong -type ProjectSettingsInfo = components['schemas']['ProjectResponse'] & { db_port: number } -interface ProjectSettingsInfoExtended extends ProjectSettingsInfo { - // [Joshen] Based on the enums here - // https://github.com/supabase/infrastructure/blob/95dc09fe077dba7817bb112fa72b6814a620ecd3/shared/src/projects.ts#L177 - db_ip_addr_config: 'legacy' | 'static-ipv4' | 'concurrent-ipv6' | 'ipv6' -} - -export type ProjectSettings = { - project: ProjectSettingsInfoExtended - services: components['schemas']['SettingsResponse']['services'] -} - -export async function getProjectSettings( - { projectRef }: ProjectSettingsVariables, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') - - // [Joshen] API typing is wrong here - const { data, error } = await get('/platform/props/project/{ref}/settings', { - params: { path: { ref: projectRef } }, - signal, - }) - - if (error) handleError(error) - return data as unknown as ProjectSettings -} - -export type ProjectSettingsData = Awaited> -export type ProjectSettingsError = ResponseError - -/** @deprecated */ -export const useProjectSettingsQuery = ( - { projectRef }: ProjectSettingsVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - configKeys.settings(projectRef), - ({ signal }) => getProjectSettings({ projectRef }, signal), - { - enabled: enabled && typeof projectRef !== 'undefined', - refetchInterval(data) { - const apiService = ((data as ProjectSettings)?.services ?? []).find( - (x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 - - return interval - }, - ...options, - } - ) diff --git a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx index 496ba9cb95c..af3804e7554 100644 --- a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx +++ b/apps/studio/pages/project/[ref]/integrations/graphiql.tsx @@ -11,7 +11,7 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import { Loading } from 'components/ui/Loading' import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' @@ -34,9 +34,7 @@ const GraphiQLPage: NextPageWithLayout = () => { const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) const { data: settings, isFetched } = useProjectSettingsV2Query({ projectRef }) - const serviceRoleKey = (settings?.service_api_keys ?? []).find( - (x) => x.name === 'service_role key' - )?.api_key + const { serviceKey } = getAPIKeys(settings) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) const jwtSecret = config?.jwt_secret @@ -77,13 +75,13 @@ const GraphiQLPage: NextPageWithLayout = () => { opts?.headers?.['Authorization'] ?? opts?.headers?.['authorization'] ?? userAuthorization ?? - `Bearer ${serviceRoleKey}`, + `Bearer ${serviceKey?.api_key}`, }, }) } return customFetcher - }, [projectRef, getImpersonatedRole, jwtSecret, accessToken, serviceRoleKey]) + }, [projectRef, getImpersonatedRole, jwtSecret, accessToken, serviceKey]) if ((IS_PLATFORM && !accessToken) || !isFetched || (isExtensionsLoading && !pgGraphqlExtension)) { return From b5a515aca613e8d473a69ab0ab59c351c47007d2 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 23 Oct 2024 21:26:09 +0800 Subject: [PATCH 096/255] fix: avatar url startsWith error (#30053) --- apps/studio/components/interfaces/Auth/Users/Users.utils.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index 4bb4eaf5024..a1cf50f2c64 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -211,9 +211,9 @@ export function getAvatarUrl(user: User): string | undefined { profileImageUrl || profileImageURL || profile_image_url || - '') as string | undefined + '') as unknown - if (url === undefined) return undefined + if (typeof url !== 'string') return undefined const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x)) return isSupported ? url : undefined } From 14076eed865defd5c5a2b802423731cf165cf6a0 Mon Sep 17 00:00:00 2001 From: Mark Burggraf Date: Wed, 23 Oct 2024 09:58:40 -0700 Subject: [PATCH 097/255] added new neon migration guide (#30056) --- .../NavigationMenu.constants.ts | 6 ++ .../platform/migrating-to-supabase/neon.mdx | 90 +++++++++++++++++++ .../docs/public/img/icons/neon-icon-light.svg | 4 + apps/docs/public/img/icons/neon-icon.svg | 4 + 4 files changed, 104 insertions(+) create mode 100644 apps/docs/content/guides/platform/migrating-to-supabase/neon.mdx create mode 100644 apps/docs/public/img/icons/neon-icon-light.svg create mode 100644 apps/docs/public/img/icons/neon-icon.svg diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6aa8637e1ff..27a91a80c11 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1861,6 +1861,12 @@ export const MIGRATION_PAGES: Partial dump.sql +``` + +2. Import the database to your Supabase project + + Use `psql` to import the Postgres database file to your Supabase project. + + ```bash + psql -d "$NEW_DB_URL" -f dump.sql + ``` + +Additional options + +- To only migrate a single database schema, add the `--schema=PATTERN` parameter to your `pg_dump` command. +- To exclude a schema: `--exclude-schema=PATTERN`. +- To only migrate a single table: `--table=PATTERN`. +- To exclude a table: `--exclude-table=PATTERN`. + +Run `pg_dump --help` for a full list of options. + + + +## Enterprise + +[Contact us](https://forms.supabase.com/enterprise) if you need more help migrating your project. diff --git a/apps/docs/public/img/icons/neon-icon-light.svg b/apps/docs/public/img/icons/neon-icon-light.svg new file mode 100644 index 00000000000..c8906afd151 --- /dev/null +++ b/apps/docs/public/img/icons/neon-icon-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/docs/public/img/icons/neon-icon.svg b/apps/docs/public/img/icons/neon-icon.svg new file mode 100644 index 00000000000..7fb2f931cc4 --- /dev/null +++ b/apps/docs/public/img/icons/neon-icon.svg @@ -0,0 +1,4 @@ + + + + From 438e1f0b6ecc19bfd98b5a0051aab74457f05690 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 24 Oct 2024 03:47:37 +0200 Subject: [PATCH 098/255] remove webinar announcement link (#30059) * remove webinar announcement link * watch now --- .../_events/2024-10-23-scale-to-millions-goodtape-auth.mdx | 2 +- apps/www/components/Hero/Hero.tsx | 4 ---- apps/www/components/Nav/ProductDropdown.tsx | 5 ----- apps/www/pages/customers.tsx | 4 ---- apps/www/public/customers-rss.xml | 2 +- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/apps/www/_events/2024-10-23-scale-to-millions-goodtape-auth.mdx b/apps/www/_events/2024-10-23-scale-to-millions-goodtape-auth.mdx index 5c776e45e17..6ec223743a7 100644 --- a/apps/www/_events/2024-10-23-scale-to-millions-goodtape-auth.mdx +++ b/apps/www/_events/2024-10-23-scale-to-millions-goodtape-auth.mdx @@ -21,7 +21,7 @@ main_cta: { url: 'https://zoom.us/webinar/register/WN_26XTfO35SAC81jjS1ulWJw?amp_device_id=1bd3c92f-c97a-45ad-8aab-a508f950b536', target: '_blank', - label: 'Register', + label: 'Watch now', } speakers: 'stojan,kevinbrolly,craig_cannon,jstoone' --- diff --git a/apps/www/components/Hero/Hero.tsx b/apps/www/components/Hero/Hero.tsx index 440d71b7035..64951f276a5 100644 --- a/apps/www/components/Hero/Hero.tsx +++ b/apps/www/components/Hero/Hero.tsx @@ -6,11 +6,8 @@ import Telemetry, { TelemetryEvent } from '~/lib/telemetry' import { Button } from 'ui' import SectionContainer from '~/components/Layouts/SectionContainer' -import EventCallout from '~/components/EventCallout' -import { useBreakpoint } from 'common' const Hero = () => { - const isMobile = useBreakpoint(768) const router = useRouter() const telemetryProps = useTelemetryProps() const sendTelemetryEvent = async (event: TelemetryEvent) => { @@ -25,7 +22,6 @@ const Hero = () => {

      -

      Build in a weekend Scale to millions diff --git a/apps/www/components/Nav/ProductDropdown.tsx b/apps/www/components/Nav/ProductDropdown.tsx index ecaca7ca976..7ab15b4b319 100644 --- a/apps/www/components/Nav/ProductDropdown.tsx +++ b/apps/www/components/Nav/ProductDropdown.tsx @@ -88,11 +88,6 @@ const ProductDropdown = () => { ))}

    -
    - - - -
    diff --git a/apps/www/pages/customers.tsx b/apps/www/pages/customers.tsx index 2055c0f93e3..7722b58fc92 100644 --- a/apps/www/pages/customers.tsx +++ b/apps/www/pages/customers.tsx @@ -13,8 +13,6 @@ import { motion } from 'framer-motion' import styles from '~/styles/customers.module.css' import Link from 'next/link' import { GlassPanel } from 'ui-patterns/GlassPanel' -import EventCallout from '../components/EventCallout' -import { useBreakpoint } from 'common' export async function getStaticProps() { const allPostsData = getSortedPosts({ directory: '_customers' }) @@ -33,7 +31,6 @@ export async function getStaticProps() { function CustomerStoriesPage(props: any) { const { basePath } = useRouter() - const isMobile = useBreakpoint(768) const meta = { title: 'Customer Stories | Supabase', @@ -88,7 +85,6 @@ function CustomerStoriesPage(props: any) { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0, transition: { duration: 0.5, easing: 'easeOut' } }} > -

    Customer stories

    Discover case studies on how Supabase is being used around the world to quickly diff --git a/apps/www/public/customers-rss.xml b/apps/www/public/customers-rss.xml index 4002a65f78c..5a1018d52f3 100644 --- a/apps/www/public/customers-rss.xml +++ b/apps/www/public/customers-rss.xml @@ -9,7 +9,7 @@ https://supabase.com/customers/humata - Humata Scales with Supabase - Achieving 4X Cost Savings and Enhanced Performance + Humata Scales with Supabase: Achieving 4X Cost Savings and Enhanced Performance https://supabase.com/customers/humata By partnering with Supabase, Humata achieved a 4X reduction in vector database costs, streamlined their development, and consolidated their data so they could scale seamlessly. Mon, 30 Sep 2024 22:00:00 GMT From d8c1d1a2db4e76c8d0387635e89f45e434e18f87 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 24 Oct 2024 11:01:29 +0800 Subject: [PATCH 099/255] Chore/minor fix scratch pad (#30055) * Fix padding * Only activate scratch pad if feature preview is enabled --- .../components/ui/AIAssistantPanel/AIAssistantPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx index c2d0379154a..346f7a4793e 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -40,6 +40,7 @@ import CodeEditor from '../CodeEditor/CodeEditor' import { AIAssistant } from './AIAssistant' import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' +import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' export const AiAssistantPanel = () => { const os = detectOS() @@ -48,6 +49,7 @@ export const AiAssistantPanel = () => { const project = useSelectedProject() const queryClient = useQueryClient() const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + const isEnabled = useIsDatabaseFunctionsAssistantEnabled() const { open, editor, content } = aiAssistantPanel const previousEditor = usePrevious(editor) @@ -249,10 +251,10 @@ export const AiAssistantPanel = () => { setAiAssistantPanel({ open: true, editor: null }) } } - if (project !== undefined) window.addEventListener('keydown', handler) + if (project !== undefined && isEnabled) window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [project]) + }, [project, isEnabled]) // [Joshen] Whenever the deps change recalculate the height of the editor useEffect(() => { @@ -293,7 +295,7 @@ export const AiAssistantPanel = () => { + )} {data.columns.map((column) => ( @@ -63,8 +76,6 @@ const TableNode = ({ data, targetPosition, sourcePosition }: NodeProps, items: [], }, - { - name: 'Migrations', - key: 'migrations', - url: `/project/${ref}/database/migrations`, - items: [], - }, ...(!!pgNetExtensionExists ? [ { diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx index d9a2048be60..c77dbacf424 100644 --- a/packages/ui-patterns/index.tsx +++ b/packages/ui-patterns/index.tsx @@ -9,7 +9,6 @@ export * from './ExpandableVideo' export * from './GlassPanel' export * from './IconPanel' export * from './PrivacySettings' -export * from './SchemaTableNode' export * from './ThemeToggle' export * from './TweetCard' export * from './InnerSideMenu' From e1c871322135f24b9492383c69133fea1a0b2cab Mon Sep 17 00:00:00 2001 From: divit <27228526+delgado3d@users.noreply.github.com> Date: Thu, 24 Oct 2024 09:43:47 +0530 Subject: [PATCH 101/255] fix: remove incorrect usage of restoring state for restarts (#29883) --- .../Infrastructure/InfrastructureConfiguration/InstanceNode.tsx | 2 -- .../Infrastructure/InfrastructureConfiguration/MapView.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index ae014b55630..568166cc00d 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -266,8 +266,6 @@ export const ReplicaNode = ({ data }: NodeProps) => { ) : status === REPLICA_STATUS.GOING_DOWN ? ( Going down - ) : status === REPLICA_STATUS.RESTORING ? ( - Restarting ) : status === REPLICA_STATUS.RESTARTING ? ( Restarting ) : status === REPLICA_STATUS.RESIZING ? ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index ca3285d6665..a7ddcaaa19b 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -271,8 +271,6 @@ const MapView = ({ Healthy ) : database.status === REPLICA_STATUS.COMING_UP ? ( Coming up - ) : database.status === REPLICA_STATUS.RESTORING ? ( - Restarting ) : database.status === REPLICA_STATUS.RESTARTING ? ( Restarting ) : database.status === REPLICA_STATUS.RESIZING ? ( From ddd52215dd4f9faba814d4c0d763418fa3e763c2 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Thu, 24 Oct 2024 11:08:04 +0200 Subject: [PATCH 102/255] fix: Optimize json data when including it in the www nav (#30041) * Add a script to separate the latest blog posts from the contentlayer json into a separate json. * Use the new json data for the latest blog posts. --- .../www/components/Nav/DevelopersDropdown.tsx | 13 ++---- apps/www/components/Nav/index.tsx | 16 ++++--- apps/www/data/nav.tsx | 5 +-- apps/www/lib/helpers.tsx | 19 --------- apps/www/package.json | 2 +- apps/www/scripts/generateLatestBlogPosts.mjs | 42 +++++++++++++++++++ 6 files changed, 56 insertions(+), 41 deletions(-) create mode 100644 apps/www/scripts/generateLatestBlogPosts.mjs diff --git a/apps/www/components/Nav/DevelopersDropdown.tsx b/apps/www/components/Nav/DevelopersDropdown.tsx index 76dcf53941b..e3cffcda2f7 100644 --- a/apps/www/components/Nav/DevelopersDropdown.tsx +++ b/apps/www/components/Nav/DevelopersDropdown.tsx @@ -1,13 +1,8 @@ -import React from 'react' -import Link from 'next/link' import { ChevronRight } from 'lucide-react' +import Link from 'next/link' import { data as DevelopersData } from 'data/Developers' -import { BlogPost } from 'contentlayer/generated' - -type Props = { - blogPosts?: BlogPost[] -} +import blogPosts from '~/.contentlayer/generated/LatestBlogPost/_index.json' assert { type: 'json' } type LinkProps = { text: string @@ -17,7 +12,7 @@ type LinkProps = { svg?: any } -const DevelopersDropdown = ({ blogPosts }: Props) => ( +const DevelopersDropdown = () => (
    {DevelopersData['navigation'].map((column) => ( @@ -55,7 +50,7 @@ const DevelopersDropdown = ({ blogPosts }: Props) => (
      - {blogPosts?.map((post: any) => ( + {blogPosts?.map((post) => (
    • { const [open, setOpen] = useState(false) const isLoggedIn = useIsLoggedIn() const isUserLoading = useIsUserLoading() - const latestBlogPosts = allBlogPosts.sort(sortDates).slice(0, 2) - const menu = getMenu(latestBlogPosts) + const menu = getMenu() const isHomePage = router.pathname === '/' const isLaunchWeekPage = router.pathname.includes('/launch-week') diff --git a/apps/www/data/nav.tsx b/apps/www/data/nav.tsx index 4ac31ec1373..c74aa12c919 100644 --- a/apps/www/data/nav.tsx +++ b/apps/www/data/nav.tsx @@ -3,9 +3,8 @@ import ProductDropdown from '~/components/Nav/ProductDropdown' import { data as DevelopersData } from 'data/Developers' import SolutionsData from 'data/Solutions' -import { BlogPost } from 'contentlayer/generated' -export const getMenu = (latestBlogPosts: BlogPost[]) => ({ +export const getMenu = () => ({ primaryNav: [ { title: 'Product', @@ -17,7 +16,7 @@ export const getMenu = (latestBlogPosts: BlogPost[]) => ({ { title: 'Developers', hasDropdown: true, - dropdown: , + dropdown: , dropdownContainerClassName: 'rounded-xl', subMenu: DevelopersData, }, diff --git a/apps/www/lib/helpers.tsx b/apps/www/lib/helpers.tsx index 9f928c9b11e..c94a49722f1 100644 --- a/apps/www/lib/helpers.tsx +++ b/apps/www/lib/helpers.tsx @@ -44,22 +44,3 @@ export const stripEmojis = (str: string) => ) .replace(/\s+/g, ' ') .trim() - -/** - * Fixes Safari dates sorting bug - */ -export function sortDates(a: any, b: any, direction: 'asc' | 'desc' = 'desc') { - const isAsc = direction === 'asc' - var reg = /-|:|T|\+/ //The regex on which matches the string should be split (any used delimiter) -> could also be written like /[.:T\+]/ - var parsed = [ - //an array which holds the date parts for a and b - a.date.split(reg), //Split the datestring by the regex to get an array like [Year,Month,Day] - b.date.split(reg), - ] - var dates = [ - //Create an array of dates for a and b - new Date(parsed[0][0], parsed[0][1], parsed[0][2]), //Constructs an date of the above parsed parts (Year,Month... - new Date(parsed[1][0], parsed[1][1], parsed[1][2]), - ] - return isAsc ? (dates[0] as any) - (dates[1] as any) : (dates[1] as any) - (dates[0] as any) //Returns the difference between the date (if b > a then a - b < 0) -} diff --git a/apps/www/package.json b/apps/www/package.json index 48833eec172..863dea91528 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -11,7 +11,7 @@ "lint": "next lint", "clean": "rimraf node_modules", "typecheck": "npm run content:build && tsc --noEmit", - "content:build": "contentlayer2 build", + "content:build": "contentlayer2 build && node scripts/generateLatestBlogPosts.mjs", "prettier": "prettier --write \"./{pages,components,lib,stores,styles,tests}/**/*.{ts,tsx,md,js,jsx,json}\"", "postbuild": "node ./internals/generate-sitemap.mjs" }, diff --git a/apps/www/scripts/generateLatestBlogPosts.mjs b/apps/www/scripts/generateLatestBlogPosts.mjs new file mode 100644 index 00000000000..3a8a36f3cfe --- /dev/null +++ b/apps/www/scripts/generateLatestBlogPosts.mjs @@ -0,0 +1,42 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { allBlogPosts } from '../.contentlayer/generated/index.mjs' + +/** + * Fixes Safari dates sorting bug + */ +const sortDates = (a, b, direction = 'desc') => { + const isAsc = direction === 'asc' + var reg = /-|:|T|\+/ //The regex on which matches the string should be split (any used delimiter) -> could also be written like /[.:T\+]/ + var parsed = [ + //an array which holds the date parts for a and b + a.date.split(reg), //Split the datestring by the regex to get an array like [Year,Month,Day] + b.date.split(reg), + ] + var dates = [ + //Create an array of dates for a and b + new Date(parsed[0][0], parsed[0][1], parsed[0][2]), //Constructs an date of the above parsed parts (Year,Month... + new Date(parsed[1][0], parsed[1][1], parsed[1][2]), + ] + return isAsc ? dates[0] - dates[1] : dates[1] - dates[0] //Returns the difference between the date (if b > a then a - b < 0) +} + +const latestBlogPosts = allBlogPosts + .sort(sortDates) + .slice(0, 2) + .map(({ title, url, description }) => ({ title, url, description })) + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const folderPath = path.join(__dirname, '../.contentlayer/generated/LatestBlogPost') +try { + await fs.mkdir(folderPath, { recursive: true }) +} catch (error) { + if (error.code !== 'EEXIST') { + throw error + } + // Folder already exists, continue silently +} + +const filePath = path.join(__dirname, '../.contentlayer/generated/LatestBlogPost/_index.json') +await fs.writeFile(filePath, JSON.stringify(latestBlogPosts), 'utf8') From 63919938be3289c12b656c9704992c7dc59693e6 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Thu, 24 Oct 2024 11:28:06 +0200 Subject: [PATCH 103/255] fix log drain permissions (#30071) --- apps/studio/pages/project/[ref]/settings/log-drains.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 07a350f868f..b887bae6caa 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -24,7 +24,7 @@ import type { NextPageWithLayout } from 'types' import { Alert_Shadcn_, Button } from 'ui' const LogDrainsSettings: NextPageWithLayout = () => { - const canManageLogDrains = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare') + const canManageLogDrains = useCheckPermissions(PermissionAction.ANALYTICS_ADMIN_WRITE, 'logflare') const [open, setOpen] = useState(false) const { ref } = useParams() as { ref: string } From 837b424c72f1247c67b164b28731ac1bfa888170 Mon Sep 17 00:00:00 2001 From: Kevin Lu Date: Thu, 24 Oct 2024 02:41:34 -0700 Subject: [PATCH 104/255] docs: typo fix (#30065) --- apps/docs/content/guides/platform/upgrading.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/platform/upgrading.mdx b/apps/docs/content/guides/platform/upgrading.mdx index e6f848bffa0..a4f4ff210fa 100644 --- a/apps/docs/content/guides/platform/upgrading.mdx +++ b/apps/docs/content/guides/platform/upgrading.mdx @@ -99,7 +99,7 @@ If you upgrade to a paid plan while your project is paused, any expired one-clic If the 90 day project restore window has expired but you need to access data contained within your project using SQL, you can attempt to restore the project into a local Postgres instance. Supabase publishes tooling that can be used for that purpose. Be aware that this workflow does not produce a complete Supabase environment with REST/Auth/Storage. Instead, it creates a standalone Postgres instance that is maximally compatible with your project's backup file to assist with recovering your data. -Downloaded backups are plain text SQL files that can be executed in Postgres to restore your project's schema and data. If you need you access data within a backup we recommend restoring into a Postgres instance built using the Supabase open source build tooling to ensure that the local instance is as similar to the original project as possible. Here are the steps to start Supabase Postgres locally and restore the backup: +Downloaded backups are plain text SQL files that can be executed in Postgres to restore your project's schema and data. If you need to access data within a backup we recommend restoring into a Postgres instance built using the Supabase open source build tooling to ensure that the local instance is as similar to the original project as possible. Here are the steps to start Supabase Postgres locally and restore the backup: Install [nix](https://nixos.org/): From 5781937739ce99d9ab2909b150ced2bfaba9f101 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Thu, 24 Oct 2024 20:59:35 +0800 Subject: [PATCH 105/255] chore: aggressive prefetching (#29987) * chore: aggressive prefetching * use abort signals in prefetchers * move encrypted columns to react-query * prefetching for filter and sort applied * prefetch remaining entity types * prefetch tables in more places * prefetch editor page on project panel * add feature flag * fix typescript * nit * Nit * fix imports * remove views check on encrypted schemas * use fetchQuery instead of prefetchQuery * fix useEncryptedColumnsQuery ts error * filter by schema on encrypted columns * don't use pg_get_tabledef for foreign tables * Remove unnecesary import --------- Co-authored-by: Joshen Lim --- apps/studio/components/grid/SupabaseGrid.tsx | 14 +- .../components/grid/SupabaseGrid.utils.ts | 62 +++++- .../formatter/ForeignKeyFormatter.tsx | 15 +- .../PolicyTableRow/PolicyTableRowHeader.tsx | 24 ++- .../interfaces/Database/Tables/TableList.tsx | 7 +- .../Database/Wrappers/WrapperRow.tsx | 9 +- .../Home/NewProjectPanel/NewProjectPanel.tsx | 8 +- .../ForeignRowSelector/ForeignRowSelector.tsx | 9 +- .../SidePanelEditor/SidePanelEditor.utils.tsx | 64 ------ .../ForeignKeysManagement/ForeignKeyRow.tsx | 11 +- .../TableGridEditor/TableGridEditor.tsx | 57 ++---- .../NavigationBar/NavigationBar.utils.tsx | 7 +- .../NavigationBar/NavigationIconLink.tsx | 27 ++- .../TableEditorLayout/EntityListItem.tsx | 6 +- .../TableEditorLayout/TableEditorMenu.tsx | 12 +- apps/studio/components/ui/ui.types.ts | 1 + .../data/database/entity-definitions-query.ts | 8 +- .../database/foreign-key-constraints-query.ts | 58 ++++-- apps/studio/data/database/schemas-query.ts | 24 ++- .../encrypted-columns-query.ts | 69 +++++++ apps/studio/data/encrypted-columns/keys.ts | 7 + .../data/entity-types/entity-type-query.ts | 30 ++- .../entity-types-infinite-query.ts | 38 +++- .../foreign-tables/foreign-table-query.ts | 11 +- .../materialized-view-query.ts | 12 +- .../prefetchers/project.$ref.editor.$id.tsx | 182 ++++++++++++++++++ .../data/prefetchers/project.$ref.editor.tsx | 64 ++++++ .../data/table-rows/table-rows-query.ts | 59 ++++-- apps/studio/data/tables/table-query.ts | 27 +-- apps/studio/data/views/view-query.ts | 11 +- apps/studio/hooks/misc/useTable.ts | 16 +- .../pages/project/[ref]/editor/[id].tsx | 2 +- apps/studio/state/table-editor.tsx | 4 +- 33 files changed, 717 insertions(+), 238 deletions(-) create mode 100644 apps/studio/data/encrypted-columns/encrypted-columns-query.ts create mode 100644 apps/studio/data/encrypted-columns/keys.ts create mode 100644 apps/studio/data/prefetchers/project.$ref.editor.$id.tsx create mode 100644 apps/studio/data/prefetchers/project.$ref.editor.tsx diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index c25b503426d..4d9472a26b9 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -28,8 +28,12 @@ import { InitialStateType } from './store/reducers' import type { SupabaseGridProps } from './types' import { getGridColumns } from './utils/gridColumns' -function onLoadStorage(storageRef: string, tableName: string, schema?: string | null) { - const storageKey = getStorageKey(STORAGE_KEY_PREFIX, storageRef) +export function loadTableEditorSortsAndFiltersFromLocalStorage( + projectRef: string, + tableName: string, + schema?: string | null +) { + const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) const jsonStr = localStorage.getItem(storageKey) if (!jsonStr) return const json = JSON.parse(jsonStr) @@ -45,7 +49,11 @@ async function initTable( filter?: string[] // Comes directly from URL param ): Promise<{ savedState: { sorts?: string[]; filters?: string[] } }> { const savedState = props.projectRef - ? onLoadStorage(props.projectRef, props.table.name, props.table.schema) + ? loadTableEditorSortsAndFiltersFromLocalStorage( + props.projectRef, + props.table.name, + props.table.schema + ) : undefined // Check for saved state on initial load and also, load sort and filters via URL param only if given diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 1e6a6e8c5be..25fddc31895 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -1,12 +1,20 @@ +import type { PostgresRelationship, PostgresTable } from '@supabase/postgres-meta' import AwesomeDebouncePromise from 'awesome-debounce-promise' -import type { Filter } from 'components/grid/types' import { compact } from 'lodash' -import type { Dictionary } from 'types' + +import type { Filter } from 'components/grid/types' +import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import { + ForeignKeyConstraint, + ForeignKeyConstraintsData, +} from 'data/database/foreign-key-constraints-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { TableLike } from 'hooks/misc/useTable' +import type { Dictionary, SchemaView } from 'types' import { FilterOperatorOptions } from './components/header/filter/Filter.constants' import { STORAGE_KEY_PREFIX } from './constants' import { InitialStateType } from './store/reducers' import type { Sort, SupabaseGridProps, SupaColumn, SupaTable } from './types' - /** * Ensure that if editable is false, we should remove all editing actions * to prevent rare-case bugs with the UI @@ -124,6 +132,54 @@ export function parseSupaTable( } } +export function getSupaTable({ + selectedTable, + entityType, + foreignKeyMeta, + encryptedColumns, +}: { + selectedTable: TableLike + entityType?: ENTITY_TYPE + foreignKeyMeta: ForeignKeyConstraintsData + encryptedColumns: string[] +}) { + // [Joshen] We can tweak below to eventually support composite keys as the data + // returned from foreignKeyMeta should be easy to deal with, rather than pg-meta + const formattedRelationships = ( + ('relationships' in selectedTable && selectedTable.relationships) || + [] + ).map((relationship: PostgresRelationship) => { + const relationshipMeta = foreignKeyMeta.find( + (fk: ForeignKeyConstraint) => fk.id === relationship.id + ) + return { + ...relationship, + deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, + } + }) + + const isViewSelected = + entityType === ENTITY_TYPE.VIEW || entityType === ENTITY_TYPE.MATERIALIZED_VIEW + const isForeignTableSelected = entityType === ENTITY_TYPE.FOREIGN_TABLE + + return !isViewSelected && !isForeignTableSelected + ? parseSupaTable( + { + table: selectedTable as PostgresTable, + columns: (selectedTable as PostgresTable).columns ?? [], + primaryKeys: (selectedTable as PostgresTable).primary_keys ?? [], + relationships: formattedRelationships, + }, + encryptedColumns + ) + : parseSupaTable({ + table: selectedTable as SchemaView, + columns: (selectedTable as SchemaView).columns ?? [], + primaryKeys: [], + relationships: [], + }) +} + export const saveStorageDebounced = AwesomeDebouncePromise(saveStorage, 500) function saveStorage( diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 7738c14b7d3..2948e7ff0f5 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -1,9 +1,9 @@ import { ArrowRight } from 'lucide-react' -import Link from 'next/link' import type { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useTableQuery } from 'data/tables/table-query' import { useTablesQuery } from 'data/tables/tables-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' @@ -62,11 +62,20 @@ export const ForeignKeyFormatter = (props: Props) => { className="translate-y-[2px]" style={{ padding: '3px' }} > - { - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') @@ -42,7 +42,11 @@ const PolicyTableRowHeader = ({ return (
      - + {table.rls_enabled ? (
      @@ -53,7 +57,7 @@ const PolicyTableRowHeader = ({
      )}

      {table.name}

      - +
      {isTableLocked && ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 1242353bfee..519347c7858 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -30,12 +30,12 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useForeignTablesQuery } from 'data/foreign-tables/foreign-tables-query' import { useMaterializedViewsQuery } from 'data/materialized-views/materialized-views-query' +import { usePrefetchEditorTablePage } from 'data/prefetchers/project.$ref.editor.$id' import { useTablesQuery } from 'data/tables/tables-query' import { useViewsQuery } from 'data/views/views-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button, Checkbox_Shadcn_, @@ -74,6 +74,8 @@ const TableList = ({ const { ref } = useParams() const { project } = useProjectContext() + const prefetchEditorTablePage = usePrefetchEditorTablePage() + const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const [filterString, setFilterString] = useState('') @@ -476,6 +478,9 @@ const TableList = ({ onClick={() => router.push(`/project/${project?.ref}/editor/${x.id}`) } + onMouseEnter={() => + prefetchEditorTablePage({ id: x.id ? String(x.id) : undefined }) + } >

      View in Table Editor

      diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index ffdb8ae54d9..6dc505446c8 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Collapsible } from 'ui' import type { WrapperMeta } from './Wrappers.types' @@ -119,11 +120,15 @@ const WrapperRow = ({
      {wrapper.tables ? ( wrapper.tables.map((table: any) => ( - +
      {table.name}
      - +
      )) ) : (

      No tables available

      diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx index 19b5708425e..e77fbe11591 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx @@ -1,8 +1,9 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/router' +import { useParams } from 'common' import Panel from 'components/ui/Panel' +import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Auth, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons' import { Button } from 'ui' @@ -10,8 +11,7 @@ import APIKeys from './APIKeys' import GetStartedHero from './GetStartedHero' const NewProjectPanel = () => { - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const { projectAuthAll: authEnabled, @@ -47,7 +47,7 @@ const NewProjectPanel = () => {
      diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 431ee1b7d5b..693f25789e5 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,4 +1,4 @@ -import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta' +import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryKey, useQueryClient } from '@tanstack/react-query' import { find, isUndefined } from 'lodash' @@ -8,14 +8,11 @@ import { toast } from 'sonner' import { useParams } from 'common' import { SupabaseGrid } from 'components/grid/SupabaseGrid' -import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' +import { getSupaTable } from 'components/grid/SupabaseGrid.utils' import { SupaTable } from 'components/grid/types' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' -import { - ForeignKeyConstraint, - useForeignKeyConstraintsQuery, -} from 'data/database/foreign-key-constraints-query' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useEncryptedColumnsQuery } from 'data/encrypted-columns/encrypted-columns-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { sqlKeys } from 'data/sql/keys' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' @@ -28,13 +25,12 @@ import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' -import type { Dictionary, SchemaView } from 'types' +import type { Dictionary } from 'types' import { Button } from 'ui' import GridHeaderActions from './GridHeaderActions' import { TableGridSkeletonLoader } from './LoadingState' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' -import { useEncryptedColumns } from './SidePanelEditor/SidePanelEditor.utils' import TableDefinition from './TableDefinition' export interface TableGridEditorProps { @@ -62,8 +58,10 @@ const TableGridEditor = ({ const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const isReadOnly = !canEditTables && !canEditColumns - const encryptedColumns = useEncryptedColumns({ - schemaName: selectedTable?.schema, + const { data: encryptedColumns } = useEncryptedColumnsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: selectedTable?.schema, tableName: selectedTable?.name, }) @@ -148,43 +146,16 @@ const TableGridEditor = ({ const isViewSelected = entityType?.type === ENTITY_TYPE.VIEW || entityType?.type === ENTITY_TYPE.MATERIALIZED_VIEW const isTableSelected = entityType?.type === ENTITY_TYPE.TABLE - const isForeignTableSelected = entityType?.type === ENTITY_TYPE.FOREIGN_TABLE const isLocked = EXCLUDED_SCHEMAS.includes(entityType?.schema ?? '') const canEditViaTableEditor = isTableSelected && !isLocked - // [Joshen] We can tweak below to eventually support composite keys as the data - // returned from foreignKeyMeta should be easy to deal with, rather than pg-meta - const formattedRelationships = ( - ('relationships' in selectedTable && selectedTable.relationships) || - [] - ).map((relationship: PostgresRelationship) => { - const relationshipMeta = foreignKeyMeta.find( - (fk: ForeignKeyConstraint) => fk.id === relationship.id - ) - return { - ...relationship, - deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - } + const gridTable = getSupaTable({ + encryptedColumns, + foreignKeyMeta, + selectedTable, + entityType: entityType?.type, }) - const gridTable = - !isViewSelected && !isForeignTableSelected - ? parseSupaTable( - { - table: selectedTable as PostgresTable, - columns: (selectedTable as PostgresTable).columns ?? [], - primaryKeys: (selectedTable as PostgresTable).primary_keys ?? [], - relationships: formattedRelationships, - }, - encryptedColumns - ) - : parseSupaTable({ - table: selectedTable as SchemaView, - columns: (selectedTable as SchemaView).columns ?? [], - primaryKeys: [], - relationships: [], - }) - const gridKey = `${selectedTable.schema}_${selectedTable.name}` const onTableCreated = (table: PostgresTable) => { diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 159f5bb7dae..8b807064bc6 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,6 +1,8 @@ +import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' + import type { Route } from 'components/ui/ui.types' +import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import type { Project } from 'data/projects/project-detail-query' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { Auth, Database, @@ -11,8 +13,8 @@ import { Storage, TableEditor, } from 'icons' +import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { ICON_SIZE, ICON_STROKE_WIDTH } from './NavigationBar' -import { Settings, FileText, List, Lightbulb, Blocks } from 'lucide-react' export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP @@ -24,6 +26,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => label: 'Table Editor', icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`), + linkComponent: , }, { key: 'sql', diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx index ca8b6143169..758deedc315 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx @@ -1,13 +1,18 @@ import { noop } from 'lodash' import Link from 'next/link' -import { AnchorHTMLAttributes, forwardRef } from 'react' -import { cn } from 'ui' -import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' +import { + AnchorHTMLAttributes, + cloneElement, + ComponentPropsWithoutRef, + forwardRef, + isValidElement, +} from 'react' +import { cn, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' import type { Route } from 'components/ui/ui.types' -import { useAppStateSnapshot } from 'state/app-state' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' interface NavigationIconButtonProps extends AnchorHTMLAttributes { route: Route @@ -41,8 +46,18 @@ const NavigationIconLink = forwardRef>( + function LinkComponent(props, ref) { + if (route.linkComponent && isValidElement(route.linkComponent)) { + return cloneElement(route.linkComponent, { ...props, ref }) + } + + return + } + ) + const linkContent = ( - {route.label} - + ) if (!allowNavPanelToExpand) { diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 2a2f463ea13..98cd65ec2b4 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -46,6 +46,7 @@ import { } from 'ui' import { useProjectContext } from '../ProjectLayout/ProjectContext' import { Markdown } from 'components/interfaces/Markdown' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' export interface EntityListItemProps { id: number @@ -277,8 +278,9 @@ const EntityListItem: ItemRenderer = ({ } return ( - = ({ )} - + ) } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index c859e571e41..82ff2ae0376 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -12,10 +12,10 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { useSchemasQuery } from 'data/database/schemas-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useTableQuery } from 'data/tables/table-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' +import useTable from 'hooks/misc/useTable' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { @@ -96,15 +96,7 @@ const TableEditorMenu = () => { ) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) - const { data: selectedTable } = useTableQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - id: Number(id), - }, - // only run if we have a selected table - { enabled: Boolean(id) } - ) + const { data: selectedTable } = useTable(id !== undefined ? Number(id) : undefined) useEffect(() => { if (selectedTable) { diff --git a/apps/studio/components/ui/ui.types.ts b/apps/studio/components/ui/ui.types.ts index 8ede34b7f18..2aee180817c 100644 --- a/apps/studio/components/ui/ui.types.ts +++ b/apps/studio/components/ui/ui.types.ts @@ -6,4 +6,5 @@ export interface Route { icon: ReactNode link?: string disabled?: boolean + linkComponent?: ReactNode } diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index e288f81fa0e..4506ea58ec9 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -32,13 +32,7 @@ with records as ( 'create materialized view ', concat(nc.nspname, '.', c.relname), ' as', pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) ) - when 'f' then pg_temp.pg_get_tabledef( - concat(nc.nspname), - concat(c.relname), - false, - 'FKEYS_INTERNAL', - 'NO_TRIGGERS' - ) + when 'f' then concat('create foreign table ', nc.nspname, '.', c.relname, ' ( ... )') when 'p' then pg_temp.pg_get_tabledef( concat(nc.nspname), concat(c.relname), diff --git a/apps/studio/data/database/foreign-key-constraints-query.ts b/apps/studio/data/database/foreign-key-constraints-query.ts index 3c348b11ec2..7c34c7894fd 100644 --- a/apps/studio/data/database/foreign-key-constraints-query.ts +++ b/apps/studio/data/database/foreign-key-constraints-query.ts @@ -1,5 +1,12 @@ -import { UseQueryOptions } from '@tanstack/react-query' -import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query' +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { sqlKeys } from 'data/sql/keys' +import { + executeSql, + ExecuteSqlData, + ExecuteSqlError, + useExecuteSqlQuery, +} from '../sql/execute-sql-query' type GetForeignKeyConstraintsVariables = { schema?: string @@ -96,6 +103,22 @@ export type ForeignKeyConstraintsVariables = GetForeignKeyConstraintsVariables & export type ForeignKeyConstraintsData = ForeignKeyConstraint[] export type ForeignKeyConstraintsError = ExecuteSqlError +function select( + data: + | { + result: any + } + | undefined +) { + return (data?.result ?? []).map((foreignKey: ForeignKeyConstraintRaw) => { + return { + ...foreignKey, + source_columns: foreignKey.source_columns.replace('{', '').replace('}', '').split(','), + target_columns: foreignKey.target_columns.replace('{', '').replace('}', '').split(','), + } + }) +} + export const useForeignKeyConstraintsQuery = < TData extends ForeignKeyConstraintsData = ForeignKeyConstraintsData, >( @@ -107,19 +130,30 @@ export const useForeignKeyConstraintsQuery = < projectRef, connectionString, sql: getForeignKeyConstraintsQuery({ schema }), - queryKey: ['foreign-key-constraints'], + queryKey: ['foreign-key-constraints', schema], }, { - select(data) { - return ((data as any)?.result ?? []).map((foreignKey: ForeignKeyConstraintRaw) => { - return { - ...foreignKey, - source_columns: foreignKey.source_columns.replace('{', '').replace('}', '').split(','), - target_columns: foreignKey.target_columns.replace('{', '').replace('}', '').split(','), - } - }) - }, + select, ...options, } ) } + +export function prefetchForeignKeyConstraints( + client: QueryClient, + { projectRef, connectionString, schema }: ForeignKeyConstraintsVariables +) { + return client + .fetchQuery(sqlKeys.query(projectRef, ['foreign-key-constraints', schema]), ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: getForeignKeyConstraintsQuery({ schema }), + queryKey: ['foreign-key-constraints', schema], + }, + signal + ) + ) + .then((data) => select(data) as ForeignKeyConstraintsData) +} diff --git a/apps/studio/data/database/schemas-query.ts b/apps/studio/data/database/schemas-query.ts index 911f8fc88fa..22e27c76092 100644 --- a/apps/studio/data/database/schemas-query.ts +++ b/apps/studio/data/database/schemas-query.ts @@ -2,7 +2,12 @@ import pgMeta from '@supabase/pg-meta' import { QueryClient, UseQueryOptions } from '@tanstack/react-query' import { z } from 'zod' -import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from 'data/sql/execute-sql-query' +import { + executeSql, + ExecuteSqlData, + ExecuteSqlError, + useExecuteSqlQuery, +} from 'data/sql/execute-sql-query' import { sqlKeys } from 'data/sql/keys' export type SchemasVariables = { @@ -39,3 +44,20 @@ export const useSchemasQuery = ( export function invalidateSchemasQuery(client: QueryClient, projectRef: string | undefined) { return client.invalidateQueries(sqlKeys.query(projectRef, ['schemas', 'list'])) } + +export function prefetchSchemas( + client: QueryClient, + { projectRef, connectionString }: SchemasVariables +) { + return client.fetchQuery(sqlKeys.query(projectRef, ['schemas', 'list']), ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: pgMetaSchemasList.sql, + queryKey: ['schemas', 'list'], + }, + signal + ) + ) +} diff --git a/apps/studio/data/encrypted-columns/encrypted-columns-query.ts b/apps/studio/data/encrypted-columns/encrypted-columns-query.ts new file mode 100644 index 00000000000..e21e2685631 --- /dev/null +++ b/apps/studio/data/encrypted-columns/encrypted-columns-query.ts @@ -0,0 +1,69 @@ +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' +import { encryptedColumnKeys } from './keys' + +export type EncryptedColumnsVariables = { + projectRef?: string + connectionString?: string + schema?: string + tableName?: string +} + +export async function getEncryptedColumns( + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables, + signal?: AbortSignal +) { + if (!tableName) return [] + + try { + const encryptedColumns = await executeSql( + { + projectRef, + connectionString, + sql: `select column_name as name from information_schema.columns where table_schema = '${schema}' and table_name = 'decrypted_${tableName}' and column_name like 'decrypted_%'`, + queryKey: ['encrypted-columns', tableName], + }, + signal + ) + return encryptedColumns.result.map((column: any) => column.name.split('decrypted_')[1]) + } catch (error) { + console.error('Error fetching encrypted columns', error) + return [] + } +} + +export type EncryptedColumnsData = Awaited> +export type EncryptedColumnsError = ResponseError + +export const useEncryptedColumnsQuery = ( + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + encryptedColumnKeys.list(projectRef, schema, tableName), + ({ signal }) => + getEncryptedColumns({ projectRef, connectionString, schema, tableName }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) + +export function prefetchEncryptedColumns( + client: QueryClient, + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables +) { + return client.fetchQuery(encryptedColumnKeys.list(projectRef, schema, tableName), ({ signal }) => + getEncryptedColumns( + { + projectRef, + connectionString, + schema, + tableName, + }, + signal + ) + ) +} diff --git a/apps/studio/data/encrypted-columns/keys.ts b/apps/studio/data/encrypted-columns/keys.ts new file mode 100644 index 00000000000..e73d19d7a53 --- /dev/null +++ b/apps/studio/data/encrypted-columns/keys.ts @@ -0,0 +1,7 @@ +export const encryptedColumnKeys = { + list: ( + projectRef: string | undefined, + schema: string | undefined, + tableName: string | undefined + ) => ['projects', projectRef, 'encrypted-columns', schema, tableName] as const, +} diff --git a/apps/studio/data/entity-types/entity-type-query.ts b/apps/studio/data/entity-types/entity-type-query.ts index c650159ce52..b0e26577073 100644 --- a/apps/studio/data/entity-types/entity-type-query.ts +++ b/apps/studio/data/entity-types/entity-type-query.ts @@ -1,5 +1,12 @@ -import { UseQueryOptions } from '@tanstack/react-query' -import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query' +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { sqlKeys } from 'data/sql/keys' +import { + executeSql, + ExecuteSqlData, + ExecuteSqlError, + useExecuteSqlQuery, +} from '../sql/execute-sql-query' import { ENTITY_TYPE } from './entity-type-constants' type EntityTypeArgs = { @@ -75,3 +82,22 @@ export const useEntityTypeQuery = + executeSql( + { + projectRef, + connectionString, + sql: entityTypeSqlQuery({ id }), + queryKey: ['entity-type', id], + }, + signal + ) + ) + .then((data: { result: EntityTypeData[] }) => data?.result?.[0] ?? null) +} diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index 3931fb892e9..91d1b549bd9 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -1,7 +1,8 @@ -import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' +import { QueryClient, useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' import { executeSql, ExecuteSqlVariables } from 'data/sql/execute-sql-query' import type { Entity } from './entity-type-query' import { entityTypeKeys } from './keys' +import { ENTITY_TYPE } from './entity-type-constants' export type EntityTypesVariables = { projectRef?: string @@ -29,7 +30,7 @@ export async function getEntityTypes( limit = 100, page = 0, sort = 'alphabetical', - filterTypes, + filterTypes = Object.values(ENTITY_TYPE), }: EntityTypesVariables, signal?: AbortSignal ) { @@ -57,7 +58,7 @@ export async function getEntityTypes( pg_namespace nc join pg_class c on nc.oid = c.relnamespace where - c.relkind in (${filterTypes === undefined ? `'r', 'v', 'm', 'f', 'p'` : filterTypes.map((x) => `'${x}'`).join(', ')}) + c.relkind in (${filterTypes.map((x) => `'${x}'`).join(', ')}) and not pg_is_other_temp_schema(nc.oid) and ( pg_has_role(c.relowner, 'USAGE') @@ -155,3 +156,34 @@ export const useEntityTypesQuery = ( ...options, } ) + +export function prefetchEntityTypes( + client: QueryClient, + { + projectRef, + connectionString, + schemas = ['public'], + search, + limit = 100, + sort, + filterTypes, + }: Omit +) { + return client.prefetchInfiniteQuery( + entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), + ({ signal, pageParam }) => + getEntityTypes( + { + projectRef, + connectionString, + schemas, + search, + limit, + page: pageParam, + sort, + filterTypes, + }, + signal + ) + ) +} diff --git a/apps/studio/data/foreign-tables/foreign-table-query.ts b/apps/studio/data/foreign-tables/foreign-table-query.ts index d6e3af3748f..cc7370d4ff8 100644 --- a/apps/studio/data/foreign-tables/foreign-table-query.ts +++ b/apps/studio/data/foreign-tables/foreign-table-query.ts @@ -1,5 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { foreignTableKeys } from './keys' @@ -56,3 +56,12 @@ export const useForeignTableQuery = ( ...options, } ) + +export function prefetchForeignTable( + client: QueryClient, + { projectRef, connectionString, id }: ForeignTableVariables +) { + return client.fetchQuery(foreignTableKeys.foreignTable(projectRef, id), ({ signal }) => + getForeignTable({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/data/materialized-views/materialized-view-query.ts b/apps/studio/data/materialized-views/materialized-view-query.ts index 0b714667b9b..1d6e2411e24 100644 --- a/apps/studio/data/materialized-views/materialized-view-query.ts +++ b/apps/studio/data/materialized-views/materialized-view-query.ts @@ -1,5 +1,6 @@ import type { PostgresMaterializedView } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' + import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { materializedViewKeys } from './keys' @@ -59,3 +60,12 @@ export const useMaterializedViewQuery = ( ...options, } ) + +export function prefetchMaterializedView( + client: QueryClient, + { projectRef, connectionString, id }: MaterializedViewVariables +) { + return client.fetchQuery(materializedViewKeys.materializedView(projectRef, id), ({ signal }) => + getMaterializedView({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx new file mode 100644 index 00000000000..ad4ed80b1fe --- /dev/null +++ b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx @@ -0,0 +1,182 @@ +import { useQueryClient } from '@tanstack/react-query' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ComponentProps, PropsWithChildren, useCallback } from 'react' + +import { loadTableEditorSortsAndFiltersFromLocalStorage } from 'components/grid/SupabaseGrid' +import { + formatFilterURLParams, + formatSortURLParams, + getSupaTable, +} from 'components/grid/SupabaseGrid.utils' +import { Filter, Sort } from 'components/grid/types' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { + ForeignKeyConstraintsData, + prefetchForeignKeyConstraints, +} from 'data/database/foreign-key-constraints-query' +import { prefetchEncryptedColumns } from 'data/encrypted-columns/encrypted-columns-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { Entity, prefetchEntityType } from 'data/entity-types/entity-type-query' +import { ForeignTableData, prefetchForeignTable } from 'data/foreign-tables/foreign-table-query' +import { + MaterializedViewData, + prefetchMaterializedView, +} from 'data/materialized-views/materialized-view-query' +import { prefetchTableRows } from 'data/table-rows/table-rows-query' +import { prefetchTable, TableData } from 'data/tables/table-query' +import { prefetchView, ViewData } from 'data/views/view-query' +import { useFlag } from 'hooks/ui/useFlag' +import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' +import { TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE } from 'state/table-editor' + +export function usePrefetchEditorTablePage() { + const router = useRouter() + const queryClient = useQueryClient() + const { project } = useProjectContext() + const roleImpersonationState = useRoleImpersonationStateSnapshot() + const tableEditorPrefetchingEnabled = useFlag('tableEditorPrefetching') + + return useCallback( + ({ id: _id, filters, sorts }: { id?: string; filters?: Filter[]; sorts?: Sort[] }) => { + if (!tableEditorPrefetchingEnabled) return + + const id = _id ? Number(_id) : undefined + if (!project || !id || isNaN(id)) return + + // Prefetch the code + router.prefetch(`/project/${project.ref}/editor/${id}`) + + // Prefetch the data + prefetchEntityType(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + .then((entity) => { + let promises: [ + Promise, + Promise, + Promise, + ( + | Promise + | undefined + ), + ] = [ + Promise.resolve(entity), + prefetchForeignKeyConstraints(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + schema: entity?.schema, + }), + prefetchEncryptedColumns(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + schema: entity?.schema, + tableName: entity?.name, + }), + undefined, + ] + + switch (entity?.type) { + case ENTITY_TYPE.TABLE: + case ENTITY_TYPE.PARTITIONED_TABLE: + promises[3] = prefetchTable(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.VIEW: + promises[3] = prefetchView(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.MATERIALIZED_VIEW: + promises[3] = prefetchMaterializedView(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.FOREIGN_TABLE: + promises[3] = prefetchForeignTable(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + } + + return Promise.all(promises) + }) + .then(([entity, foreignKeyConstraints, encryptedColumns, table]) => { + if (entity && table) { + const supaTable = getSupaTable({ + selectedTable: table, + encryptedColumns, + foreignKeyMeta: foreignKeyConstraints, + entityType: entity.type, + }) + + const { sorts: localSorts = [], filters: localFilters = [] } = + loadTableEditorSortsAndFiltersFromLocalStorage( + project.ref, + entity.name, + entity.schema + ) ?? {} + + prefetchTableRows(queryClient, { + queryKey: [supaTable.schema, supaTable.name], + projectRef: project?.ref, + connectionString: project?.connectionString, + table: supaTable, + sorts: sorts ?? formatSortURLParams(supaTable.name, localSorts), + filters: filters ?? formatFilterURLParams(localFilters), + page: 1, + limit: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE, + impersonatedRole: roleImpersonationState.role, + }) + } + }) + }, + [project, queryClient, router] + ) +} + +type LinkProps = ComponentProps + +interface EditorTablePageLinkProps extends Omit { + projectRef?: string + id?: string + sorts?: Sort[] + filters?: Filter[] + href?: LinkProps['href'] +} + +export function EditorTablePageLink({ + projectRef, + id, + sorts, + filters, + href, + children, + ...props +}: PropsWithChildren) { + const prefetch = usePrefetchEditorTablePage() + + return ( + prefetch({ id, sorts, filters })} + {...props} + > + {children} + + ) +} diff --git a/apps/studio/data/prefetchers/project.$ref.editor.tsx b/apps/studio/data/prefetchers/project.$ref.editor.tsx new file mode 100644 index 00000000000..e21d6fed797 --- /dev/null +++ b/apps/studio/data/prefetchers/project.$ref.editor.tsx @@ -0,0 +1,64 @@ +import { useQueryClient } from '@tanstack/react-query' +import Link from 'next/link' +import { ComponentProps, PropsWithChildren, useCallback } from 'react' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { prefetchSchemas } from 'data/database/schemas-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { prefetchEntityTypes } from 'data/entity-types/entity-types-infinite-query' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useFlag } from 'hooks/ui/useFlag' +import { useRouter } from 'next/router' + +export function usePrefetchEditorIndexPage() { + const router = useRouter() + const queryClient = useQueryClient() + const { project } = useProjectContext() + const tableEditorPrefetchingEnabled = useFlag('tableEditorPrefetching') + + const [entityTypesSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( + 'table-editor-sort', + 'alphabetical' + ) + + return useCallback(() => { + if (!tableEditorPrefetchingEnabled || !project) return + + // Prefetch code + router.prefetch(`/project/${project.ref}/editor`) + + // Prefetch data + prefetchSchemas(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + }) + prefetchEntityTypes(queryClient, { + projectRef: project?.ref, + connectionString: project?.connectionString, + sort: entityTypesSort, + filterTypes: Object.values(ENTITY_TYPE), + }) + }, [entityTypesSort, project, queryClient, router]) +} + +type LinkProps = ComponentProps + +interface EditorIndexPageLinkProps extends Omit { + projectRef?: string + href?: LinkProps['href'] +} + +export function EditorIndexPageLink({ + href, + projectRef, + children, + ...props +}: PropsWithChildren) { + const prefetch = usePrefetchEditorIndexPage() + + return ( + + {children} + + ) +} diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index dfa8e2b3dc4..39ecb7dce9a 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -1,4 +1,4 @@ -import type { QueryKey, UseQueryOptions } from '@tanstack/react-query' +import type { QueryClient, QueryKey, UseQueryOptions } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' import { Query } from 'components/grid/query/Query' @@ -7,13 +7,17 @@ import { JSON_TYPES, TEXT_TYPES, } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' +import { sqlKeys } from 'data/sql/keys' import { KB } from 'lib/constants' import { ImpersonationRole, ROLE_IMPERSONATION_NO_RESULTS, wrapWithRoleImpersonation, } from 'lib/role-impersonation' -import { useIsRoleImpersonationEnabled } from 'state/role-impersonation-state' +import { + isRoleImpersonationEnabled, + useIsRoleImpersonationEnabled, +} from 'state/role-impersonation-state' import { ExecuteSqlData, ExecuteSqlError, @@ -204,6 +208,21 @@ export type TableRowsVariables = GetTableRowsArgs & { export type TableRowsData = TableRows export type TableRowsError = ExecuteSqlError +function getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }: TableRowsVariables) { + return [ + ...(queryKey ?? []), + { + table: { + name: table?.name, + schema: table?.schema, + columns: table?.columns.map((c) => c.name), + }, + impersonatedRole, + ...args, + }, + ] +} + export const useTableRowsQuery = ( { projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables, options: UseQueryOptions = {} @@ -218,18 +237,7 @@ export const useTableRowsQuery = ( projectRef: projectRef ?? 'ref', role: impersonatedRole, }), - queryKey: [ - ...(queryKey ?? []), - { - table: { - name: table?.name, - schema: table?.schema, - columns: table?.columns.map((c) => c.name), - }, - impersonatedRole, - ...args, - }, - ], + queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }), isRoleImpersonationEnabled, }, { @@ -247,3 +255,26 @@ export const useTableRowsQuery = ( } ) } + +export function prefetchTableRows( + client: QueryClient, + { projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables +) { + return client.fetchQuery( + sqlKeys.query(projectRef, getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args })), + ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: wrapWithRoleImpersonation(getTableRowsSqlQuery({ table, ...args }), { + projectRef: projectRef ?? 'ref', + role: impersonatedRole, + }), + queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }), + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole), + }, + signal + ) + ) +} diff --git a/apps/studio/data/tables/table-query.ts b/apps/studio/data/tables/table-query.ts index 2e8bea2b5dd..36b5d4195a3 100644 --- a/apps/studio/data/tables/table-query.ts +++ b/apps/studio/data/tables/table-query.ts @@ -1,6 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' -import { useCallback } from 'react' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import { tableKeys } from './keys' @@ -57,23 +56,11 @@ export const useTableQuery = ( } ) -/** - * useGetTable - * Tries to get a table from the react-query cache, or loads it from the server if it's not cached. - */ -export function useGetTable({ - projectRef, - connectionString, -}: Pick) { - const queryClient = useQueryClient() - - return useCallback( - (id: NonNullable) => { - return queryClient.fetchQuery({ - queryKey: tableKeys.table(projectRef, id), - queryFn: ({ signal }) => getTable({ id, projectRef, connectionString }, signal), - }) - }, - [connectionString, projectRef, queryClient] +export function prefetchTable( + client: QueryClient, + { projectRef, connectionString, id }: TableVariables +) { + return client.fetchQuery(tableKeys.table(projectRef, id), ({ signal }) => + getTable({ id, projectRef, connectionString }, signal) ) } diff --git a/apps/studio/data/views/view-query.ts b/apps/studio/data/views/view-query.ts index ae6d11b6c2d..9fc16cf2299 100644 --- a/apps/studio/data/views/view-query.ts +++ b/apps/studio/data/views/view-query.ts @@ -1,5 +1,5 @@ import type { PostgresView } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' @@ -55,3 +55,12 @@ export const useViewQuery = ( ...options, } ) + +export function prefetchView( + client: QueryClient, + { projectRef, connectionString, id }: ViewVariables +) { + return client.fetchQuery(viewKeys.view(projectRef, id), ({ signal }) => + getView({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/hooks/misc/useTable.ts b/apps/studio/hooks/misc/useTable.ts index 17bf4042be1..891d25fe9df 100644 --- a/apps/studio/hooks/misc/useTable.ts +++ b/apps/studio/hooks/misc/useTable.ts @@ -25,18 +25,7 @@ function useTable(id?: number) { id: entity?.id, }, { - enabled: entity?.type === ENTITY_TYPE.TABLE, - } - ) - - const partitionTableResult = useTableQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - id: entity?.id, - }, - { - enabled: entity?.type === ENTITY_TYPE.PARTITIONED_TABLE, + enabled: entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE, } ) @@ -75,9 +64,8 @@ function useTable(id?: number) { switch (entity?.type) { case ENTITY_TYPE.TABLE: - return tableResult case ENTITY_TYPE.PARTITIONED_TABLE: - return partitionTableResult + return tableResult case ENTITY_TYPE.VIEW: return viewResult case ENTITY_TYPE.MATERIALIZED_VIEW: diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index 69240d08908..9c232bb3883 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -31,7 +31,7 @@ const TableEditorPage: NextPageWithLayout = () => { if (tables.length > 0) { router.push(`/project/${projectRef}/editor/${tables[0].id}`) } else { - router.push(`/project/${projectRef}/editor/`) + router.push(`/project/${projectRef}/editor`) } }} /> diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index a55245d9ba3..05367f0153f 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -6,6 +6,8 @@ import { PropsWithChildren, createContext, useContext, useRef } from 'react' import type { Dictionary } from 'types' import { proxy, useSnapshot } from 'valtio' +export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100 + type ForeignKeyState = { foreignKey: ForeignKey row: Dictionary @@ -63,7 +65,7 @@ export const createTableEditorState = () => { setPage: (page: number) => { state.page = page }, - rowsPerPage: 100, + rowsPerPage: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE, setRowsPerPage: (rowsPerPage: number) => { state.rowsPerPage = rowsPerPage }, From 3a3360bbd0eb7d3a6de80fe9728f21cc91ccb18d Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 24 Oct 2024 21:32:47 +0800 Subject: [PATCH 106/255] Fix all usage of button tooltip (#30076) * Fix all usage of button tooltip * PRETTY --- .../components/interfaces/Auth/Hooks/HookCard.tsx | 4 +++- .../Auth/Policies/AIPolicyEditorPanel/index.tsx | 4 +++- .../Auth/Policies/PolicyTableRow/PolicyRow.tsx | 3 +-- .../PolicyTableRow/PolicyTableRowHeader.tsx | 8 ++++++-- .../Auth/RedirectUrls/RedirectUrlList.tsx | 8 ++++++-- .../interfaces/Auth/Users/UserOverview.tsx | 8 ++++++-- .../BranchManagement/BranchManagement.tsx | 8 ++++++-- .../interfaces/BranchManagement/EmptyStates.tsx | 4 +++- .../DataWarehouse/CreateWarehouseAccessToken.tsx | 4 +++- .../Functions/FunctionsList/FunctionsList.tsx | 4 +++- .../interfaces/Database/Tables/ColumnList.tsx | 4 +++- .../interfaces/Database/Tables/TableList.tsx | 4 +++- .../EdgeFunctionDetails/EdgeFunctionDetails.tsx | 4 +++- .../EdgeFunctionSecrets/EdgeFunctionSecret.tsx | 4 +++- .../EdgeFunctionSecrets/EdgeFunctionSecrets.tsx | 4 +++- .../Integrations/CronJobs/CreateCronJobSheet.tsx | 4 +++- .../interfaces/Integrations/IntegrationPanels.tsx | 5 ++++- .../Organization/OAuthApps/OAuthApps.tsx | 4 +++- .../interfaces/Settings/API/HardenAPIModal.tsx | 15 +++++++++++++-- .../interfaces/Settings/Database/BannedIPs.tsx | 4 +++- .../NetworkRestrictions/NetworkRestrictions.tsx | 4 +++- .../DeleteProjectPanel/DeleteProjectButton.tsx | 4 +++- .../TransferProjectButton.tsx | 4 +++- .../InstanceConfiguration.tsx | 4 +++- .../InfrastructureConfiguration/MapView.tsx | 4 +++- .../layouts/ProjectLayout/PauseFailedState.tsx | 7 ++++++- .../PausedState/ProjectPausedState.tsx | 4 +++- .../layouts/ProjectLayout/RestoreFailedState.tsx | 7 ++++++- .../layouts/ProjectLayout/RestoringState.tsx | 7 ++++++- .../layouts/ReportsLayout/ReportsMenu.tsx | 4 +++- .../layouts/StorageLayout/StorageMenu.tsx | 4 +++- .../layouts/TableEditorLayout/TableEditorMenu.tsx | 4 +++- .../StorageExplorer/FileExplorerHeader.tsx | 8 ++++++-- .../FileExplorerHeaderSelection.tsx | 2 ++ apps/studio/pages/project/[ref]/auth/policies.tsx | 1 - 35 files changed, 134 insertions(+), 41 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx index 9850a9a51da..80dc0264277 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HookCard.tsx @@ -100,7 +100,9 @@ export const HookCard = ({ hook, onSelect }: HookCardProps) => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to configure auth hooks', + text: !canUpdateAuthHook + ? 'You need additional permissions to configure auth hooks' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index b11333148e0..ba6aa9ab2ae 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -804,7 +804,9 @@ export const AIPolicyEditorPanel = memo(function ({ tooltip={{ content: { side: 'top', - text: 'You need additional permissions to update policies', + text: !canUpdatePolicies + ? 'You need additional permissions to update policies' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index ae34fb099d4..9944a44b4c4 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import Panel from 'components/ui/Panel' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -20,7 +20,6 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, } from 'ui' -import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' interface PolicyRowProps { policy: PostgresPolicy diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 1a954208d97..4823c3bc72e 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -79,7 +79,9 @@ const PolicyTableRowHeader = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to toggle RLS', + text: !canToggleRLS + ? 'You need additional permissions to toggle RLS' + : undefined, }, }} > @@ -94,7 +96,9 @@ const PolicyTableRowHeader = ({ content: { side: 'bottom', text: !canToggleRLS - ? 'You need additional permissions to create RLS policies' + ? !canToggleRLS + ? 'You need additional permissions to create RLS policies' + : undefined : undefined, }, }} diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index 8ee007d54b4..304cac3af4d 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -72,7 +72,9 @@ export const RedirectUrlList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to remove redirect URLs', + text: !canUpdateConfig + ? 'You need additional permissions to remove redirect URLs' + : undefined, }, }} icon={} @@ -88,7 +90,9 @@ export const RedirectUrlList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update redirect URLs', + text: !canUpdateConfig + ? 'You need additional permissions to update redirect URLs' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 7dd8d768955..6754170842f 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -521,6 +521,8 @@ const RowAction = ({ } className?: string }) => { + const disabled = button?.disabled ?? false + return (
      @@ -535,11 +537,13 @@ const RowAction = ({ icon={success ? : button.icon} loading={button.isLoading ?? false} onClick={button.onClick} - disabled={button?.disabled ?? false} + disabled={disabled} tooltip={{ content: { side: 'bottom', - text: `You need additional permissions to ${button.text.toLowerCase()}`, + text: disabled + ? `You need additional permissions to ${button.text.toLowerCase()}` + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 435e55c6fa4..366d9e47ff2 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -216,7 +216,9 @@ const BranchManagement = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create branches', + text: !canCreateBranches + ? 'You need additional permissions to create branches' + : undefined, }, }} > @@ -271,7 +273,9 @@ const BranchManagement = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to disable branching', + text: !canDisableBranching + ? 'You need additional permissions to disable branching' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx index 4432578919c..264af8aa091 100644 --- a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx @@ -29,7 +29,9 @@ export const BranchingEmptyState = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to enable branching', + text: !canEnableBranching + ? 'You need additional permissions to enable branching' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx index af20da05fdd..cbb8960f1fa 100644 --- a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx @@ -40,7 +40,9 @@ const CreateWarehouseAccessToken = ({ onSubmit, loading, open, setOpen }: Create tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create access tokens', + text: !canCreateAccessTokens + ? 'You need additional permissions to create access tokens' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 65f4bf35f2c..521ea2827c9 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -142,7 +142,9 @@ const FunctionsList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create functions', + text: !canCreateFunctions + ? 'You need additional permissions to create functions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index cf3977ff8f1..b3c3482ee26 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -72,7 +72,9 @@ const ColumnList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create columns', + text: !canUpdateColumns + ? 'You need additional permissions to create columns' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 519347c7858..d7245c85ec8 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -275,7 +275,9 @@ const TableList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create tables', + text: !canUpdateTables + ? 'You need additional permissions to create tables' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 3936710c68e..aca153eb113 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -257,7 +257,9 @@ const EdgeFunctionDetails = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to delete edge functions', + text: !canUpdateEdgeFunction + ? 'You need additional permissions to delete edge functions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 516266d5c20..ba5212aafdf 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -42,7 +42,9 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) side: 'bottom', text: isReservedSecret ? 'This is a reserved secret and cannot be deleted' - : 'You need additional permissions to delete edge function secrets', + : !canUpdateSecrets + ? 'You need additional permissions to delete edge function secrets' + : undefined, }, }} /> diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index c5183483b9e..fa151ea1294 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -81,7 +81,9 @@ const EdgeFunctionSecrets = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update edge function secrets', + text: !canUpdateSecrets + ? 'You need additional permissions to update edge function secrets' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index 02391b94026..caa6249c62d 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -316,7 +316,9 @@ export const CreateCronJobSheet = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to enable database extensions', + text: !canToggleExtensions + ? 'You need additional permissions to enable database extensions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx index dc03ab01576..8af175bded5 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx +++ b/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -303,7 +303,10 @@ const EmptyIntegrationConnection = React.forwardRef< disabled={disabled} onClick={() => onClick()} tooltip={{ - content: { side: 'bottom', text: 'Additional permissions required to add connection' }, + content: { + side: 'bottom', + text: disabled ? 'Additional permissions required to add connection' : undefined, + }, }} > Add new project connection diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx index c7c5c69de5b..375284c0b9b 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.tsx @@ -85,7 +85,9 @@ const OAuthApps = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create apps', + text: !canCreateOAuthApps + ? 'You need additional permissions to create apps' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/API/HardenAPIModal.tsx b/apps/studio/components/interfaces/Settings/API/HardenAPIModal.tsx index 038c8878f33..cabc8158278 100644 --- a/apps/studio/components/interfaces/Settings/API/HardenAPIModal.tsx +++ b/apps/studio/components/interfaces/Settings/API/HardenAPIModal.tsx @@ -184,7 +184,13 @@ export const HardenAPIModal = ({ visible, onClose }: HardenAPIModalProps) => { disabled={hasAPISchema && isAPISchemaExposed} loading={isCreatingAPISchema} tooltip={{ - content: { side: 'right', text: 'Schema has already been created and exposed' }, + content: { + side: 'right', + text: + hasAPISchema && isAPISchemaExposed + ? 'Schema has already been created and exposed' + : undefined, + }, }} > Create and expose schema to Data API @@ -249,7 +255,12 @@ export const HardenAPIModal = ({ visible, onClose }: HardenAPIModalProps) => { className="w-min" disabled={!isPublicSchemaExposed} loading={isUpdatingConfig} - tooltip={{ content: { side: 'right', text: 'Public schema no longer exposed' } }} + tooltip={{ + content: { + side: 'right', + text: !isPublicSchemaExposed ? 'Public schema no longer exposed' : undefined, + }, + }} onClick={onSelectRemovePublicSchema} > Remove public schema from exposed schemas diff --git a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx index 2c942355681..cc291744349 100644 --- a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx +++ b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx @@ -99,7 +99,9 @@ const BannedIPs = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to unban networks', + text: !canUnbanNetworks + ? 'You need additional permissions to unban networks' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 19a6f94a6fd..b39170c9349 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -55,7 +55,9 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update network restrictions', + text: disabled + ? 'You need additional permissions to update network restrictions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index 9025f11222e..427a7d66422 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -27,7 +27,9 @@ const DeleteProjectButton = ({ type = 'danger' }: DeleteProjectButtonProps) => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to delete this project', + text: !canDeleteProject + ? 'You need additional permissions to delete this project' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx index c1f6a75e03d..9777aca6547 100644 --- a/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx +++ b/apps/studio/components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx @@ -79,7 +79,9 @@ const TransferProjectButton = () => { side: 'bottom', text: !canTransferProject ? 'You need additional permissions to transfer this project' - : 'Project transfers are temporarily disabled, please try again later.', + : disableProjectTransfer + ? 'Project transfers are temporarily disabled, please try again later.' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index ac9c1837de8..81db395954a 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -231,7 +231,9 @@ const InstanceConfigurationUI = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to deploy replicas', + text: !canManageReplicas + ? 'You need additional permissions to deploy replicas' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index a7ddcaaa19b..b39fe8e455c 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -358,7 +358,9 @@ const MapView = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to deploy replicas', + text: !canManageReplicas + ? 'You need additional permissions to deploy replicas' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index 88eb1049fe9..9e8e36faf38 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -75,7 +75,12 @@ const PauseFailedState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 9c2e528eb2a..a64d8ccff44 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -276,7 +276,9 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to resume this project', + text: !canResumeProject + ? 'You need additional permissions to resume this project' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index 8b416e8a6a0..6ba94a4833b 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -75,7 +75,12 @@ const RestoreFailedState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx index 4f95cd0f2c4..5f4bf20491e 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx @@ -126,7 +126,12 @@ const RestoringState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index a4e768de272..dbb19165499 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -136,7 +136,9 @@ const ReportsMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create custom reports', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx index a30790de3b6..d47cd875f5f 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx @@ -79,7 +79,9 @@ const StorageMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create buckets', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 82ff2ae0376..215893ac1d3 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -136,7 +136,9 @@ const TableEditorMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create tables', + text: !canCreateTables + ? 'You need additional permissions to create tables' + : undefined, }, }} > diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx index 5e31b0477ca..e9b7bf980c9 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -434,7 +434,9 @@ const FileExplorerHeader = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to upload files', + text: !canUpdateStorage + ? 'You need additional permissions to upload files' + : undefined, }, }} > @@ -448,7 +450,9 @@ const FileExplorerHeader = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create folders', + text: !canUpdateStorage + ? 'You need additional permissions to create folders' + : undefined, }, }} > diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx index c1131dd741b..8b6daad9cf2 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx @@ -48,6 +48,7 @@ const FileExplorerHeaderSelection = () => { } type="primary" + disabled={!canUpdateFiles} onClick={() => setSelectedItemsToDelete(selectedItems)} tooltip={{ content: { @@ -62,6 +63,7 @@ const FileExplorerHeaderSelection = () => { } type="primary" + disabled={!canUpdateFiles} onClick={() => setSelectedItemsToMove(selectedItems)} tooltip={{ content: { diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 1c3bb93b5a8..1d13326de51 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -9,7 +9,6 @@ import Policies from 'components/interfaces/Auth/Policies/Policies' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' import NoPermission from 'components/ui/NoPermission' import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' From bb0306061d63cee89ae2a64731c28f5ce94d706f Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Thu, 24 Oct 2024 12:56:20 -0230 Subject: [PATCH 107/255] Chore/update to next 15 (#30075) * Update Next.js docs for v15 * More awaiting * remove async for pages router * fix for dashboard connection guide --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> --- apps/docs/DEVELOPERS.md | 2 +- apps/docs/README.md | 2 +- apps/docs/components/MDX/oauth_pkce_flow.mdx | 2 +- .../docs/content/guides/auth/auth-helpers/nextjs.mdx | 4 ++-- apps/docs/content/guides/auth/passwords.mdx | 4 ++-- apps/docs/content/guides/auth/quickstarts/nextjs.mdx | 9 ++++++--- .../guides/auth/server-side/creating-a-client.mdx | 4 ++-- apps/docs/content/guides/auth/server-side/nextjs.mdx | 12 ++++++------ .../content/guides/auth/social-login/auth-google.mdx | 4 ++-- .../guides/getting-started/quickstarts/nextjs.mdx | 8 ++++---- .../guides/getting-started/tutorials/with-nextjs.mdx | 8 ++++---- .../content/nextjs/app/supabasejs/content.tsx | 2 +- 12 files changed, 32 insertions(+), 29 deletions(-) diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index ed8fa91080d..2d9f7cb3684 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -14,7 +14,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE ## Local setup -[supabase.com/docs](https://supabase.com/docs) is a Next.JS site. You can get setup by following the same steps for all of our other Next.JS projects: +[supabase.com/docs](https://supabase.com/docs) is a Next.js site. You can get setup by following the same steps for all of our other Next.js projects: 1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md) 2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false` diff --git a/apps/docs/README.md b/apps/docs/README.md index c8a0cd91609..7803882d6c7 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -31,7 +31,7 @@ We use documentation specifications which can be used to generate human-readable - CLISpec (custom to Supabase): for CLI commands and usage. The benefit of using custom specifications is that we can generate many other types from a strict schema (eg, HTML and manpages). -It also means that we can switch to any documentation system we want. On this site we use Next.JS, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. +It also means that we can switch to any documentation system we want. On this site we use Next.js, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. ## Contributing diff --git a/apps/docs/components/MDX/oauth_pkce_flow.mdx b/apps/docs/components/MDX/oauth_pkce_flow.mdx index f4bb7617a45..02beff87623 100644 --- a/apps/docs/components/MDX/oauth_pkce_flow.mdx +++ b/apps/docs/components/MDX/oauth_pkce_flow.mdx @@ -68,7 +68,7 @@ export async function GET(request: Request) { const next = searchParams.get('next') ?? '/' if (code) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer diff --git a/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx index b0f1f3cbd30..5b64c6a4f0e 100644 --- a/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx @@ -20,7 +20,7 @@ The `auth-helpers` package has been replaced with the `@supabase/ssr` package. W size="medium" className="text-foreground-light border-b mt-8 pb-2" > - + ({ cookies: () => cookieStore }) await supabase.auth.exchangeCodeForSession(code) } diff --git a/apps/docs/content/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx index c671ac333dc..9d3d0f83bf1 100644 --- a/apps/docs/content/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -184,7 +184,7 @@ export async function GET(request: NextRequest) { redirectTo.pathname = next if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -648,7 +648,7 @@ export async function GET(request: NextRequest) { redirectTo.pathname = next if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 9a7117247af..6db4230d2ac 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -72,9 +72,7 @@ hideToc: true Start the development server, go to http://localhost:3000 in a browser, and you should see the contents of `app/page.tsx`. - To Sign Up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`. - - Check out the [Supabase Auth docs](https://supabase.com/docs/guides/auth#authentication) for more authentication methods. + To sign up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`. @@ -88,3 +86,8 @@ hideToc: true + +## Learn more + +- [Setting up Server-Side Auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs) for a Next.js deep dive +- [Supabase Auth docs](https://supabase.com/docs/guides/auth#authentication) for more Supabase authentication methods diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index b9e5d752c51..7d8e3f0ecc3 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -171,8 +171,8 @@ export function createClient() { import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -export function createClient() { - const cookieStore = cookies() +export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/apps/docs/content/guides/auth/server-side/nextjs.mdx b/apps/docs/content/guides/auth/server-side/nextjs.mdx index 384f78b869a..8189371cc26 100644 --- a/apps/docs/content/guides/auth/server-side/nextjs.mdx +++ b/apps/docs/content/guides/auth/server-side/nextjs.mdx @@ -125,8 +125,8 @@ export function createClient() { import { createServerClient, type CookieOptions } from '@supabase/ssr' import { cookies } from 'next/headers' -export function createClient() { - const cookieStore = cookies() +export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -337,7 +337,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export async function login(formData: FormData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -357,7 +357,7 @@ export async function login(formData: FormData) { } export async function signup(formData: FormData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -429,7 +429,7 @@ export async function GET(request: NextRequest) { const next = searchParams.get('next') ?? '/' if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -482,7 +482,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export default async function PrivatePage() { - const supabase = createClient() + const supabase = await createClient() const { data, error } = await supabase.auth.getUser() if (error || !data?.user) { diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index 8ebb31ea2c3..c79d6d15300 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -341,9 +341,9 @@ Most web apps and websites can utilize Google's [personalized sign-in buttons](h // Use 'nonce' when invoking the supabase.auth.signInWithIdToken() method ``` -### One-tap with NextJS +### One-tap with Next.js -If you're integrating Google One-Tap with your NextJS application, you can refer to the example below to get started: +If you're integrating Google One-Tap with your Next.js application, you can refer to the example below to get started: ```tsx 'use client' diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index 720ea4966e5..1b11196eaeb 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -8,7 +8,7 @@ hideToc: true - + Go to [database.new](https://database.new) and create a new Supabase project. @@ -118,7 +118,7 @@ hideToc: true import { createClient } from '@/utils/supabase/server'; export default async function Notes() { - const supabase = createClient(); + const supabase = await createClient(); const { data: notes } = await supabase.from("notes").select(); return
      {JSON.stringify(notes, null, 2)}
      @@ -129,8 +129,8 @@ hideToc: true import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' - export function createClient() { - const cookieStore = cookies() + export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index b849b2b1af0..02d0c095791 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -385,7 +385,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export async function login(formData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -405,7 +405,7 @@ export async function login(formData) { } export async function signup(formData) { - const supabase = createClient() + const supabase = await createClient() const data = { email: formData.get('email'), @@ -512,7 +512,7 @@ export async function GET(request) { redirectTo.searchParams.delete('type') if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -720,7 +720,7 @@ import AccountForm from './account-form' import { createClient } from '@/utils/supabase/server' export default async function Account() { - const supabase = createClient() + const supabase = await createClient() const { data: { user }, diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx index 9a2cd70d26c..2ff11fda1fc 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx @@ -35,7 +35,7 @@ import { createClient } from '@/utils/supabase/server' import { cookies } from 'next/headers' export default async function Page() { - const cookieStore = cookies() + const cookieStore = await cookies() const supabase = createClient(cookieStore) const { data: todos } = await supabase.from('todos').select() From 9a3d0992c304f70dc0b83146a41bfda8f935f6a3 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Thu, 24 Oct 2024 17:38:51 +0200 Subject: [PATCH 108/255] www features section (#30072) * init features page * fix filtering * seo boostg * add features to sitemap * update copywriting * improve features descriptions * more studio features * moar featuress * better back to features * prev next feature nav * clean * improve page footer * fixes * stuff * sync filters with query params on page load * better mobile layout * 16px mobile input fontsize * add cover images or videos to features * improve a11y * update sitemap * last changes * update schema definitions --- .../components/Blog/ShareArticleActions.tsx | 6 +- apps/www/components/Nav/MobileMenu.tsx | 46 +- apps/www/components/Nav/ProductDropdown.tsx | 94 +- apps/www/components/PrevNextFeatureNav.tsx | 132 ++ apps/www/data/features.tsx | 1651 +++++++++++++++++ apps/www/internals/generate-sitemap.mjs | 2 + apps/www/pages/features.tsx | 219 +++ apps/www/pages/features/[slug].tsx | 237 +++ .../auth-captcha-protection-light.png | Bin 0 -> 59792 bytes .../features/auth-captcha-protection.png | Bin 0 -> 57927 bytes .../auto-generated-graphql-api-light.png | Bin 0 -> 81202 bytes .../features/auto-generated-graphql-api.png | Bin 0 -> 81134 bytes .../auto-generated-rest-api-light.png | Bin 0 -> 95645 bytes .../features/auto-generated-rest-api.png | Bin 0 -> 78493 bytes .../public/images/features/backups-light.png | Bin 0 -> 91181 bytes apps/www/public/images/features/backups.png | Bin 0 -> 79187 bytes .../images/features/branching-light.png | Bin 0 -> 91873 bytes apps/www/public/images/features/branching.png | Bin 0 -> 85515 bytes .../images/features/custom-domains-light.png | Bin 0 -> 75602 bytes .../public/images/features/custom-domains.png | Bin 0 -> 75689 bytes .../features/database-webhooks-light.png | Bin 0 -> 69306 bytes .../images/features/database-webhooks.png | Bin 0 -> 64094 bytes .../images/features/email-login-light.png | Bin 0 -> 69914 bytes .../public/images/features/email-login.png | Bin 0 -> 69560 bytes .../images/features/features-cover-dark.svg | 502 +++++ .../images/features/features-cover-light.svg | 500 +++++ .../images/features/file-storage-light.png | Bin 0 -> 139800 bytes .../public/images/features/file-storage.png | Bin 0 -> 102780 bytes .../images/features/logs-analytics-light.png | Bin 0 -> 96306 bytes .../public/images/features/logs-analytics.png | Bin 0 -> 92413 bytes .../images/features/management-api-light.png | Bin 0 -> 97950 bytes .../public/images/features/management-api.png | Bin 0 -> 98174 bytes .../features/network-restrictions-light.png | Bin 0 -> 86632 bytes .../images/features/network-restrictions.png | Bin 0 -> 77791 bytes .../images/features/phone-login-light.png | Bin 0 -> 63663 bytes .../public/images/features/phone-login.png | Bin 0 -> 60087 bytes .../features/policy-templates-light.png | Bin 0 -> 112998 bytes .../images/features/policy-templates.png | Bin 0 -> 114513 bytes .../features/postgres-database-light.png | Bin 0 -> 103920 bytes .../images/features/postgres-database.png | Bin 0 -> 90251 bytes .../features/postgres-extensions-light.png | Bin 0 -> 90787 bytes .../images/features/postgres-extensions.png | Bin 0 -> 89450 bytes .../images/features/postgres-roles-light.png | Bin 0 -> 87536 bytes .../public/images/features/postgres-roles.png | Bin 0 -> 89088 bytes .../features/reports-and-metrics-light.png | Bin 0 -> 70075 bytes .../images/features/reports-and-metrics.png | Bin 0 -> 74068 bytes .../features/s3-compatibility-light.png | Bin 0 -> 100608 bytes .../images/features/s3-compatibility.png | Bin 0 -> 92213 bytes ...security-and-performance-advisor-light.png | Bin 0 -> 93851 bytes .../security-and-performance-advisor.png | Bin 0 -> 86208 bytes .../images/features/social-login-light.png | Bin 0 -> 55196 bytes .../public/images/features/social-login.png | Bin 0 -> 54314 bytes .../images/features/sql-editor-light.png | Bin 0 -> 94977 bytes .../www/public/images/features/sql-editor.png | Bin 0 -> 84239 bytes .../images/features/ssl-enforcement-light.png | Bin 0 -> 83625 bytes .../images/features/ssl-enforcement.png | Bin 0 -> 73480 bytes .../features/terraform-provider-light.png | Bin 0 -> 58337 bytes .../images/features/terraform-provider.png | Bin 0 -> 57843 bytes .../features/visual-schema-designer-light.png | Bin 0 -> 104309 bytes .../features/visual-schema-designer.png | Bin 0 -> 91362 bytes apps/www/public/rss.xml | 9 +- apps/www/public/sitemap_www.xml | 948 +++++++++- 62 files changed, 4298 insertions(+), 48 deletions(-) create mode 100644 apps/www/components/PrevNextFeatureNav.tsx create mode 100644 apps/www/data/features.tsx create mode 100644 apps/www/pages/features.tsx create mode 100644 apps/www/pages/features/[slug].tsx create mode 100644 apps/www/public/images/features/auth-captcha-protection-light.png create mode 100644 apps/www/public/images/features/auth-captcha-protection.png create mode 100644 apps/www/public/images/features/auto-generated-graphql-api-light.png create mode 100644 apps/www/public/images/features/auto-generated-graphql-api.png create mode 100644 apps/www/public/images/features/auto-generated-rest-api-light.png create mode 100644 apps/www/public/images/features/auto-generated-rest-api.png create mode 100644 apps/www/public/images/features/backups-light.png create mode 100644 apps/www/public/images/features/backups.png create mode 100644 apps/www/public/images/features/branching-light.png create mode 100644 apps/www/public/images/features/branching.png create mode 100644 apps/www/public/images/features/custom-domains-light.png create mode 100644 apps/www/public/images/features/custom-domains.png create mode 100644 apps/www/public/images/features/database-webhooks-light.png create mode 100644 apps/www/public/images/features/database-webhooks.png create mode 100644 apps/www/public/images/features/email-login-light.png create mode 100644 apps/www/public/images/features/email-login.png create mode 100644 apps/www/public/images/features/features-cover-dark.svg create mode 100644 apps/www/public/images/features/features-cover-light.svg create mode 100644 apps/www/public/images/features/file-storage-light.png create mode 100644 apps/www/public/images/features/file-storage.png create mode 100644 apps/www/public/images/features/logs-analytics-light.png create mode 100644 apps/www/public/images/features/logs-analytics.png create mode 100644 apps/www/public/images/features/management-api-light.png create mode 100644 apps/www/public/images/features/management-api.png create mode 100644 apps/www/public/images/features/network-restrictions-light.png create mode 100644 apps/www/public/images/features/network-restrictions.png create mode 100644 apps/www/public/images/features/phone-login-light.png create mode 100644 apps/www/public/images/features/phone-login.png create mode 100644 apps/www/public/images/features/policy-templates-light.png create mode 100644 apps/www/public/images/features/policy-templates.png create mode 100644 apps/www/public/images/features/postgres-database-light.png create mode 100644 apps/www/public/images/features/postgres-database.png create mode 100644 apps/www/public/images/features/postgres-extensions-light.png create mode 100644 apps/www/public/images/features/postgres-extensions.png create mode 100644 apps/www/public/images/features/postgres-roles-light.png create mode 100644 apps/www/public/images/features/postgres-roles.png create mode 100644 apps/www/public/images/features/reports-and-metrics-light.png create mode 100644 apps/www/public/images/features/reports-and-metrics.png create mode 100644 apps/www/public/images/features/s3-compatibility-light.png create mode 100644 apps/www/public/images/features/s3-compatibility.png create mode 100644 apps/www/public/images/features/security-and-performance-advisor-light.png create mode 100644 apps/www/public/images/features/security-and-performance-advisor.png create mode 100644 apps/www/public/images/features/social-login-light.png create mode 100644 apps/www/public/images/features/social-login.png create mode 100644 apps/www/public/images/features/sql-editor-light.png create mode 100644 apps/www/public/images/features/sql-editor.png create mode 100644 apps/www/public/images/features/ssl-enforcement-light.png create mode 100644 apps/www/public/images/features/ssl-enforcement.png create mode 100644 apps/www/public/images/features/terraform-provider-light.png create mode 100644 apps/www/public/images/features/terraform-provider.png create mode 100644 apps/www/public/images/features/visual-schema-designer-light.png create mode 100644 apps/www/public/images/features/visual-schema-designer.png diff --git a/apps/www/components/Blog/ShareArticleActions.tsx b/apps/www/components/Blog/ShareArticleActions.tsx index b95a64a94d1..284080999bb 100644 --- a/apps/www/components/Blog/ShareArticleActions.tsx +++ b/apps/www/components/Blog/ShareArticleActions.tsx @@ -1,22 +1,24 @@ import Link from 'next/link' -import { IconLinkedinSolid, IconTwitterX, IconYCombinator } from 'ui' +import { cn, IconLinkedinSolid, IconTwitterX, IconYCombinator } from 'ui' const ShareArticleActions = ({ title, slug, iconSize = 20, basePath = 'https://supabase.com/blog/', + className, }: { title: string slug: string iconSize?: number basePath?: string + className?: string }) => { const permalink = encodeURIComponent(`${basePath}${slug}`) const encodedTitle = encodeURIComponent(title) return ( -
      +
      { const AccordionMenuItem = ({ menuItem }: any) => ( <> {menuItem.title === 'Product' ? ( - Object.values(menuItem.subMenu)?.map((component: any) => ( - - )) + <> + {Object.values(menuItem.subMenu)?.map((component: any) => ( + + ))} + +
      + Features + + Explore everything you can do with Supabase. + +
      + + + ) : menuItem.title === 'Developers' ? (
      {menuItem.subMenu['navigation'].map((column: any) => ( @@ -70,7 +96,7 @@ const MobileMenu = ({ open, setOpen, isDarkMode, menu }: Props) => { ))}
      -
    -
    -
    +
    +
    - Customer Stories - +
    +
    + +
    +
    + Features + + Explore everything you can do with Supabase. + +
    +
    + -
      - {CustomersData.slice(0, isTablet ? 2 : 3).map((customer) => ( -
    • - -
      - {customer.title} -
      -
      -

      - {customer.title} -

      -
      - -
    • - ))} -
    +
    + + Customer Stories + + +
      + {CustomersData.slice(0, isTablet ? 2 : 2).map((customer) => ( +
    • + +
      + {customer.title} +
      +
      +

      + {customer.title} +

      +
      + +
    • + ))} +
    +
    -

    +

    {ComparisonsData.label}

      diff --git a/apps/www/components/PrevNextFeatureNav.tsx b/apps/www/components/PrevNextFeatureNav.tsx new file mode 100644 index 00000000000..bc47384ad43 --- /dev/null +++ b/apps/www/components/PrevNextFeatureNav.tsx @@ -0,0 +1,132 @@ +import { PropsWithChildren, useEffect, useState } from 'react' +import Link, { LinkProps } from 'next/link' +import { useRouter } from 'next/router' +import { ArrowLeft, ArrowRight, List } from 'lucide-react' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +import { type FeatureType, features } from '~/data/features' + +interface Props { + className?: string + wrapperClassName?: string + currentFeature: FeatureType + prevLink: string + nextLink: string +} + +const buttonClassName = + 'relative z-10 flex items-center gap-1 px-2 pointer-events-auto overflow-hidden !h-[30px] !min-w-[30px] !max-w-[30px] py-1 justify-center rounded-full border bg-default hover:bg-surface-100 hover:text-foreground hover:border-foreground-lighter transition-all' +const iconClassName = 'className="w-4 h-4 flex-shrink-0' + +const PrevNextFeatureNav: React.FC = ({ + className, + wrapperClassName, + currentFeature, + prevLink, + nextLink, + ...props +}) => { + const [open, setOpen] = useState(false) + const router = useRouter() + + useEffect(() => { + function handleRouteChange() { + setOpen(false) + } + + router.events.on('routeChangeComplete', handleRouteChange) + return () => { + router.events.off('routeChangeComplete', handleRouteChange) + } + }, [router.events]) + + return ( +
      +
      + + + Previous feature + + + + + + + + + All Features + + + + + {features + .sort((a, b) => { + if (a['title'] < b['title']) return -1 + if (a['title'] > b['title']) return 1 + return 0 + }) + .map((feature) => ( + + + + {feature.title} + + + ))} + + + + + Next feature + + +
      +
      + ) +} + +interface ButtonLinkProps extends LinkProps { + className?: string +} + +const ButtonLink: React.FC> = ({ + href, + className, + children, +}) => { + return ( + + {children} + + ) +} + +export default PrevNextFeatureNav diff --git a/apps/www/data/features.tsx b/apps/www/data/features.tsx new file mode 100644 index 00000000000..6d582b9df6c --- /dev/null +++ b/apps/www/data/features.tsx @@ -0,0 +1,1651 @@ +import { + ChartScatter, + Database, + FileCode2, + Lock, + Cloud, + UploadCloud, + Image, + MessageCircle, + ShieldCheck, + Package, + Users, + Folders, + RectangleEllipsis, + Braces, + Globe, + UserX, + GitBranch, + DatabaseZap, + Mail, + Smartphone, + Server, + Activity, + BarChart, + Brain, + Puzzle, + DatabaseBackup, + ShieldPlus, + Terminal, + Zap, +} from 'lucide-react' +import { PRODUCT, PRODUCT_SHORTNAMES } from 'shared-data/products' +import type { LucideIcon } from 'lucide-react' + +enum ADDITIONAL_PRODUCTS { + PLATFORM = 'platform management', + STUDIO = 'studio', +} + +export type FeatureProductType = PRODUCT | ADDITIONAL_PRODUCTS + +export type FeatureType = { + /** + * name of the feature + */ + title: string + /** + * subtitle will be displayed in the feature card, after the title + */ + subtitle: string + /** + * The body content in the feature page + */ + description: string + /** + * slug of the feature page + */ + slug: string + /** + * icon will be displayed in the feature card + */ + icon: string | LucideIcon + /** + * Each feature belongs to one or more products + */ + products: FeatureProductType[] + /** + * heroImage can be an absolute path to either: + * - an image + * - a youtube video + * - a video sourced from supabase storage + */ + heroImage: string + /** + * light-mode version image, if heroImage is not a video + */ + heroImageLight?: string + /** + * url to docs or blog page for this feature + */ + docsUrl?: string +} + +export const features: FeatureType[] = [ + // Database + { + title: 'Postgres database', + subtitle: 'Every project is a full Postgres database.', + description: ` +Every Supabase project is a dedicated [Postgres database](https://www.postgresql.org/) - _"a powerful, open source object-relational database system with over 35 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance"_. + +It's 100% portable, which means you can easily migrate your data to and from other Postgres databases, ensuring that your data is never locked into a proprietary system. + +## Key benefits +1. Advanced data types: Utilize JSON, arrays, and custom types to store complex data structures efficiently. +2. Powerful indexing: Improve query performance with various indexing options, including B-tree, Hash, and GiST. +3. Full SQL support: Execute complex queries and leverage advanced SQL features for data manipulation and analysis. +4. ACID compliance: Ensure data integrity and consistency with Postgres's transactional capabilities. +5. Extensibility: Add custom functions and extensions to extend database functionality. +6. Scalability: Handle growing data volumes and user loads with Postgres's proven scalability. +7. Community support: Benefit from a large, active community and extensive documentation. +`, + icon: Database, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/postgres-database.png', + heroImageLight: '/images/features/postgres-database-light.png', + docsUrl: 'https://supabase.com/docs/guides/database/overview', + slug: 'postgres-database', + }, + { + title: 'Vector database', + subtitle: 'Store vector embeddings right next to the rest of your data.', + description: ` +Supabase provides an open source toolkit for developing AI applications using Postgres and pgvector. Use the Supabase client libraries to store, index, and query your vector embeddings at scale. + +## Key benefits +1. Unified data storage: Keep vector embeddings and relational data in one place, simplifying your data architecture. +2. Efficient similarity search: Perform fast and accurate similarity searches on high-dimensional data. +3. Seamless integration: Easily incorporate vector search into existing applications without additional infrastructure. +4. Scalability: Handle large volumes of vector data with Postgres's proven scalability. +5. Cost-effective: Eliminate the need for separate vector databases, reducing infrastructure costs. +6. Advanced querying: Combine vector similarity search with traditional SQL queries for powerful hybrid searches. +7. Real-time updates: Continuously update and query vector data without complex ETL processes. +`, + icon: ChartScatter, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: 'https://www.youtube-nocookie.com/embed/ibzlEQmgPPY', + docsUrl: 'https://supabase.com/docs/guides/ai', + slug: 'vector-database', + }, + { + title: 'Auto-generated REST API via PostgREST', + subtitle: 'RESTful APIs auto-generated from your database.', + description: ` +Supabase automatically generates a comprehensive RESTful API from your database schema, powered by PostgREST. This feature dramatically accelerates development by eliminating the need to write boilerplate server-side code for basic CRUD operations. + +## Key benefits +1. Rapid development: Instantly access your data through a RESTful API without writing any backend code. +2. Automatic updates: API endpoints automatically reflect changes in your database schema, ensuring consistency. +3. Flexible querying: Use powerful query parameters to filter, sort, and paginate data directly from API calls. +4. Custom operations: Easily expose custom database functions as API endpoints for complex operations. +5. Secure by default: Leverage Postgres's role-based access control for built-in API security. +6. Performance: Benefit from PostgREST's optimized query execution for efficient API responses. +7. Standardized interface: Work with a well-documented, consistent API across all your database tables. +`, + icon: FileCode2, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/auto-generated-rest-api.png', + heroImageLight: '/images/features/auto-generated-rest-api-light.png', + docsUrl: 'https://supabase.com/docs/guides/api#rest-api-overview', + slug: 'auto-generated-rest-api', + }, + { + title: 'Auto-generated GraphQL API via pg_graphql', + subtitle: 'Fast GraphQL APIs using our custom Postgres GraphQL extension.', + description: ` +Supabase offers lightning-fast GraphQL APIs through its custom-built Postgres GraphQL extension, _pg_graphql_. + +## Key benefits +1. Simplified data fetching: Retrieve exactly the data you need in a single request, reducing over-fetching and under-fetching. +2. Automatic schema generation: GraphQL schema is automatically derived from your Postgres schema, ensuring consistency. +3. Real-time updates: Combine with Supabase's real-time features for live data subscriptions. +4. Improved performance: Leverage Postgres's query optimization capabilities for efficient GraphQL resolvers. +5. Type safety: Benefit from GraphQL's strong typing system, reducing runtime errors and improving developer experience. +6. Flexible querying: Allow clients to request complex, nested data structures in a single query. +7. Built-in documentation: Utilize GraphQL's introspection feature for self-documenting APIs. + +## The auto-generated GraphQL API is particularly beneficial for: +- Single-page applications (SPAs) requiring efficient data loading +- Mobile apps needing to minimize data transfer +- Complex UIs with varying data requirements +- Projects requiring rapid iteration on data models +- Applications with deeply nested data structures +`, + icon: Braces, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/auto-generated-graphql-api.png', + heroImageLight: '/images/features/auto-generated-graphql-api-light.png', + docsUrl: 'https://supabase.com/docs/guides/graphql/api', + slug: 'auto-generated-graphql-api', + }, + { + title: 'Database backups', + subtitle: 'Projects are backed up daily with Point in Time recovery options.', + description: ` +Supabase offers comprehensive database backup solutions to protect your data and ensure business continuity. These include daily backups and Point-in-Time Recovery (PITR), providing robust safeguards against various disaster scenarios. + +## Key features +1. Daily backups: Automatically created for all Pro, Team, and Enterprise plans. + - Pro: 7 days retention + - Team: 14 days retention + - Enterprise: Up to 30 days retention +2. Point-in-Time Recovery (PITR): Available as an add-on for finer granularity. + - Allows restoration to any point within seconds + - Achieves a Recovery Point Objective (RPO) of two minutes in worst-case scenarios +3. Backup types: + - Logical backups: Used for smaller databases (0-15GB) + - Physical backups: Used for larger databases (>15GB) and projects with PITR or read replicas +4. Flexible restoration options: Restore to a specific daily backup or a precise moment with PITR. +5. Automated process: Backups are managed and monitored by the Supabase team. + +## Database backups are crucial for: +- Protecting against accidental data loss or corruption +- Recovering from infrastructure failures +- Meeting regulatory compliance requirements +- Facilitating data migration or cloning for development + +Supabase's backup solutions provide a balance of security, flexibility, and ease of use, ensuring your data remains protected and recoverable as your project grows. +`, + icon: DatabaseBackup, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/backups.png', + heroImageLight: '/images/features/backups-light.png', + docsUrl: 'https://supabase.com/docs/guides/platform/backups', + slug: 'database-backups', + }, + { + title: 'Custom domains', + subtitle: 'White-label the Supabase APIs for a branded experience.', + description: ` +Supabase's custom domain feature allows you to use your own domain for Supabase services, enhancing your application's branding and professionalism. Available as an add-on for paid plans, this feature offers two options: custom domains and vanity subdomains. + +## Benefits: +- Branded experience for OAuth consent screens and API endpoints +- Improved portability between Supabase projects +- Consistent domain usage across development, staging, and production + +## Custom domains are particularly useful for: +- Applications using Supabase Auth with OAuth +- APIs for third-party integrations or webhooks +- Long-term API versioning and management + +By using custom domains, you create a more cohesive brand experience and gain flexibility in managing your application's infrastructure. +`, + icon: Globe, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/custom-domains.png', + heroImageLight: '/images/features/custom-domains-light.png', + docsUrl: 'https://supabase.com/docs/guides/platform/custom-domains', + slug: 'custom-domains', + }, + { + title: 'Network restrictions', + subtitle: 'Restrict IP ranges that can connect to your database.', + description: ` +Supabase's Network Restrictions feature allows you to control which IP addresses or ranges can access your database, enhancing your application's security and helping you meet compliance requirements. + +## Key features +1. IP-based access control: Limit database connections to specific IPv4 and IPv6 ranges. +2. Flexible configuration: Easily manage restrictions via Dashboard or CLI. +3. Environment-specific rules: Set different access policies for development, staging, and production. +4. Pre-authentication barrier: Restrictions are enforced before database credential checks. + +## Benefits: +- Enhanced security: Reduce the attack surface by limiting access to trusted IP addresses. +- Compliance support: Meet regulatory requirements for data access control in various industries. +- Granular control: Tailor access rules to your specific network infrastructure needs. +- Additional protection layer: Mitigate risks even if database credentials are compromised. + +## Network Restrictions are particularly valuable for: +- Enterprise applications handling sensitive data +- Financial services and healthcare systems with strict compliance requirements +- Government and public sector projects needing stringent security measures +- Multi-tenant SaaS platforms isolating client data access + +By implementing Network Restrictions, you create a more secure environment for your data, demonstrating a commitment to protection that builds trust with users and stakeholders. This feature provides a crucial first line of defense against unauthorized access attempts, complementing other security measures like strong authentication and encryption. +`, + icon: UserX, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/network-restrictions.png', + heroImageLight: '/images/features/network-restrictions-light.png', + docsUrl: 'https://supabase.com/docs/guides/platform/network-restrictions', + slug: 'network-restrictions', + }, + { + title: 'SSL enforcement', + subtitle: 'Enforce secure connections to your Postgres clients.', + description: ` +Supabase's Postgres SSL Enforcement feature allows you to mandate SSL connections to your database, enhancing security by encrypting data in transit and protecting against potential attacks. + +## Key features +1. Optional enforcement: Enable or disable SSL requirements to balance security and client compatibility. +2. Comprehensive protection: Applies to both direct Postgres and connection pooler (Supavisor) connections. +3. Flexible configuration: Manage settings via Dashboard or CLI. +4. Support for various SSL modes: Including the highly secure 'verify-full' mode. + +## Benefits: +- Data encryption: Protect all information transmitted between your application and the database. +- Integrity assurance: Prevent data tampering during transmission. +- Authentication: Ensure connections are made to the genuine database server. +- Compliance support: Meet industry standards and regulatory requirements for data protection. +- Simplified security: Automatically enforce best practices for secure database connections. + +## SSL Enforcement is particularly valuable for: +- Applications handling sensitive user data +- Financial services requiring secure transactions +- Healthcare applications managing patient information +- E-commerce platforms processing payment data +- Any application prioritizing data privacy and integrity + +By enabling SSL Enforcement, you implement a fundamental best practice in data protection. This feature ensures that every interaction with your database is encrypted and secure, whether from application servers, admin tools, or API calls. +`, + icon: ShieldCheck, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/ssl-enforcement.png', + heroImageLight: '/images/features/ssl-enforcement-light.png', + docsUrl: 'https://supabase.com/docs/guides/platform/ssl-enforcement', + slug: 'ssl-enforcement', + }, + { + title: 'Branching', + subtitle: 'Test and preview changes using Supabase Branches.', + description: ` +Supabase Branching allows you to create and test changes in separate, temporary environments without affecting your production setup. This feature works similarly to Git branches, enabling safe experimentation with configurations, database schemas, and new features. + +## Key features +1. Git-based workflow: Integrates with GitHub, creating preview branches for each pull request. +2. Isolated environments: Each branch has its own Supabase instance with separate API credentials. +3. Automatic migrations: Runs new migrations when changes are pushed to the ./supabase/migrations directory. +4. Data seeding: Preview branches can be seeded with sample data using ./supabase/seed.sql. +5. CI/CD integration: Supports preview deployments with hosting providers like Vercel. + +## Benefits: +- Risk-free experimentation: Test changes without affecting the production environment. +- Improved collaboration: Multiple team members can work on different features simultaneously. +- Streamlined reviews: Facilitate thorough checks of database changes before merging. +- Rapid iteration: Quickly prototype and validate database-driven features. + +## Supabase Branching is valuable for: +- Agile teams working on multiple features concurrently +- Projects with complex database schemas requiring careful management +- Applications undergoing significant refactoring or upgrades +- CI/CD pipelines integrating database changes +`, + icon: GitBranch, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/branching.png', + heroImageLight: '/images/features/branching-light.png', + docsUrl: 'https://supabase.com/docs/guides/platform/branching', + slug: 'branching', + }, + { + title: 'Terraform provider', + subtitle: 'Manage Supabase infrastructure via Terraform.', + description: ` +The Supabase Terraform provider enables developers and DevOps teams to manage their Supabase infrastructure as code. This integration brings the benefits of Infrastructure as Code (IaC) to your database and backend services, allowing for version-controlled, reproducible, and scalable management of Supabase resources. + +## Key features +1. Resource management: Define and control database resources, authentication settings, storage buckets, and API configurations. +2. Project-level control: Manage project settings and configurations through code. +3. Integration with existing workflows: Seamlessly incorporate Supabase management into your existing Terraform setups. +4. Import capability: Easily import existing Supabase projects into your Terraform state. + +## Benefits: +- Version control: Track and manage infrastructure changes alongside application code. +- Reproducibility: Easily recreate entire environments for development, staging, and production. +- Automated provisioning: Streamline the setup of new projects or environments. +- Consistency: Ensure uniformity across all environments, reducing configuration discrepancies. +- Scalability: Effortlessly manage multiple Supabase projects across various teams or clients. +- Collaboration: Improve team coordination by reviewing infrastructure changes through pull requests. + +## The Terraform provider is particularly valuable for: +- Enterprise-level projects requiring strict infrastructure governance +- DevOps teams implementing continuous deployment pipelines +- Multi-environment setups (development, staging, production) +- Agencies or consultancies managing multiple client projects +- Open-source projects aiming for easy contributor onboarding + +By adopting the Supabase Terraform provider, teams can implement GitOps practices, automate environment creation, easily roll back changes, and audit infrastructure modifications over time. This approach leads to more reliable, maintainable, and scalable backend infrastructures, ultimately accelerating development cycles and improving overall product quality. +`, + icon: Package, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/terraform-provider.png', + heroImageLight: '/images/features/terraform-provider-light.png', + docsUrl: 'https://supabase.com/docs/guides/deployment/terraform', + slug: 'terraform-provider', + }, + { + title: 'Read replicas', + subtitle: 'Deploy read-only databases across multiple regions for lower latency.', + description: ` +Supabase Read Replicas allow you to deploy additional read-only databases that are kept in sync with your Primary database. This feature enhances performance, improves resource management, and reduces latency for global applications. + +## Key features +1. Load balancing: Distribute read operations across multiple databases, reducing load on the Primary. +2. Global deployment: Deploy replicas closer to users for reduced latency. +3. Dedicated endpoints: Each replica has its own database and API endpoints. +4. API load balancer: Automatically balance GET requests across all available endpoints. +5. Centralized configuration: Settings are propagated across all databases in a project. +6. Monitoring tools: Track replication lag and resource utilization through the Supabase Dashboard. + +## Benefits: +- Improved performance: Serve data from the nearest location, reducing response times. +- Increased availability: Distribute read traffic across multiple replicas for enhanced resilience. +- Scalability: Handle higher read loads by offloading queries to replicas. +- Data redundancy: Replicas provide additional copies of your data. +- Analytics support: Run resource-intensive queries on replicas without impacting production. + +## Read Replicas are particularly valuable for: +- Global applications serving users across different regions +- High-traffic websites with read-heavy workloads +- Real-time analytics dashboards requiring low-latency data access +- Applications needing to scale read capacity independently of write capacity + +By leveraging Read Replicas, you can achieve consistent low-latency performance globally, improve application responsiveness for read operations, and enhance system reliability through better resource distribution and redundancy. +`, + icon: Database, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: 'https://www.youtube-nocookie.com/embed/PX3R1fXjJ2M', + docsUrl: 'https://supabase.com/docs/guides/platform/read-replicas', + slug: 'read-replicas', + }, + { + title: 'Fly Postgres', + subtitle: 'Launch the Supabase stack on Fly.io edge network.', + description: ` +Fly Postgres allows you to deploy Supabase databases on the Fly.io edge network, bringing your data closer to users and reducing latency for global applications. + +## Key features +1. Edge deployment: Launch databases in any region where Fly.io operates. +2. CLI integration: Set up and manage databases using the Fly CLI. +3. Supabase dashboard access: Use familiar Supabase tools for database management. +4. IPv6 support: Connect directly to databases using IPv6. +5. Supavisor support: Use Supavisor for IPv4 connections when needed. +6. Automatic Supabase organization creation: Seamless integration with Fly accounts. + +## Benefits: +- Improved global performance: Serve data from edge locations, minimizing latency. +- Simplified deployment: Easily set up databases using Fly CLI commands. +- Familiar tooling: Access Supabase features like SQL editor and table editor. +- Flexible connectivity: Support for both IPv6 and IPv4 (via Supavisor) connections. +- Integrated management: Manage database resources alongside other Fly applications. + +## Fly Postgres is particularly valuable for: +- Global applications requiring low-latency database access worldwide +- Developers familiar with Fly.io looking to integrate Supabase databases +- Projects needing easy deployment and management of edge-located databases + +By leveraging Fly Postgres, you can achieve improved global performance for your applications while maintaining the ease of use and powerful features of Supabase. +`, + icon: Cloud, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/platform/fly-postgres', + slug: 'fly-postgres', + }, + { + title: 'Postgres Extensions', + subtitle: 'Enhance your database with popular Postgres extensions.', + description: ` +Supabase supports a wide array of Postgres extensions, allowing you to enhance your database with additional functionalities. These extensions enable you to tailor your database to specific needs, from geospatial data to full-text search and advanced analytics. + +## Key features +1. Pre-installed extensions: Supabase comes with over 50 pre-configured extensions. +2. Easy management: Enable or disable extensions via the dashboard or SQL commands. +3. Flexible installation: Install custom SQL extensions using Supabase's SQL editor. +4. Automatic upgrades: Access new extension versions through infrastructure upgrades. +5. Schema control: Most extensions are installed under the extensions schema. + +## Benefits: +- Enhanced functionality: Add specialized features to your database without changing your application architecture. +- Optimized performance: Leverage extensions for efficient data processing and querying. +- Flexibility: Choose from a vast ecosystem of extensions to meet your project requirements. +- Easy integration: Incorporate powerful features into your existing database structure. +- Cost-effective solutions: Implement complex features without separate, specialized databases. + +Popular Postgres extensions supported by Supabase include: +- PostGIS: For working with geospatial data +- pgvector: Enables efficient similarity search and machine learning operations +- pgcrypto: Provides cryptographic functions +- pgjwt: Allows for JSON Web Token (JWT) generation and verification +- pg_net: Enables making HTTP requests from the database +- pgroonga: Provides full-text search capabilities for various languages +- timescaledb: Optimizes the database for time-series data + +Postgres extensions are valuable for a wide range of applications, from GIS and machine learning projects to security-focused applications and IoT systems dealing with time-series data. + +By leveraging these extensions, you can implement complex features more easily, reduce the need for external services, and tailor your database to specific project requirements. +`, + icon: Puzzle, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/postgres-extensions.png', + heroImageLight: '/images/features/postgres-extensions-light.png', + docsUrl: 'https://supabase.com/docs/guides/database/extensions', + slug: 'postgres-extensions', + }, + { + title: 'Database Webhooks', + subtitle: 'Trigger external payloads on database events.', + description: ` +Database Webhooks allow you to send real-time data from your database to another system whenever a table event occurs. You can hook into three table events: INSERT, UPDATE, and DELETE, with all events fired after a database row is changed. This feature provides a convenient way to integrate your Supabase database with external applications and services. + +## Key benefits +1. Real-Time Data Transfer: Automatically send data to external systems in response to database changes, ensuring timely updates. +2. Flexibility: Configure webhooks for specific tables and events, allowing for tailored integrations based on your application's needs. +3. Asynchronous Processing: Built on the pg_net extension, webhooks operate asynchronously, preventing long-running network requests from blocking database operations. +4. Easy Setup: Create webhooks directly from the Supabase Dashboard or through SQL statements, making integration straightforward. +5. Payload Customization: Automatically generated payloads provide relevant data about the event, including the new and old record states. + +This feature is particularly useful for developers looking to automate workflows and integrate their databases with third-party services like payment processors or notification systems. +`, + icon: Cloud, + products: [PRODUCT_SHORTNAMES.DATABASE], + heroImage: '/images/features/database-webhooks.png', + heroImageLight: '/images/features/database-webhooks-light.png', + docsUrl: 'https://supabase.com/docs/guides/database/webhooks', + slug: 'database-webhooks', + }, + { + title: 'Vault', + subtitle: 'Manage secrets safely in Postgres.', + description: ` +Vault is a Postgres extension and accompanying Supabase UI that simplifies the secure storage of encrypted secrets and other sensitive data in your database. This feature allows developers to utilize Postgres in innovative ways beyond its standard capabilities. + +## Key benefits: +1. Secure Storage: Secrets are stored on disk in an encrypted format, ensuring they remain protected even in backups or replication streams. +2. Easy Management: The Supabase dashboard UI makes it simple to store and manage secrets, including environment variables and API keys. +3. Flexible Encryption: Users can create custom encryption keys for different purposes, enhancing data security. +4. Seamless Access: Secrets can be accessed from SQL as easily as querying a table, facilitating their use in Postgres Functions, Triggers, and Webhooks. +5. Robust Security Features: The Vault employs authenticated encryption to ensure that secrets cannot be forged or decrypted without proper authorization. + +This feature is particularly useful for teams looking to enhance their security posture by managing sensitive data directly within their database environment. +`, + icon: Lock, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: 'https://supabase.com/docs/img/guides/database/vault-hello-compressed.mp4', + docsUrl: 'https://supabase.com/docs/guides/database/vault', + slug: 'vault', + }, + { + title: 'Supavisor', + subtitle: 'A scalable connection pooler for Postgres.', + description: ` +Supavisor is a cloud-native connection pooler designed for Postgres, built to handle millions of connections efficiently. It was developed in Elixir and integrates advanced features such as query load balancing, named prepared statement support, and query cancellation. Supavisor enhances the performance of Postgres by managing connections effectively, ensuring that applications can scale seamlessly under heavy loads. + +## Key benefits: +1. Scalable Architecture: Supports high concurrency and rapid I/O, making it suitable for applications with significant connection demands. +2. Query Load Balancing: Automatically distributes read requests across primary and replica servers to optimize performance. +3. Named Prepared Statements: Allows clients to reuse query plans across connections, improving efficiency and reducing overhead. +4. Query Cancellation: Provides the ability to cancel long-running queries easily, enhancing user experience during database interactions. +5. Easy Integration: New Supabase projects come with a Supavisor connection string by default, simplifying setup for developers. + +This feature is particularly valuable for teams looking to optimize their database interactions and ensure robust performance as their applications scale. +`, + icon: Zap, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: 'https://www.youtube-nocookie.com/embed/ogYNmJOFEpk', + docsUrl: 'https://supabase.com/blog/supavisor-postgres-connection-pooler', + slug: 'supavisor', + }, + // Realtime + { + title: 'Realtime Postgres changes', + subtitle: 'Receive your database changes through websockets.', + description: ` +Supabase's Realtime Postgres Changes feature allows you to listen to database changes in real-time using the Realtime system. This capability enables you to build responsive, live-updating applications that reflect database changes instantly. + +## Key features +1. Event-based listening: Subscribe to INSERT, UPDATE, DELETE, or all (*) events. +2. Schema and table targeting: Listen to changes in specific schemas or tables. +3. Granular filtering: Apply filters to receive only relevant changes. +4. Multiple subscriptions: Listen to different combinations of events, schemas, and tables in a single channel. +5. Row-level security integration: Respect database permissions when broadcasting changes. + +## Benefits: +- Real-time updates: Receive instant notifications when data changes, enabling live-updating UIs. +- Efficient data syncing: Keep client-side data in sync with the database without constant API calls. +- Flexible subscriptions: Tailor your subscriptions to specific events, schemas, tables, or conditions. +- Improved user experience: Provide users with up-to-date information without page refreshes. +- Simplified architecture: Implement real-time features without separate messaging systems. + +## Postgres Changes are valuable for: +- Collaborative applications where multiple users work on shared data +- Real-time dashboards and analytics platforms +- Live chat and messaging systems +- Applications requiring instant updates based on database changes + +Supabase's Realtime Postgres Changes feature provides a powerful tool for creating responsive, real-time applications while leveraging the full capabilities of your Postgres database. +`, + icon: DatabaseZap, + products: [PRODUCT_SHORTNAMES.REALTIME], + heroImage: 'https://www.youtube-nocookie.com/embed/2rUjcmgZDwQ', + docsUrl: 'https://supabase.com/docs/guides/realtime/postgres-changes', + slug: 'realtime-postgres-changes', + }, + { + title: 'Realtime Broadcast', + subtitle: 'Send messages between connected users through websockets.', + description: ` +Supabase's Realtime Broadcast feature enables real-time communication between connected clients using the Realtime system. This functionality allows you to build interactive, collaborative applications where users can instantly share messages or data in real-time. + +## Key features +1. Channel-based messaging: Send and receive messages within specific channels or rooms. +2. Event filtering: Subscribe to specific event types for targeted message handling. +3. Flexible payload: Transmit various types of data in the message payload. +4. Self-send option: Configure channels to receive messages sent by the sender. +5. Message acknowledgment: Confirm message receipt by the Realtime server. +6. REST API support: Send broadcast messages without an established WebSocket connection. + +## Benefits: +- Instant communication: Enable real-time messaging and data sharing between users. +- Low latency: Achieve near-instantaneous data transmission for time-sensitive applications. +- Flexible implementation: Use WebSocket connections or REST API calls to send messages. +- Simplified architecture: Build real-time features without managing complex messaging infrastructure. +- Enhanced user engagement: Create interactive, collaborative experiences that keep users connected. + +## Realtime Broadcast is particularly valuable for: +- Chat applications and messaging platforms +- Collaborative tools like shared documents or whiteboards +- Multiplayer games requiring real-time state synchronization +- Live auction or bidding systems +- Real-time commenting systems for blogs or social media platforms +- Interactive presentations or educational platforms + +Supabase's Realtime Broadcast feature provides a powerful tool for creating responsive, real-time applications with minimal setup and infrastructure management. +`, + icon: MessageCircle, + products: [PRODUCT_SHORTNAMES.REALTIME], + heroImage: 'https://www.youtube-nocookie.com/embed/BelYEMJ2N00', + docsUrl: 'https://supabase.com/docs/guides/realtime/broadcast', + slug: 'realtime-broadcast', + }, + { + title: 'Realtime Presence', + subtitle: 'Synchronize shared state between users through websockets.', + description: ` +Supabase's Realtime Presence feature allows you to track and synchronize shared state between connected users in real-time. This capability enables you to build collaborative applications where users can see each other's status, actions, or any custom state information instantly. + +## Key features +1. Real-time state synchronization: Track and update shared state across multiple clients. +2. Event-based tracking: Listen to 'sync', 'join', and 'leave' events for state changes. +3. Custom state definition: Share any type of state information between users. +4. Unique client identification: Use auto-generated or custom keys to identify clients. +5. Channel-based organization: Group users and their states into specific channels or rooms. +6. Easy state management: Track, untrack, and update state with simple method calls. + +## Benefits: +- Real-time user tracking: Monitor which users are currently active or online. +- Enhanced interactivity: Create more engaging, collaborative user experiences. +- Reduced complexity: Implement shared state features without building custom synchronization logic. +- Flexible use cases: Adapt the feature for various applications, from simple online indicators to complex collaborative tools. + +## Realtime Presence is particularly valuable for: +- Collaborative document editing platforms +- Multiplayer games showing player positions or status +- Team collaboration tools with user availability indicators +- Live streaming platforms displaying viewer counts and engagement +- Project management applications with real-time task assignments +- Social media platforms showing active users in a group or chat + +Supabase's Realtime Presence feature provides a powerful tool for creating interactive, real-time applications that require shared state management, allowing developers to focus on creating unique and valuable user experiences. +`, + icon: Users, + products: [PRODUCT_SHORTNAMES.REALTIME], + heroImage: 'https://www.youtube-nocookie.com/embed/BelYEMJ2N00', + docsUrl: 'https://supabase.com/docs/guides/realtime/presence', + slug: 'realtime-presence', + }, + // Auth + { + title: 'Email login', + subtitle: 'Build email logins for your application or website.', + description: ` +Supabase's Email Login feature enables secure email-based authentication for your applications, allowing users to create accounts and sign in using their email addresses. + +## Key features +1. Email signup and signin: Implement user registration and login with email and password. +2. Email verification: Option to require email confirmation before account activation. +3. Password reset flow: Built-in functionality for secure password resets. +4. Customizable redirect URLs: Specify where users are directed after email confirmation. +5. Flexible implementation: Support for both implicit and PKCE authentication flows. +6. SMTP integration: Use Supabase's email service or configure a custom SMTP server. + +## Benefits: +- Secure authentication: Implement industry-standard security practices. +- Customizable workflows: Tailor the signup and login processes to your needs. +- Seamless integration: Works with Supabase's other auth providers and features. +- Local development support: Test email flows using built-in tools like Inbucket. + +## Email login is valuable for: +- SaaS applications requiring user accounts +- E-commerce platforms with customer profiles +- Community websites and forums +- Enterprise applications with employee logins + +Supabase's Email Login provides a robust foundation for user authentication, allowing you to focus on building your core application features while ensuring secure account management. +`, + icon: Mail, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '/images/features/email-login.png', + heroImageLight: '/images/features/email-login-light.png', + docsUrl: 'https://supabase.com/docs/guides/auth/passwords', + slug: 'email-login', + }, + { + title: 'Social login', + subtitle: 'Provide social logins from platforms like Apple, GitHub, and Slack.', + description: ` +Supabase's Social Login feature enables users to authenticate using their existing accounts from popular social platforms and services, providing a streamlined, password-free access to your application. + +## Key features +1. Multiple provider support: Integrate with various platforms like Google, Facebook, Twitter, and more. +2. OAuth standard: Implement secure authentication using the open OAuth standard. +3. Provider tokens: Access provider tokens for additional API calls to the OAuth provider. +4. Customizable setup: Configure each social provider according to your needs. +5. User data access: Retrieve additional user information (with permission) from social providers. + +## Benefits: +- Simplified onboarding: Reduce friction in the signup process, potentially increasing conversion rates. +- Improved user experience: Offer quick, familiar login options to users. +- Enhanced security: Leverage the robust security measures of established platforms. +- Rich user profiles: Access additional user data to personalize experiences. + +## Social login is valuable for: +- Mobile apps seeking to minimize user input +- E-commerce platforms aiming to reduce cart abandonment +- Content platforms looking to personalize user experiences +- Community-driven websites fostering user engagement +- B2B applications integrating with professional networks + +Supabase's Social Login feature allows you to improve user experience and potentially increase signup rates while maintaining security and control over user authentication. +`, + icon: Users, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '/images/features/social-login.png', + heroImageLight: '/images/features/social-login-light.png', + docsUrl: 'https://supabase.com/docs/guides/auth/social-login', + slug: 'social-login', + }, + { + title: 'Phone logins', + subtitle: 'Provide phone logins using a third-party SMS provider.', + description: ` +Supabase's Phone Login feature allows users to authenticate using their phone numbers, providing a password-free login experience with SMS verification. + +## Key features +1. OTP-based authentication: Users sign in with a one-time code sent via SMS. +2. Phone number updates: Allow logged-in users to change their associated phone number. +3. Customizable settings: Configure rate limits and OTP expiration times. +4. Multiple SMS provider support: Integrate with various SMS services like MessageBird, Twilio, and Vonage. +5. Native Mobile Login support: Utilize built-in identity providers for Android and iOS. + +## Benefits: +- Simplified authentication: Users can sign up and log in using just their phone number. +- Enhanced security: Implement two-factor authentication (2FA) using SMS verification codes. +- Reduced friction: Eliminate the need for users to remember passwords. +- Verified user base: Ensure each account is associated with a unique, verifiable phone number. + +## Phone login is valuable for: +- Mobile apps prioritizing quick and easy onboarding +- Ride-sharing or delivery services requiring verified contact information +- Messaging platforms where phone numbers serve as user identifiers +- Applications targeting regions where phone usage exceeds email usage + +Supabase's Phone Login feature enables a seamless authentication experience, particularly suited for mobile and SMS-centric applications, while providing an additional layer of security through phone number verification. +`, + icon: Smartphone, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '/images/features/phone-login.png', + heroImageLight: '/images/features/phone-login-light.png', + docsUrl: 'https://supabase.com/docs/guides/auth/phone-login', + slug: 'phone-logins', + }, + { + title: 'Passwordless login via Magic Links', + subtitle: 'Build passwordless logins via magic links for your application or website.', + description: ` +Supabase's Passwordless Login via Magic Links allows users to sign in by clicking a unique link sent to their email address, eliminating the need for traditional passwords. + +## Key features +1. One-time use links: Each Magic Link is valid for a single login attempt. +2. Automatic user creation: New users can be automatically signed up (optional). +3. Customizable email templates: Modify the Magic Link email content to fit your brand. +4. Configurable settings: Adjust request limits and link expiration times. +5. Redirect URL control: Specify allowed redirect destinations after login. +6. PKCE flow support: Enhanced security for server-side rendering. + +## Benefits: +- Enhanced security: Reduce risks associated with password-based vulnerabilities. +- Improved user experience: Remove the friction of remembering and entering passwords. +- Reduced support overhead: Decrease password-related support requests. +- Simplified onboarding: Lower the barrier to entry for new users. + +## Passwordless login via Magic Links is valuable for: +- SaaS platforms prioritizing security and user experience +- Financial applications requiring strong authentication +- E-commerce sites aiming to reduce login friction +- Enterprise applications managing access for numerous employees +- Any application targeting users fatigued by managing multiple passwords + +Supabase's Magic Links feature offers a secure and user-friendly authentication method, aligning with modern security practices while providing a frictionless login experience. +`, + icon: Lock, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/auth/auth-email-passwordless', + slug: 'passwordless-login-via-magicklink', + }, + { + title: 'Multi-Factor Authentication (MFA)', + subtitle: 'Add an extra layer of security to your application with MFA.', + description: ` +Supabase's Multi-Factor Authentication (MFA) adds an extra layer of security to your application by verifying user identity through additional steps beyond the initial login. + +## Key features +1. Multiple authentication methods: Support for App Authenticator (TOTP) and phone messaging. +2. Flexible enrollment: API for building custom MFA setup interfaces. +3. Challenge and Verify APIs: Securely validate user access to additional factors. +4. Authenticator Assurance Levels: JWT claims (aal1, aal2) to indicate the level of identity verification. +5. Customizable enforcement: Options to enforce MFA for all users, new users only, or opt-in basis. +6. Database integration: Row Level Security policies to enforce MFA rules at the database level. + +## Benefits: +- Enhanced security: Significantly reduce the risk of unauthorized account access. +- User trust: Demonstrate a commitment to protecting user data and privacy. +- Compliance support: Meet security requirements for regulated industries. +- Flexible implementation: Customize MFA flows to suit your application's needs. + +## MFA is particularly valuable for: +- Financial services and banking applications +- Healthcare platforms managing protected health information +- Enterprise applications with access to confidential business data +- E-commerce sites protecting user payment information +- Any application storing sensitive user data + +Supabase's MFA feature provides a robust tool for enhancing application security, allowing developers to implement strong authentication measures tailored to their specific requirements. +`, + icon: Lock, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/auth/auth-mfa', + slug: 'multi-factor-authentication', + }, + { + title: 'Authorization via Row Level Security', + subtitle: 'Control the data each user can access with Postgres Policies.', + description: ` +Supabase's Row Level Security (RLS) feature allows you to implement granular authorization rules directly in your Postgres database, securing your data at the row level. + +## Key features +1. Policy-based access control: Create SQL rules to determine data access for each table. +2. Role-specific policies: Define different access rules for authenticated and anonymous users. +3. Flexible policy types: Implement SELECT, INSERT, UPDATE, and DELETE policies. +4. Integration with Supabase Auth: Use built-in helper functions like auth.uid() and auth.jwt() in policies. +5. Performance optimization: Utilize indexes and optimized query patterns for efficient policy execution. +6. Bypass options: Use service keys or create roles with bypassrls privilege for administrative tasks. + +## Benefits: +- Enhanced data security: Control access to individual rows based on user attributes or roles. +- Simplified application logic: Reduce complex authorization checks in your application code. +- Consistency across clients: Ensure uniform access control regardless of data access method. +- Centralized policy management: Define and manage access rules directly in the database. + +## RLS is particularly valuable for: +- Multi-tenant applications requiring data isolation +- Healthcare systems needing patient data privacy +- Financial platforms with strict data access controls +- Collaborative tools where users should only see their own or shared data +- Any application dealing with sensitive or personalized data + +Supabase's RLS feature provides a powerful tool for implementing sophisticated access control patterns with minimal application code, enhancing security while simplifying development. +`, + icon: Lock, + products: [PRODUCT_SHORTNAMES.DATABASE, PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: `https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/videos/marketing/website/supabase-rls.mp4`, + docsUrl: 'https://supabase.com/docs/guides/auth/row-level-security', + slug: 'row-level-security', + }, + { + title: 'Captcha protection', + subtitle: 'Add Captcha to your sign-in, sign-up, and password reset forms.', + description: ` +Supabase's Captcha Protection feature allows you to integrate CAPTCHA challenges into your authentication flows, providing an additional layer of security against automated attacks and bot activities. + +## Key features +1. Multiple provider support: Integration with hCaptcha and Cloudflare Turnstile. +2. Easy configuration: Enable and configure CAPTCHA through the Supabase Dashboard. +3. Flexible implementation: Support for various JavaScript frameworks and custom frontend integration. +4. Auth flow integration: Seamlessly add CAPTCHA to sign-up, sign-in, and password reset processes. +5. Token-based verification: Use CAPTCHA tokens in Supabase auth functions for enhanced security. + +## Benefits: +- Bot protection: Defend against automated attacks on authentication endpoints. +- Reduced spam: Minimize fake account creation and spam submissions. +- Customizable security: Adjust CAPTCHA difficulty based on risk assessment. +- Improved security posture: Demonstrate commitment to security best practices. + +## Captcha protection is valuable for: +- High-traffic websites vulnerable to automated attacks +- E-commerce platforms preventing fake account creation +- Forums and community sites reducing spam registrations +- Financial services applications adding an extra layer of security +- Any public-facing forms susceptible to bot submissions + +Supabase's Captcha Protection feature provides a powerful tool for enhancing application security, helping to protect against various automated threats while maintaining a user-friendly experience. +`, + icon: RectangleEllipsis, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '/images/features/auth-captcha-protection.png', + heroImageLight: '/images/features/auth-captcha-protection-light.png', + docsUrl: 'https://supabase.com/docs/guides/auth/auth-captcha', + slug: 'auth-captcha-protection', + }, + { + title: 'Server-side Auth', + subtitle: 'Helpers for implementing user authentication in popular server-side languages.', + description: ` +Supabase's Server-side Auth feature provides tools and utilities for implementing secure user authentication in server-side environments, complementing client-side auth implementations. + +## Key features +1. SSR compatibility: Fully supports Server-Side Rendering frameworks. +2. Cookie-based sessions: Store user sessions in cookies instead of local storage for enhanced security. +3. PKCE flow support: Implement the more secure PKCE authentication flow for server-side apps. +4. @supabase/ssr package: Simplified setup for Supabase client in SSR environments (currently in beta). +5. Framework integration: Easily integrate with popular SSR frameworks like Next.js and SvelteKit. + +## Benefits: +- Enhanced security: Implement secure token verification and session management on the server. +- Simplified development: Use pre-built helpers to handle common auth tasks, reducing boilerplate code. +- Consistent auth experience: Maintain a unified auth approach across client and server components. +- SSR support: Enable authenticated server-side rendering for improved performance and SEO. + +## Server-side Auth is valuable for: +- Server-rendered web applications requiring authenticated content +- APIs and microservices needing to verify client authenticity +- Hybrid apps combining client and server-side rendering +- Projects leveraging SSR frameworks like Next.js, Nuxt, or SvelteKit +- Applications requiring secure, programmatic access to user session data + +Supabase's Server-side Auth feature allows developers to create more secure and robust applications with consistent authentication across both client and server components, adhering to security best practices while leveraging Supabase's auth system. +`, + icon: Server, + products: [PRODUCT_SHORTNAMES.AUTHENTICATION], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/auth/server-side', + slug: 'server-side-auth', + }, + // Storage + { + title: 'File storage', + subtitle: 'Supabase Storage makes it simple to store and serve files.', + description: ` +Supabase Storage provides a scalable solution for storing and serving files in your applications, allowing easy management of user-generated content and assets within your Supabase project. + +## Key features +1. Global CDN: Serve files quickly from over 285 cities worldwide. +2. Image optimization: Resize and compress media files on the fly. +3. Flexible file types: Store images, videos, documents, and any other file type. +4. Access control: Implement fine-grained access using Postgres RLS policies. +5. Resumable uploads: Support for protocols like TUS for reliable file transfers. +6. Organization: Structure files into buckets and folders for efficient management. + +## Benefits: +- Seamless integration: Use the same Supabase client for both database and storage operations. +- Scalability: Handle large numbers of files and high traffic loads without infrastructure management. +- Performance: Quick file serving with global CDN support. +- Cost-effectiveness: Pay only for the storage you use, with no upfront costs. + +## File storage is valuable for: +- Social media platforms storing user-uploaded media +- E-commerce sites managing product images and documents +- Content management systems handling various file types +- Collaborative tools storing shared assets +- Mobile apps needing to sync user data and media + +Supabase Storage simplifies adding robust file management to your applications, allowing you to focus on building unique features while relying on Supabase for secure, scalable file storage and serving. +`, + icon: Folders, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: '/images/features/file-storage.png', + heroImageLight: '/images/features/file-storage-light.png', + docsUrl: 'https://supabase.com/docs/guides/storage', + slug: 'file-storage', + }, + { + title: 'Content Delivery Network', + subtitle: 'Cache large files using the Supabase CDN.', + description: ` +Supabase's Content Delivery Network (CDN) feature caches and serves assets uploaded to Supabase Storage efficiently across the globe, improving latency for users worldwide. + +## Key features +1. Global distribution: Serve content from geographically distributed servers. +2. Origin caching: Cache content from the storage server in your project's region. +3. Intelligent routing: Direct users to the nearest CDN node for faster access. +4. Cache status tracking: Monitor cache hits and misses via the cf-cache-status header. +5. Public and private bucket support: Optimize caching based on bucket privacy settings. + +## Benefits: +- Improved performance: Reduce latency by serving content from servers closest to the user. +- Scalability: Handle high traffic loads without straining your main servers. +- Global reach: Deliver content efficiently to users worldwide without managing global infrastructure. +- Enhanced security: Additional layer of protection against DDoS and other application attacks. +- Improved availability: Increase content redundancy through distributed serving. + +## The CDN feature is valuable for: +- Media-heavy websites serving large images or video files +- Global applications requiring fast content delivery across different regions +- E-commerce platforms needing to serve product images quickly +- Content publishers distributing large documents or multimedia files +- Any application dealing with large file downloads or streaming + +Supabase's CDN significantly improves the performance and user experience of your applications, especially for users accessing large files or media content globally, without the complexity of managing your own CDN infrastructure. +`, + icon: Cloud, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/storage/cdn', + slug: 'cdn', + }, + { + title: 'Smart Content Delivery Network', + subtitle: 'Automatically revalidate assets at the edge via the Smart CDN.', + description: ` +Supabase's Smart CDN automatically synchronizes asset metadata to the edge, ensuring content is quickly delivered and remains up-to-date without manual intervention. + +## Key features +1. Automatic cache revalidation: Invalidates CDN cache when assets are changed or deleted. +2. Improved cache hit rate: Shields origin server from unchanged asset requests, even with different query strings. +3. Long-term edge caching: Stores assets on the CDN for extended periods while maintaining freshness. +4. Flexible browser caching: Control browser cache duration using the cacheControl option. +5. Rapid propagation: Changes reflect across global data centers within 60 seconds. +6. Cache bypass option: Use unique query strings to fetch directly from the origin when needed. + +## Benefits: +- Content freshness: Users always receive the most recent version of assets. +- Reduced origin load: Minimize requests to the origin server by optimizing edge caching. +- Improved user experience: Deliver fast-loading, up-to-date content globally. +- Cost optimization: Reduce bandwidth costs by serving more content from the edge. + +## The Smart CDN feature is valuable for: +- Dynamic websites with frequently updated content +- E-commerce platforms with real-time inventory updates +- News and media sites delivering the latest information +- Applications with rapidly changing user-generated content +- Global platforms requiring both speed and content accuracy + +Supabase's Smart CDN optimizes both performance and content accuracy, providing a superior user experience for applications with dynamic or frequently updated content, without the complexity of manual cache management. +`, + icon: Cloud, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: 'https://www.youtube-nocookie.com/embed/NpEl20iuOtg', + docsUrl: 'https://supabase.com/docs/guides/storage/cdn/smart-cdn', + slug: 'smart-cdn', + }, + { + title: 'Image transformations', + subtitle: 'Transform images on the fly.', + description: ` +Supabase's Image Transformations feature allows you to optimize and resize images on-the-fly, directly from your storage buckets. + +## Key features +1. Dynamic resizing: Adjust image dimensions using width and height parameters. +2. Quality control: Set image quality with a scale of 20 to 100. +3. Resize modes: Choose from 'cover', 'contain', or 'fill' to suit your needs. +4. Automatic format optimization: Convert images to WebP for supported browsers. +5. Flexible implementation: Use with public URLs, signed URLs, or direct downloads. +6. Next.js integration: Custom loader for optimized images in Next.js applications. +7. Self-hosting option: Deploy your own image transformation service using Imgproxy. + +## Benefits: +- Performance optimization: Reduce bandwidth usage and improve load times with optimized images. +- Storage efficiency: Store a single high-quality version and generate variants as needed. +- Responsive design support: Serve appropriately sized images for different devices and layouts. +- Simplified workflow: Eliminate the need for manual image processing and multiple version storage. + +## Image transformations are valuable for: +- Responsive web applications requiring different image sizes +- E-commerce platforms showcasing product images +- Content management systems adapting images for various layouts +- Mobile apps optimizing images for cellular networks +- Any application handling large volumes of images in varying contexts + +Supabase's Image Transformations feature enables you to efficiently manage and serve optimized images, improving your application's performance and user experience while saving time and resources. +`, + icon: Image, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: 'https://www.youtube-nocookie.com/embed/dLqSmxX3r7I', + docsUrl: 'https://supabase.com/docs/guides/storage/image-transformations', + slug: 'image-transformations', + }, + { + title: 'Resumable uploads', + subtitle: 'Upload large files using resumable uploads.', + description: ` +Supabase's Resumable Uploads feature enables reliable transfer of large files, allowing uploads to be paused and resumed, particularly useful for files exceeding 6MB or in unstable network conditions. + +## Key features +1. TUS protocol implementation: Utilizes the open protocol for resumable uploads. +2. Progress tracking: Provides upload progress events for better user feedback. +3. Chunk-based uploads: Uses 6MB chunks for efficient transfer and resumption. +4. Retry mechanism: Implements automatic retries with customizable delay intervals. +5. Metadata support: Allows setting of bucket name, object name, content type, and cache control. +6. Upsert option: Provides the ability to overwrite existing files if needed. +7. Flexible integration: Compatible with tus-js-client and other TUS-supporting libraries like Uppy. + +## Benefits: +- Reliability: Successfully upload large files even with unstable internet connections. +- User experience: Allow users to pause and resume uploads at their convenience. +- Bandwidth efficiency: Avoid re-uploading already transferred parts of a file after an interruption. +- Large file support: Confidently handle uploads of very large files without worrying about timeouts. + +## Resumable uploads are valuable for: +- Cloud storage applications handling large file transfers +- Video sharing platforms dealing with high-quality video uploads +- Backup and sync services ensuring data integrity during transfers +- Content creation tools handling large media files +- Any application where users need to upload sizeable files in potentially unstable conditions + +Supabase's Resumable Uploads feature significantly improves the reliability and user experience of file uploads, especially for large files or in scenarios with unpredictable network conditions. +`, + icon: UploadCloud, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: 'https://www.youtube-nocookie.com/embed/pT2PcZFq_M0', + docsUrl: 'https://supabase.com/docs/guides/storage/uploads/resumable-uploads', + slug: 'resumable-uploads', + }, + { + title: 'S3 compatibility', + subtitle: 'Interact with Storage from tools which support the S3 protocol.', + description: ` +Supabase Storage offers compatibility with the S3 protocol, allowing you to use any S3 client to interact with your Storage objects. + +## Key features +1. Protocol interoperability: Support for standard, resumable, and S3 uploads, all interoperable. +2. Extensive API support: Implementation of most commonly used S3 endpoints for bucket and object operations. +3. Bucket management: Support for creating, deleting, and listing buckets. +4. Object handling: Capabilities for uploading, downloading, copying, and deleting objects. +5. Multipart uploads: Support for creating, completing, and aborting multipart uploads. +6. Metadata management: Ability to set and retrieve system metadata for objects. +7. Conditional operations: Support for conditional GET and HEAD requests. + +## Benefits: +- Ecosystem compatibility: Leverage existing S3-compatible tools and libraries with Supabase Storage. +- Familiar workflows: Use well-known S3 commands and SDKs for storage operations. +- Flexibility: Choose the most suitable client libraries or tools for your specific needs. +- Easy migration: Simplify the process of moving from S3 to Supabase or using both in parallel. + +## S3 compatibility is valuable for: +- DevOps teams using S3-based deployment and backup scripts +- Data analysis workflows leveraging S3-compatible data lakes +- Content management systems with existing S3 integrations +- Legacy applications built around S3 storage +- Any project looking to leverage the vast ecosystem of S3-compatible tools + +Supabase's S3 compatibility allows seamless integration with existing workflows and tools, providing flexibility in data storage and management while benefiting from Supabase's managed solution. +`, + icon: UploadCloud, + products: [PRODUCT_SHORTNAMES.STORAGE], + heroImage: '/images/features/s3-compatibility.png', + heroImageLight: '/images/features/s3-compatibility-light.png', + docsUrl: 'https://supabase.com/docs/guides/storage/s3/compatibility', + slug: 's3-compatibility', + }, + // Functions + { + title: 'Deno Edge Functions', + subtitle: 'Globally distributed TypeScript functions to execute custom business logic.', + description: ` +Supabase's Deno Edge Functions allow you to deploy and run custom TypeScript functions globally at the edge, bringing your code closer to users for enhanced performance and reduced latency. + +## Key features +1. Global distribution: Functions run close to users, reducing latency. +2. TypeScript support: Leverage TypeScript's type safety and modern language features. +3. Deno runtime: Benefit from Deno's security-first approach and modern JavaScript APIs. +4. Seamless integration: Easy integration with other Supabase services like Auth and Database. +5. Versatile use cases: Support for webhooks, third-party integrations, and custom API endpoints. +6. Open-source and portable: Run locally or on any Deno-compatible platform. +7. Rich ecosystem: Wide range of examples and integrations available. + +## Benefits: +- Low-latency responses: Ideal for globally distributed applications. +- Scalability: Automatically handle varying loads without manual intervention. +- Flexibility: Extend Supabase project capabilities beyond database operations. +- Developer-friendly: Fast deployments and rollbacks for agile development. + +## Deno Edge Functions are valuable for: +- API backends requiring low-latency responses globally +- Serverless applications needing custom logic close to users +- Webhook handlers for third-party service integrations +- Real-time data processing and transformation +- Custom authentication and authorization flows +- Integration with various services (e.g., Stripe, OpenAI, Hugging Face) +- Building bots for platforms like Discord and Telegram + +Supabase's Deno Edge Functions enable you to build responsive, globally distributed applications with custom server-side logic, supporting a wide range of use cases and integrations. +`, + icon: FileCode2, + products: [PRODUCT_SHORTNAMES.FUNCTIONS], + heroImage: 'https://www.youtube-nocookie.com/embed/5OWH9c4u68M', + docsUrl: 'https://supabase.com/docs/guides/functions', + slug: 'deno-edge-functions', + }, + { + title: 'Regional invocations', + subtitle: 'Execute an Edge Function in a region close to your database.', + description: ` +Supabase's Regional Invocations feature allows you to execute Edge Functions in a region close to your database, optimizing performance for database-intensive operations. This capability ensures low-latency communication between your functions and database, enhancing overall application responsiveness. + +## Key benefits +1. Reduced latency: Minimize round-trip time between functions and database for faster operations. +2. Improved performance: Enhance the speed of database queries and updates from your functions. +3. Data locality: Keep data processing close to where the data is stored for efficiency. +4. Consistency: Ensure that functions operate on the most up-to-date data with minimal delay. +5. Cost optimization: Reduce data transfer costs between regions. +6. Simplified architecture: Streamline your application's geographic setup for database operations. +7. Compliance support: Help meet data residency requirements by processing data in specific regions. + +## Regional invocations are particularly valuable for: +- Applications with data-intensive serverless functions +- Real-time data processing workflows requiring low latency +- Financial applications needing quick database reads and writes +- Analytics services processing large volumes of data +- Compliance-sensitive applications with data residency requirements +- Any scenario where minimizing function-to-database latency is crucial + +By leveraging Supabase's Regional Invocations, you can significantly enhance the performance of your database-centric serverless functions. This feature allows you to build more responsive applications, especially those requiring frequent or complex database interactions, by ensuring that your Edge Functions operate as close as possible to your Supabase database. +`, + icon: Globe, + products: [PRODUCT_SHORTNAMES.FUNCTIONS], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/functions/regional-invocation', + slug: 'regional-invocations', + }, + { + title: 'NPM compatibility', + subtitle: 'Edge functions natively support NPM modules and Node built-in APIs.', + description: ` +Supabase's NPM Compatibility feature for Edge Functions allows you to use NPM modules and Node.js built-in APIs directly in your Deno-based Edge Functions. This powerful capability bridges the gap between Deno and the vast Node.js ecosystem, giving you access to a wide range of libraries and familiar APIs. + +## Key benefits +1. Extensive library access: Leverage the vast NPM ecosystem in your Edge Functions. +2. Familiar Node.js APIs: Use built-in Node.js modules you're already familiar with. +3. Code reusability: Easily port existing Node.js code to Supabase Edge Functions. +4. Ecosystem compatibility: Integrate with services and tools designed for Node.js environments. +5. Simplified development: Reduce the learning curve for developers coming from a Node.js background. +6. Flexibility: Choose between Deno-native and Node-compatible libraries as needed. +7. Future-proofing: Adapt existing codebases to edge computing without major rewrites. + +## NPM compatibility is particularly valuable for: +- Projects migrating from Node.js-based serverless platforms to Supabase +- Developers looking to leverage specific NPM packages in edge computing scenarios +- Applications requiring Node.js-specific libraries not available in Deno +- Teams with existing Node.js codebases wanting to adopt edge computing +- Rapid prototyping using familiar Node.js modules and APIs +- Any project aiming to balance the benefits of Deno with Node.js ecosystem access + +By leveraging NPM Compatibility in Supabase Edge Functions, you can take advantage of the best of both worlds: the modern, secure runtime of Deno and the rich ecosystem of Node.js. This feature allows you to build powerful, efficient edge computing solutions while maintaining access to the tools and libraries you're familiar with, accelerating development and enabling more complex use cases at the edge. +`, + icon: FileCode2, + products: [PRODUCT_SHORTNAMES.FUNCTIONS], + heroImage: 'https://www.youtube-nocookie.com/embed/eCbiywoDORw', + docsUrl: 'https://supabase.com/blog/edge-functions-node-npm', + slug: 'npm-compatibility', + }, + // Vector + { + title: 'AI Integrations', + subtitle: 'Enhance applications with OpenAI and Hugging Face integrations.', + description: ` +Supabase's AI Integrations feature provides seamless connectivity with leading AI platforms like OpenAI and Hugging Face, allowing you to easily incorporate advanced AI capabilities into your applications. This powerful feature enables developers to leverage state-of-the-art machine learning models and natural language processing directly within their Supabase projects. + +## Key benefits +1. Easy AI integration: Incorporate advanced AI capabilities without managing complex infrastructure. +2. Versatile model access: Utilize a wide range of pre-trained models for various AI tasks. +3. Scalability: Handle AI workloads efficiently with Supabase's managed infrastructure. +4. Cost-effective AI deployment: Leverage powerful AI models without significant upfront investments. +5. Real-time AI processing: Combine AI capabilities with Supabase's real-time features for dynamic applications. +6. Data synergy: Seamlessly use your Supabase data with AI models for personalized experiences. +7. Rapid prototyping: Quickly experiment with and deploy AI-powered features in your applications. + +## AI Integrations are particularly valuable for: +- Natural language processing applications like chatbots and content analyzers +- Recommendation systems for e-commerce or content platforms +- Image and video analysis tools for media applications +- Predictive analytics dashboards for business intelligence +- Personalization engines for user experience enhancement +- Any application seeking to incorporate AI-driven insights or automation + +By leveraging Supabase's AI Integrations, you can rapidly develop and deploy sophisticated AI-powered applications. This feature bridges the gap between powerful AI models and your application data, enabling you to create more intelligent, responsive, and personalized user experiences without the complexity of managing separate AI infrastructure. +`, + icon: Brain, + products: [PRODUCT_SHORTNAMES.VECTOR], + heroImage: 'https://www.youtube-nocookie.com/embed/OgnYxRkxEUw', + docsUrl: 'https://supabase.com/docs/guides/ai/examples/huggingface-image-captioning', + slug: 'ai-integrations', + }, + // Platform + { + title: 'CLI', + subtitle: 'Use our CLI to develop your project locally and deploy.', + description: ` +Supabase's Command Line Interface (CLI) tool provides developers with a powerful and flexible way to manage their Supabase projects directly from the terminal. This feature streamlines the development workflow, allowing for local development, testing, and seamless deployment of Supabase projects. + +## Key benefits +1. Local development: Set up and run a local Supabase instance for development and testing. +2. Version control integration: Easily manage database migrations and schema changes with Git-like workflows. +3. Automated deployments: Deploy changes to your Supabase project with simple CLI commands. +4. Database migrations: Generate, apply, and revert database migrations effortlessly. +5. Environment management: Handle multiple environments (development, staging, production) efficiently. +6. Seed data management: Populate your database with test data for consistent development and testing. +7. CI/CD integration: Incorporate Supabase operations into your continuous integration and deployment pipelines. + +## The CLI is particularly valuable for: +- Development teams working on Supabase projects collaboratively +- DevOps professionals managing Supabase deployments across multiple environments +- Open-source projects requiring reproducible Supabase setups +- Developers preferring command-line tools for increased productivity +- Projects with complex database schemas requiring careful version control +- Any application needing a streamlined local-to-production workflow with Supabase + +By leveraging the Supabase CLI, you can significantly improve your development workflow, ensuring consistency between local and production environments, streamlining deployments, and making it easier to manage complex Supabase projects. This tool empowers developers to work more efficiently with Supabase, whether they're building small prototypes or large-scale applications. +`, + icon: Terminal, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: 'https://www.youtube-nocookie.com/embed/vyHyYpvjaks', + docsUrl: + 'https://supabase.com/docs/guides/local-development?queryGroups=package-manager&package-manager=pnpm', + slug: 'cli', + }, + { + title: 'Management API', + subtitle: 'Manage your projects programmatically.', + description: ` +Supabase's Management API provides a powerful interface for programmatically managing your Supabase projects. This feature allows developers and DevOps teams to automate project creation, configuration, and maintenance tasks, enabling more efficient and scalable project management workflows. + +## Key benefits +1. Automation: Automate repetitive tasks like project creation and configuration. +2. Scalability: Easily manage multiple Supabase projects across different environments. +3. CI/CD integration: Incorporate Supabase project management into your continuous integration and deployment pipelines. +4. Programmatic control: Manage projects dynamically based on your application's needs. +5. Consistent setup: Ensure uniform configuration across multiple projects or environments. +6. Audit trail: Keep track of project changes and configurations programmatically. +7. Resource optimization: Dynamically allocate and deallocate resources based on demand. + +## The Management API is particularly valuable for: +- Large organizations managing multiple Supabase projects +- SaaS platforms offering Supabase-powered backends to their customers +- DevOps teams implementing infrastructure-as-code practices +- Managed service providers offering Supabase as part of their stack +- Applications requiring dynamic project creation and management +- Any scenario where manual project management through the dashboard is impractical + +By utilizing the Supabase Management API, you can create more efficient, scalable, and automated workflows for managing your Supabase projects. This feature is especially powerful for organizations dealing with multiple projects or environments, enabling them to maintain consistency, reduce manual errors, and quickly respond to changing project requirements. +`, + icon: FileCode2, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: '/images/features/management-api.png', + heroImageLight: '/images/features/management-api-light.png', + docsUrl: 'https://supabase.com/docs/guides/project-management/api', + slug: 'management-api', + }, + { + title: 'Role-Based Access Control (RBAC)', + icon: ShieldPlus, + subtitle: 'Define and manage user roles securely', + description: ` +Supabase's Role-Based Access Control (RBAC) feature provides a powerful and flexible way to manage user permissions within your application. RBAC allows you to define roles with specific sets of permissions and assign these roles to users, enabling fine-grained control over who can access what within your system. + +## Key benefits +1. Granular access control: Define precise permissions for different user types or job functions. +2. Simplified management: Easily manage permissions by assigning roles rather than individual permissions. +3. Scalability: Efficiently handle permissions for large numbers of users and resources. +4. Compliance support: Meet regulatory requirements for access control in various industries. +5. Reduced error risk: Minimize the chance of accidental permission assignments. +6. Auditing capabilities: Easily track and review role assignments and permission changes. +7. Flexibility: Quickly adapt to organizational changes by modifying role definitions. + +## RBAC is particularly valuable for: +- Enterprise applications with complex organizational structures +- Healthcare systems requiring strict data access controls +- Financial platforms with varying levels of user authority +- Content management systems with different contributor roles +- E-commerce platforms with layered admin permissions +- Any application needing to limit access to sensitive features or data + +By leveraging Role-Based Access Control, you can create a secure, scalable, and easily manageable access control system for your application. This feature allows you to implement complex permission structures with ease, ensuring that users only have access to the resources and functionalities appropriate for their role within the system. +`, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: 'https://www.youtube-nocookie.com/embed/kwoKmi6inAw', + docsUrl: + 'https://supabase.com/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac', + slug: 'role-based-access-control', + }, + // Analytics + { + title: 'Reports & Metrics', + subtitle: "Monitor your project's health with usage insights.", + description: ` +Supabase Reports provide comprehensive insights into your project's performance, usage patterns, and overall health. This feature offers a detailed view of various metrics, helping you optimize your application, troubleshoot issues, and make data-driven decisions about resource allocation and scaling. + +## Key benefits +1. Performance monitoring: Track query performance, API response times, and overall system health. +2. Usage insights: Understand how your database, storage, and other resources are being utilized. +3. Cost optimization: Identify opportunities to optimize resource usage and reduce costs. +4. Trend analysis: Observe usage patterns over time to predict future needs and potential issues. +5. Security overview: Monitor authentication attempts, failed queries, and other security-related metrics. +6. Customizable dashboards: Create tailored views of the metrics most important to your team. + +## Reports are particularly valuable for: +- Development teams needing to optimize application performance +- DevOps professionals monitoring system health and resource utilization +- Project managers tracking usage growth and planning for scaling +- Financial teams managing cloud spending and optimizing costs +- Security personnel monitoring for unusual activity or potential threats +- Any stakeholder requiring insights into the project's operational status + +By leveraging Supabase Reports, you gain a understanding of your project's performance and usage patterns. This feature empowers you to make informed decisions about scaling, optimization, and resource allocation, ensuring that your application runs efficiently and cost-effectively. Whether you're troubleshooting issues, planning for growth, or optimizing costs, Supabase Reports provides the insights you need to manage your project effectively. +`, + icon: BarChart, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: '/images/features/reports-and-metrics.png', + heroImageLight: '/images/features/reports-and-metrics-light.png', + docsUrl: 'https://supabase.com/blog/supabase-reports-and-metrics', + slug: 'reports-and-metrics', + }, + { + title: 'SOC 2 Compliance', + subtitle: 'Build with confidence on a SOC 2 compliant platform.', + description: ` +Supabase's SOC 2 Compliance demonstrates its commitment to maintaining the highest standards of security, availability, and confidentiality. This certification assures users that Supabase follows strict information security policies and procedures, encompassing the security, availability, processing, integrity, and confidentiality of customer data. + +## Key benefits +1. Trust and credibility: Build on a platform that has been independently audited for security practices. +2. Risk mitigation: Reduce the risk of data breaches and security incidents. +3. Compliance support: Easier compliance with your own regulatory requirements. +4. Standardized processes: Benefit from Supabase's adherence to industry-standard security practices. +5. Continuous improvement: Supabase's commitment to maintaining SOC 2 compliance ensures ongoing security enhancements. +6. Transparency: Access to SOC 2 reports provides insight into Supabase's security controls. +7. Competitive advantage: Use Supabase's compliance as a selling point for your own services. + +## SOC 2 Compliance is particularly valuable for: +- Enterprises with strict vendor security requirements +- Startups looking to build credibility with enterprise clients +- Financial technology companies handling sensitive financial data +- Healthcare applications dealing with protected health information +- Government contractors requiring compliant cloud services +- Any organization prioritizing data security and privacy in their applications + +Leveraging Supabase's SOC 2 Compliance involves: +1. Reviewing Supabase's SOC 2 report to understand its security practices +2. Aligning your application's security practices with Supabase's standards +3. Utilizing Supabase's compliant features to enhance your own security posture +4. Communicating the benefits of building on a SOC 2 compliant platform to stakeholders + +By building on Supabase's SOC 2 compliant platform, you gain a significant advantage in terms of security and trust. This compliance demonstrates to your users, partners, and regulators that you take data protection seriously and have chosen a backend platform that adheres to rigorous security standards. Whether you're a startup looking to win enterprise clients or an established company aiming to enhance your security posture, Supabase's SOC 2 compliance provides a solid foundation for building secure, trustworthy applications. +`, + icon: ShieldCheck, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: '', + docsUrl: 'https://supabase.com/docs/guides/security/compliance', + slug: 'soc-2-compliance', + }, + // Studio + { + title: 'AI-Powered Query Assistance', + subtitle: 'Get help crafting complex SQL queries effortlessly.', + description: ` +The AI-Powered Query Assistance feature in Supabase provides users with intelligent suggestions for writing SQL queries based on natural language input. This tool is designed to enhance productivity and streamline the development process, making it easier for both novice and experienced developers to interact with their databases. By transforming user queries into optimized SQL commands, it reduces the time spent on query formulation and minimizes errors. + +## Key benefits +1. User-Friendly: Simplifies the query-writing process by allowing users to express their needs in plain language. +2. Time-Saving: Accelerates development by quickly generating accurate SQL queries. +3. Learning Tool: Acts as a learning resource for new developers to understand SQL syntax and best practices. +4. Error Reduction: Minimizes common mistakes in query formulation through intelligent suggestions. +5. Integration: Seamlessly integrates with existing Supabase projects, enhancing the overall user experience. + +This feature is especially beneficial for teams looking to improve their database interaction efficiency while ensuring that all members can contribute effectively, regardless of their SQL expertise. +`, + icon: Brain, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: 'https://www.youtube-nocookie.com/embed/hu2SQjvCXIw', + docsUrl: + 'https://supabase.com/blog/studio-introducing-assistant#introducing-the-supabase-assistant', + slug: 'ai-query-assistance', + }, + { + title: 'Logs & Analytics', + subtitle: 'Gain insights into your application’s performance and usage.', + description: ` +The Logs & Analytics feature in Supabase provides users with comprehensive logging and analytics capabilities, powered by Logflare. This tool enables developers to track and analyze log events from various Supabase services, such as the API gateway, Postgres databases, Storage, Edge Functions, and more. By leveraging a multi-node Elixir cluster, Supabase processes billions of log events daily, ensuring that users have access to critical insights for optimizing their applications. + +## Key benefits +1. Real-Time Monitoring: Access live data on application performance and user interactions to make informed decisions. +2. Comprehensive Log Management: Ingest and store logs from multiple sources, allowing for centralized management of application events. +3. Powerful Querying: Utilize SQL queries through Logflare Endpoints to analyze logs efficiently, enabling users to extract meaningful metrics. +4. Customizable Dashboards: Create tailored views within Supabase Studio to visualize log data and track key performance indicators (KPIs). +5. Scalability: Handle large volumes of log data with a robust infrastructure designed for high availability and performance. + +This feature is particularly valuable for teams looking to enhance their application's reliability and performance by gaining deeper insights into usage patterns and potential issues. +`, + icon: Activity, + products: [ADDITIONAL_PRODUCTS.PLATFORM], + heroImage: '/images/features/logs-analytics.png', + heroImageLight: '/images/features/logs-analytics-light.png', + docsUrl: 'https://supabase.com/docs/guides/monitoring-troubleshooting/logs', + slug: 'logs-analytics', + }, + { + title: 'Visual Schema Designer', + subtitle: 'Design your database schema with an intuitive interface.', + description: ` +The Visual Schema Designer feature allows users to create and modify database schemas through a user-friendly drag-and-drop interface. This tool is aimed at simplifying the database design process, making it accessible for users who may not be familiar with traditional SQL commands. By visualizing relationships between tables and fields, users can efficiently plan their database architecture. + +## Key benefits +1. Intuitive Design: Facilitates easy schema creation through visual representation. +2. Relationship Mapping: Clearly illustrates how tables are interconnected, aiding in effective database design. +3. Accessibility: Makes database management approachable for non-technical users. +4. Real-Time Updates: Automatically reflects changes in the schema, ensuring consistency across the project. +5. Collaboration: Enhances teamwork by allowing multiple users to contribute to schema design simultaneously. + +This feature is particularly valuable for teams engaged in agile development processes where rapid iteration and collaboration are essential. +`, + icon: RectangleEllipsis, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: '/images/features/visual-schema-designer.png', + heroImageLight: '/images/features/visual-schema-designer-light.png', + docsUrl: 'https://supabase.com/blog/supabase-studio-3-0#schema-visualizer', + slug: 'visual-schema-designer', + }, + { + title: 'Policy Templates', + subtitle: 'Quickly implement common security policies.', + description: ` +Policy Templates provide a library of predefined security policies that can be easily applied to your Supabase projects. This feature streamlines the process of establishing role-based access control and other security measures, ensuring that best practices are followed consistently across applications. + +## Key benefits +1. Time Efficient: Reduces the time required to set up security policies by offering ready-made templates. +2. Best Practices: Ensures adherence to industry standards for data security and access control. +3. Customizable: Allows users to modify templates to fit specific project needs while maintaining a strong security foundation. +4. User-Friendly: Simplifies policy application through an intuitive interface within the Supabase Studio. +5. Documentation Support: Each template comes with comprehensive documentation to guide users through implementation. + +This feature is essential for organizations looking to maintain robust security protocols without extensive manual configuration efforts. +`, + icon: ShieldPlus, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: '/images/features/policy-templates.png', + heroImageLight: '/images/features/policy-templates-light.png', + docsUrl: '', + slug: 'policy-templates', + }, + { + title: 'SQL Editor', + subtitle: 'A powerful interface for writing and executing SQL queries.', + description: ` +The SQL Editor in Supabase Studio provides users with a robust platform for writing, executing, and managing SQL queries directly within their browser. This feature is designed to enhance productivity by offering syntax highlighting, auto-completion, and error detection, making it easier for developers to interact with their databases efficiently. + +## Key benefits +1. User-Friendly Interface: Intuitive design that simplifies the process of writing SQL queries. +2. Syntax Highlighting: Enhances readability by color-coding SQL syntax, helping users quickly identify errors. +3. Auto-Completion: Speeds up query writing by suggesting table names, column names, and functions. +4. Execution History: Keeps track of previously executed queries for easy reference and reuse. +5. Error Detection: Provides immediate feedback on syntax errors, reducing debugging time. + +This feature is particularly valuable for developers looking to streamline their workflow while ensuring accurate and efficient database interactions. +`, + icon: FileCode2, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: '/images/features/sql-editor.png', + heroImageLight: '/images/features/sql-editor-light.png', + docsUrl: '', + slug: 'sql-editor', + }, + { + title: 'Security & Performance Advisor', + subtitle: 'Optimize your database security and performance effortlessly.', + description: ` +The Security & Performance Advisor feature in Supabase offers users actionable insights into their database's security posture and performance metrics. By analyzing configurations and usage patterns, this tool identifies potential vulnerabilities and performance bottlenecks, providing recommendations for improvements. + +## Key benefits +1. Proactive Security Checks: Regular assessments of security settings to identify vulnerabilities. +2. Performance Optimization: Analyzes query performance to recommend optimizations that enhance efficiency. +3. User-Friendly Dashboard: Presents findings in an easily digestible format within Supabase Studio. +4. Actionable Recommendations: Provides clear steps for addressing identified issues, empowering users to enhance their database environments. +5. Ongoing Monitoring: Continuously evaluates changes in database usage to adapt recommendations accordingly. + +This feature is essential for organizations aiming to maintain high security standards while ensuring optimal performance across their applications. +`, + icon: ShieldPlus, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: '/images/features/security-and-performance-advisor.png', + heroImageLight: '/images/features/security-and-performance-advisor-light.png', + docsUrl: 'https://supabase.com/blog/security-and-performance-advisor', + slug: 'security-and-performance-advisor', + }, + { + title: 'Postgres Roles', + subtitle: 'Managing access to your Postgres database and configuring permissions.', + description: ` +Postgres Roles are a fundamental aspect of managing access permissions within your Supabase database. Roles can function as individual users or groups of users, allowing for flexible permission management. This feature is essential for setting up secure access to your database while enabling efficient collaboration among team members. + +Key benefits: +1. Granular Access Control: Configure permissions for various database objects, including tables, views, and functions, using the GRANT command. +2. Role Hierarchy: Organize roles in a hierarchy to simplify permission management, allowing child roles to inherit permissions from parent roles. +3. Secure User Management: Create roles with specific login privileges and strong passwords to ensure secure access to your database. +4. Revocation of Permissions: Easily revoke permissions using the REVOKE command, providing control over who has access to what within your database. +5. Predefined Roles: Supabase extends Postgres with a set of predefined roles, simplifying the initial setup for new projects. + +This feature is particularly valuable for teams looking to implement robust security measures while maintaining flexibility in how users interact with their database. +`, + icon: Users, + products: [PRODUCT_SHORTNAMES.DATABASE, ADDITIONAL_PRODUCTS.STUDIO], + heroImage: '/images/features/postgres-roles.png', + heroImageLight: '/images/features/postgres-roles-light.png', + docsUrl: 'https://supabase.com/docs/guides/database/postgres/roles', + slug: 'postgres-roles', + }, + { + title: 'Foreign Key Selector', + subtitle: 'Easily manage foreign key relationships between tables.', + description: ` +The Foreign Key Selector feature simplifies the process of establishing and managing foreign key relationships within your database schema. By providing a visual interface for selecting foreign keys, this tool enhances usability and reduces the likelihood of errors during schema design. + +## Key benefits +1. Visual Management: Allows users to easily visualize and select foreign key relationships between tables. +2. Error Reduction: Minimizes mistakes associated with manual foreign key configuration. +3. Streamlined Workflow: Enhances the efficiency of schema design by simplifying complex relationships. +4. Real-Time Updates: Automatically reflects changes made in the foreign key relationships throughout the project. +5. Documentation Support: Provides contextual information about foreign keys to guide users during setup. + +This feature is particularly beneficial for developers working with complex data models who need a straightforward way to manage relational integrity within their databases. +`, + icon: DatabaseZap, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: + 'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/videos/docs/fk-lookup.mp4', + docsUrl: 'https://supabase.com/blog/supabase-studio-2.0#foreign-key-selector', + slug: 'foreign-key-selector', + }, + { + title: 'Log Drains', + subtitle: 'Export logs to external destinations for enhanced monitoring.', + description: ` +Log Drains enable developers to export logs generated by Supabase products—such as the Database, Storage, Realtime, and Auth—to external destinations like Datadog or custom HTTP endpoints. This feature provides a unified view of logs within existing logging and monitoring systems, allowing teams to build robust alerting and observability pipelines. + +## Key benefits +1. Centralized Logging: Consolidate logs from multiple Supabase services into a single location for easier management and analysis. +2. Custom Alerting: Ingest logs into Security Information and Event Management (SIEM) or Intrusion Detection Systems (IDS) to create tailored alerting rules based on database events. +3. Extended Retention: Supports longer log retention periods to meet compliance requirements, ensuring data availability for audits and investigations. +4. Flexible Configuration: Easily set up Log Drains through the project settings, with support for popular destinations like Datadog and custom HTTP endpoints. +5. Scalable Architecture: Built on Logflare's multi-node Elixir cluster, allowing for efficient and scalable log dispatching to multiple destinations. + +This feature is particularly useful for teams seeking to enhance their observability practices while maintaining compliance and security standards across their applications. +`, + icon: Activity, + products: [ADDITIONAL_PRODUCTS.STUDIO], + heroImage: 'https://www.youtube-nocookie.com/embed/A4GFmvgxS-E', + docsUrl: 'https://supabase.com/blog/log-drains', + slug: 'log-drains', + }, +] diff --git a/apps/www/internals/generate-sitemap.mjs b/apps/www/internals/generate-sitemap.mjs index 6fd2fc4333a..abe470b5b99 100644 --- a/apps/www/internals/generate-sitemap.mjs +++ b/apps/www/internals/generate-sitemap.mjs @@ -28,6 +28,7 @@ async function generate() { '!pages/404.tsx', '.next/server/pages/partners/integrations/*.html', '.next/server/pages/partners/experts/*.html', + '.next/server/pages/features/*.html', ]) const blogUrl = 'blog' @@ -62,6 +63,7 @@ async function generate() { if (route === '/case-studies/[slug]') return null if (route === '/customers/[slug]') return null if (route === '/events/[slug]') return null + if (route === '/features/[slug]') return null if (route === '/blog/categories/[category]') return null if (route === '/partners/experts/[slug]') return null if (route === '/partners/integrations/[slug]') return null diff --git a/apps/www/pages/features.tsx b/apps/www/pages/features.tsx new file mode 100644 index 00000000000..a8654f8f666 --- /dev/null +++ b/apps/www/pages/features.tsx @@ -0,0 +1,219 @@ +import React, { useEffect, useState, useCallback } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import { motion } from 'framer-motion' +import { Search } from 'lucide-react' +import debounce from 'lodash/debounce' + +import { Button, Checkbox, cn, Input } from 'ui' +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import Panel from '~/components/Panel' + +import { features } from '~/data/features' + +function FeaturesPage() { + const router = useRouter() + const { basePath, query } = router + const [searchTerm, setSearchTerm] = useState((query.q as string) || '') + const [selectedProducts, setSelectedProducts] = useState( + (query.products as string)?.split(',') || [] + ) + + const HAS_ACTIVE_FILTERS = selectedProducts.length || searchTerm.length + + const products = Array.from(new Set(features.flatMap((feature) => feature.products))) + + // Debounced function to update URL params + const updateQueryParamsDebounced = useCallback( + debounce(() => updateQueryParams(), 300), + [searchTerm, selectedProducts] + ) + + const updateQueryParams = () => { + const params = new URLSearchParams() + if (searchTerm) params.set('q', searchTerm) + if (selectedProducts.length > 0) params.set('products', selectedProducts.join(',')) + + router.replace({ pathname: '/features', query: params.toString() }, undefined, { + shallow: true, + }) + } + + // Apply filters based on initial URL params on mount + useEffect(() => { + updateQueryParamsDebounced() + return updateQueryParamsDebounced.cancel // Cleanup on unmount + }, [searchTerm, selectedProducts, updateQueryParamsDebounced]) + + // Sync state with query parameters when they change + useEffect(() => { + if (query.q !== searchTerm) setSearchTerm((query.q as string) || '') + if (query.products !== selectedProducts.join(',')) { + setSelectedProducts((query.products as string)?.split(',') || []) + } + }, [query.q, query.products]) + + // Handle search input change + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value) + } + + // Handle product checkbox change + const handleProductChange = (product: string) => { + setSelectedProducts((prev) => + prev.includes(product) ? prev.filter((p) => p !== product) : [...prev, product] + ) + } + + // Filter features based on search term and selected products + const filteredFeatures = features.filter((feature) => { + const matchesSearch = + feature.title.toLowerCase().includes(searchTerm.toLowerCase()) || + feature.subtitle.toLowerCase().includes(searchTerm.toLowerCase()) || + feature.description.toLowerCase().includes(searchTerm.toLowerCase()) + + const matchesProduct = + selectedProducts.length === 0 || + feature.products.some((product) => selectedProducts.includes(product)) + + return matchesSearch && matchesProduct + }) + + const meta = { + title: 'Supabase Features', + description: + 'From authentication to storage, everything you need to build and ship your next project.', + } + + return ( + <> + + + +
      + +

      Supabase Features

      +

      + Everything you need
      to build and ship your next + project. +

      +
      +
      +
      + +
      +
      + } + size="small" + autoComplete="off" + type="search" + placeholder="Search features" + value={searchTerm} + onChange={handleSearchChange} + className="w-full [&_input]:text-base [&_input]:md:text-sm [&_input]:!leading-4" + /> +
      +

      Filter by tags:

      +
      + {products + .sort((a, b) => (a > b ? 1 : -1)) + .map((product) => ( +
      + handleProductChange(product)} + className="[&_input]:m-0" + /> + +
      + ))} +
      +
      + Features selected: {filteredFeatures.length} +
      +
      + +
      +
      +
      + {!filteredFeatures?.length ? ( +

      + No features found with these filters +

      + ) : ( +
      + {filteredFeatures + .sort((a, b) => (a.title > b.title ? 1 : -1)) + .map((feature) => ( + + +
      + +
      +
      +

      + {feature.title} +

      +

      + {feature.subtitle} +

      +
      +
      + + ))} +
      + )} +
      +
      +
      + + ) +} + +export default FeaturesPage diff --git a/apps/www/pages/features/[slug].tsx b/apps/www/pages/features/[slug].tsx new file mode 100644 index 00000000000..db79dc3f6f4 --- /dev/null +++ b/apps/www/pages/features/[slug].tsx @@ -0,0 +1,237 @@ +import { ChevronLeft, ChevronRight, LucideIcon } from 'lucide-react' +import { GetStaticPaths, GetStaticProps } from 'next' +import { NextSeo } from 'next-seo' +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' + +import { Badge, Button, cn, Image } from 'ui' +import DefaultLayout from '~/components/Layouts/Default' +import ShareArticleActions from '~/components/Blog/ShareArticleActions' +import SectionContainer from '~/components/Layouts/SectionContainer' +import CTABanner from '~/components/CTABanner' +import PrevNextFeatureNav from '~/components/PrevNextFeatureNav' + +import { features } from '~/data/features' +import type { FeatureType } from '~/data/features' + +interface FeaturePageProps { + feature: FeatureType + prevFeature: FeatureType | null + nextFeature: FeatureType | null +} + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate paths based on each feature's slug + const paths = features.map((feature) => ({ + params: { slug: feature.slug }, + })) + return { paths, fallback: false } +} + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const { slug } = params as { slug: string } + const featureIndex = features.findIndex((feature) => feature.slug === slug) + + if (featureIndex === -1) { + return { notFound: true } + } + + const feature = features[featureIndex] + const prevFeature = featureIndex > 0 ? features[featureIndex - 1] : features[features.length - 1] + const nextFeature = featureIndex < features.length - 1 ? features[featureIndex + 1] : features[0] + + // Destructure the icon property out, and add the icon name to props instead + const { icon, ...featureWithoutIcon } = feature + const { icon: _prevIcon, ...prevFeatureWithoutIcon } = prevFeature + const { icon: _nextIcon, ...nextFeatureWithoutIcon } = nextFeature + + return { + props: { + feature: featureWithoutIcon, + prevFeature: prevFeatureWithoutIcon, + nextFeature: nextFeatureWithoutIcon, + }, + } +} + +const FeaturePage: React.FC = ({ feature, prevFeature, nextFeature }) => { + const meta = { + title: `${feature.title} | Supabase Features`, + description: feature.subtitle, + url: `https://supabase.com/features/${feature.slug}`, + // image: ogImageUrl, + } + + const Icon = features.find((f) => f.slug === feature.slug)?.icon as LucideIcon + + return ( + <> + + +
      + +
      + +
      +
      + {feature.products.map((product) => ( + + + {product} + + + ))} +
      +

      {feature.title}

      +

      {feature.subtitle}

      +
      +
      + {!!feature.heroImage ? ( + feature.heroImage.includes('.mp4') ? ( + + ) : feature.heroImage.includes('.youtube') ? ( +
      +
      +### Demo using Digital Ocean +1. A Digital Ocean Droplet with 1 GB memory and 25 GB solid-state drive (SSD) is sufficient to start +2. To access the Dashboard, use the ipv4 IP address of your Droplet. +3. If you're unable to access Dashboard, run `docker compose ps` to see if the Studio service is running and healthy. + From fb7c6e49b863e97e88e4c81e935a707bfab0cdfc Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Thu, 31 Oct 2024 00:55:08 +0800 Subject: [PATCH 164/255] feat/ design system: inner side bar docs (#30048) * init * Update inner-side-menu.mdx * Update index.tsx --- apps/design-system/__registry__/index.tsx | 66 ++++++++ apps/design-system/config/docs.ts | 5 + .../docs/fragments/inner-side-menu.mdx | 142 ++++++++++++++++++ .../default/example/inner-side-menu-demo.tsx | 45 ++++++ .../default/example/inner-side-menu-empty.tsx | 74 +++++++++ .../example/inner-side-menu-loading.tsx | 32 ++++ .../inner-side-menu-multiple-sections.tsx | 32 ++++ .../example/inner-side-menu-static-titles.tsx | 40 +++++ .../example/inner-side-menu-with-search.tsx | 92 ++++++++++++ apps/design-system/registry/examples.ts | 30 ++++ packages/ui-patterns/InnerSideMenu/index.tsx | 51 +++++-- 11 files changed, 600 insertions(+), 9 deletions(-) create mode 100644 apps/design-system/content/docs/fragments/inner-side-menu.mdx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-demo.tsx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-empty.tsx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-loading.tsx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx create mode 100644 apps/design-system/registry/default/example/inner-side-menu-with-search.tsx diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index b91cfecb325..1eb10a6990e 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1787,6 +1787,72 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "inner-side-menu-demo": { + name: "inner-side-menu-demo", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-demo")), + source: "", + files: ["registry/default/example/inner-side-menu-demo.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-static-titles": { + name: "inner-side-menu-static-titles", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-static-titles")), + source: "", + files: ["registry/default/example/inner-side-menu-static-titles.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-loading": { + name: "inner-side-menu-loading", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-loading")), + source: "", + files: ["registry/default/example/inner-side-menu-loading.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-multiple-sections": { + name: "inner-side-menu-multiple-sections", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-multiple-sections")), + source: "", + files: ["registry/default/example/inner-side-menu-multiple-sections.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-empty": { + name: "inner-side-menu-empty", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-empty")), + source: "", + files: ["registry/default/example/inner-side-menu-empty.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "inner-side-menu-with-search": { + name: "inner-side-menu-with-search", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/inner-side-menu-with-search")), + source: "", + files: ["registry/default/example/inner-side-menu-with-search.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "multi-select-demo": { name: "multi-select-demo", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index ad2619adf12..fa2e91a1c20 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -102,6 +102,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/info-tooltip', items: [], }, + { + title: 'Inner Side Menu', + href: '/docs/fragments/inner-side-menu', + items: [], + }, { title: 'Form Item Layout', href: '/docs/fragments/form-item-layout', diff --git a/apps/design-system/content/docs/fragments/inner-side-menu.mdx b/apps/design-system/content/docs/fragments/inner-side-menu.mdx new file mode 100644 index 00000000000..329252c1efe --- /dev/null +++ b/apps/design-system/content/docs/fragments/inner-side-menu.mdx @@ -0,0 +1,142 @@ +--- +title: Inner Side Menu +description: InnerSideMenu is a component that provides a collapsible side menu with multiple sections. +component: true +--- + + + +## Examples + +{/* ### Multiple sections */} + +{/* */} + +### Collapsible menus + +Inner Side Menu uses the `` component to wrap the menu items. +This is a wrapper component around the `` component. + +```jsx {10-12,16-17} +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, +} from 'ui-patterns/InnerSideMenu' + +function app() { + return ( + + + + Dashboard + Team + Settings + + + ) +} +``` + +### Static titles + +If you don't need the Collapsible features, you can instead use ``. +This doesn't require the use of the `` parent component wrapped around it. + +You can wrap the `` conponents however you like. Below we've put a `
      ` around them. + +```jsx {1} +Projects +
      + Dashboard + Team + Settings +
      +``` + + + +### Loading state + +Use `` to show a loading state for menu items. + +This item is a wrapper around the `` component, and also includes some y-padding so the items don't layout shift when shifting to loaded state and to look visually separated in a list. + + + +### With search + +Use the following components to add a search input to the menu. + +```jsx + + setSearchTerm(e.target.value)} + aria-labelledby="Search items" + /> + +``` + +There is also a Filter Dropdown component that can be used to sort the menu items. +This can be used inside ``. + +```jsx {9-16} + + setSearchTerm(e.target.value)} + aria-labelledby="Search items" + > + setSort(value)}> + + Sort Alphabetically + + + Sort Reverse Alphabetically + + + + +``` + + + +### Empty list state + +Use `` to show an empty state. + +Use the `actions` prop to add any actions like a ` + } +/> +``` + +You can also use the prop `illustration` to pass in a custom illustration, it accets any React Node. + +```jsx {4-7} + + ../ + + } +/> +``` + + diff --git a/apps/design-system/registry/default/example/inner-side-menu-demo.tsx b/apps/design-system/registry/default/example/inner-side-menu-demo.tsx new file mode 100644 index 00000000000..f8a12c28e29 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-demo.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuDemo() { + return ( + + + Hello + + Dashboard + Team + Settings + + + + + Hello + + + Dashboard + + Team + Settings + + + + Hello + + + Dashboard + + Team + Settings + + + + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-empty.tsx b/apps/design-system/registry/default/example/inner-side-menu-empty.tsx new file mode 100644 index 00000000000..b9abf8bf2d1 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-empty.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideBarShimmeringLoaders, + InnerSideBarEmptyPanel, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' +import { Button, Card } from 'ui' +import { Heart, Pointer } from 'lucide-react' + +export default function InnerSideMenuEmpty() { + const [isLoading, setIsLoading] = React.useState(true) + const [hasItems, setHasItems] = React.useState(false) + + React.useEffect(() => { + // Simulate loading + const timer = setTimeout(() => { + setIsLoading(false) + }, 2000) + return () => clearTimeout(timer) + }, []) + + return ( + + + + + 🚀
      } + actions={ + + } + /> + +
      + + + + + +
      + +
      + + + } + actions={ + + } + /> +
      +
      + + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-loading.tsx b/apps/design-system/registry/default/example/inner-side-menu-loading.tsx new file mode 100644 index 00000000000..46e096f1c09 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-loading.tsx @@ -0,0 +1,32 @@ +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleContent, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuItemLoading, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuLoading() { + return ( + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx b/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx new file mode 100644 index 00000000000..c31c62c0b78 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-multiple-sections.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuMultipleSections() { + return ( +
      + + + + + API + + Database + + + + + + + Reports + Usage + + +
      + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx b/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx new file mode 100644 index 00000000000..12df6a102e2 --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-static-titles.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideMenuSeparator, + InnerSideBarTitle, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuBasic() { + return ( + +
      + Projects + Dashboard + Team + Settings +
      + +
      + Projects + + Dashboard + + Team + Settings +
      +
      + Projects + + Dashboard + + Team + Settings +
      +
      + ) +} diff --git a/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx b/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx new file mode 100644 index 00000000000..1fdbac66c4f --- /dev/null +++ b/apps/design-system/registry/default/example/inner-side-menu-with-search.tsx @@ -0,0 +1,92 @@ +import React, { useState, useMemo } from 'react' +import { Card } from 'ui' +import { + InnerSideMenuCollapsible, + InnerSideMenuCollapsibleTrigger, + InnerSideMenuCollapsibleContent, + InnerSideMenuItem, + InnerSideBarFilters, + InnerSideBarFilterSearchInput, + InnerSideBarFilterSortDropdown, + InnerSideBarFilterSortDropdownItem, + InnerSideBarEmptyPanel, +} from 'ui-patterns/InnerSideMenu' + +export default function InnerSideMenuWithSearch() { + const [sort, setSort] = useState('alphabetical') + const [searchTerm, setSearchTerm] = useState('') + + const foodCategories = { + Fruits: ['Apple', 'Banana', 'Orange', 'Strawberry'], + Vegetables: ['Carrot', 'Broccoli', 'Spinach', 'Tomato'], + Grains: ['Rice', 'Wheat', 'Oats', 'Quinoa'], + Proteins: ['Chicken', 'Beef', 'Fish', 'Tofu'], + } + + const filteredAndSortedCategories = useMemo(() => { + return Object.entries(foodCategories).reduce( + (acc, [category, items]) => { + let filteredItems = items.filter((item) => + item.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + // Sort the filtered items + filteredItems.sort((a, b) => { + if (sort === 'alphabetical') { + return a.localeCompare(b) + } else { + return b.localeCompare(a) + } + }) + + acc[category] = filteredItems + return acc + }, + {} as Record + ) + }, [foodCategories, searchTerm, sort]) + + return ( + + + setSearchTerm(e.target.value)} + aria-labelledby="Search items" + > + setSort(value)}> + + Sort Alphabetically + + + Sort Reverse Alphabetically + + + + + + {Object.entries(filteredAndSortedCategories).map(([category, items]) => ( + + + + {items.length > 0 ? ( + items.map((item) => ( + + {item} + + )) + ) : ( + + )} + + + ))} + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 6cd26e5e819..cd11b55aee5 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1030,6 +1030,36 @@ export const examples: Registry = [ type: 'components:example', files: ['example/info-tooltip-demo.tsx'], }, + { + name: 'inner-side-menu-demo', + type: 'components:example', + files: ['example/inner-side-menu-demo.tsx'], + }, + { + name: 'inner-side-menu-static-titles', + type: 'components:example', + files: ['example/inner-side-menu-static-titles.tsx'], + }, + { + name: 'inner-side-menu-loading', + type: 'components:example', + files: ['example/inner-side-menu-loading.tsx'], + }, + { + name: 'inner-side-menu-multiple-sections', + type: 'components:example', + files: ['example/inner-side-menu-multiple-sections.tsx'], + }, + { + name: 'inner-side-menu-empty', + type: 'components:example', + files: ['example/inner-side-menu-empty.tsx'], + }, + { + name: 'inner-side-menu-with-search', + type: 'components:example', + files: ['example/inner-side-menu-with-search.tsx'], + }, { name: 'multi-select-demo', type: 'components:example', diff --git a/packages/ui-patterns/InnerSideMenu/index.tsx b/packages/ui-patterns/InnerSideMenu/index.tsx index 52f946884d2..89de8af534e 100644 --- a/packages/ui-patterns/InnerSideMenu/index.tsx +++ b/packages/ui-patterns/InnerSideMenu/index.tsx @@ -1,4 +1,4 @@ -import { ChevronsDown, Search } from 'lucide-react' +import { ChevronRight, ChevronsDown, Search } from 'lucide-react' import Link from 'next/link' import { ElementRef, forwardRef } from 'react' import { @@ -11,6 +11,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, Input_Shadcn_, + Skeleton, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, @@ -18,11 +19,27 @@ import { } from 'ui' import ShimmeringLoader from '../ShimmeringLoader' +const InnerSideBarTitle = forwardRef>( + (props, ref) => { + const { className, ...restProps } = props + return ( + + ) + } +) + const InnerSideMenuCollapsible = forwardRef< ElementRef, React.ComponentPropsWithoutRef >(({ ...props }, ref) => { - return + return }) const InnerSideMenuCollapsibleTrigger = forwardRef< @@ -31,19 +48,18 @@ const InnerSideMenuCollapsibleTrigger = forwardRef< >(({ ...props }, ref) => { return ( - {/* */} + /> {props.title} ) @@ -55,8 +71,12 @@ const InnerSideMenuCollapsibleContent = forwardRef< >(({ ...props }, ref) => { return ( ) }) @@ -92,6 +112,17 @@ const InnerSideMenuItem = forwardRef< ) }) +function InnerSideMenuItemLoading({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( +
      + +
      + ) +} + const InnerSideBarFilters = forwardRef>( (props, ref) => { return ( @@ -199,7 +230,7 @@ const InnerSideBarEmptyPanel = forwardRef< ref={ref} {...props} className={cn( - 'border bg-surface-100/50 flex flex-col gap-y-3 items-center justify-center rounded-md px-3 py-4', + 'border bg-surface-100/50 flex flex-col gap-y-3 items-center justify-center rounded-md px-5 py-4', props.className )} > @@ -217,14 +248,16 @@ const InnerSideBarEmptyPanel = forwardRef< export { InnerSideBarEmptyPanel, + InnerSideBarFilters, InnerSideBarFilterSearchInput, InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, - InnerSideBarFilters, InnerSideBarShimmeringLoaders, + InnerSideBarTitle, InnerSideMenuCollapsible, InnerSideMenuCollapsibleContent, InnerSideMenuCollapsibleTrigger, InnerSideMenuItem, + InnerSideMenuItemLoading, InnerSideMenuSeparator, } From 30253993bcfe381f6b31c797e8bfc8d2cf266654 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Wed, 30 Oct 2024 19:12:45 -0230 Subject: [PATCH 165/255] Chore/ch blog edits (#30196) Sm edits --- .../_blog/2024-10-30-supabase-clickhouse-partnership.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/www/_blog/2024-10-30-supabase-clickhouse-partnership.mdx b/apps/www/_blog/2024-10-30-supabase-clickhouse-partnership.mdx index 979d8074ee7..08d4d48768b 100644 --- a/apps/www/_blog/2024-10-30-supabase-clickhouse-partnership.mdx +++ b/apps/www/_blog/2024-10-30-supabase-clickhouse-partnership.mdx @@ -16,7 +16,7 @@ toc_depth: 3 We're seeing an emerging trend for AI customers: Postgres and ClickHouse is becoming the “default data stack”. -This makes sense - AI companies typically generate a lot of logs and and analytical data, which is better suited for an OLAP database like ClickHouse. +This makes sense - AI companies typically generate a lot of logs and analytical data, which is better suited for an OLAP database like ClickHouse. @@ -146,7 +146,7 @@ This is driven directly by customers using tools like PeerDB. With adjustable WA - Default: 7GB database space, 1GB Write Ahead Log - Custom example: 6GB database space, 2 GB Write Ahead Log -Additionally, we're introducing High-performance Disks. High-performance Disks. We'll release more details about this later. +Additionally, we're introducing High-performance Disks. We'll release more details about this later. ### ClickHouse platform updates @@ -169,7 +169,7 @@ Improving the experience between Postgres and ClickHouse is the first phase of t }} /> -If you simply want to try out the tools and updates we've described above, you can get started with all of then, free of charge: +If you simply want to try out the tools and updates we've described above, you can get started with all of them, free of charge: - Supabase: [database.new](https://database.new) - ClickHouse: [clickhouse.com](https://clickhouse.com/) From db2e977f7d532c151741a00f9bec8b903e4900f7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 31 Oct 2024 10:07:53 +0800 Subject: [PATCH 166/255] Add spam validation check in auth email templates (#30188) * Add spam validation check in auth email templates * Update comment * Add another banding * Update --- .../Auth/EmailTemplates/EmailTemplates.tsx | 22 +---- .../Auth/EmailTemplates/SpamValidation.tsx | 60 ++++++++++++ .../Auth/EmailTemplates/TemplateEditor.tsx | 94 ++++++++++++++----- .../components/ui/CodeEditor/CodeEditor.tsx | 5 +- .../data/auth/validate-spam-mutation.ts | 51 ++++++++++ packages/api-types/types/api.d.ts | 42 +++++++++ packages/ui/src/components/StatusIcon.tsx | 1 + 7 files changed, 229 insertions(+), 46 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx create mode 100644 apps/studio/data/auth/validate-spam-mutation.ts diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index 435af941871..a0e0790ee04 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -1,18 +1,12 @@ import { ExternalLink } from 'lucide-react' import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useAuthConfigQuery } from 'data/auth/auth-config-query' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - Tabs, - WarningIcon, -} from 'ui' +import { Button, Tabs } from 'ui' import { TEMPLATES_SCHEMAS } from '../AuthTemplatesValidation' import EmailRateLimitsAlert from '../EmailRateLimitsAlert' import TemplateEditor from './TemplateEditor' @@ -52,11 +46,7 @@ const EmailTemplates = () => {
      {isError && ( - - - Failed to retrieve auth configuration - {authConfigError.message} - + )} {isLoading && } {isSuccess && ( @@ -77,11 +67,7 @@ const EmailTemplates = () => {
    ) : null} - + ) })} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx new file mode 100644 index 00000000000..889a8795e7d --- /dev/null +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/SpamValidation.tsx @@ -0,0 +1,60 @@ +import { Check } from 'lucide-react' + +import Table from 'components/to-be-cleaned/Table' +import InformationBox from 'components/ui/InformationBox' +import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation' +import { cn, WarningIcon } from 'ui' + +interface SpamValidationProps { + validationResult?: ValidateSpamResponse +} + +// [Joshen] According to API, we label as a spam risk as long as there are spam +// rules identified with scores above 0. Scores are irrelevant in our context and +// are hence not visualized in the UI + +export const SpamValidation = ({ validationResult }: SpamValidationProps) => { + const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) + const hasSpamWarning = spamRules.length > 0 + + return ( + : } + title={ + hasSpamWarning + ? 'Email has a high probability of being marked as spam and deliverability may be affected' + : 'Email content is unlikely to be marked as spam' + } + description={ + hasSpamWarning ? ( + <> +

    + {spamRules.length > 0 + ? hasSpamWarning + ? ` Rectify the following issues to improve your email's deliverability in order of priority:` + : ` Address the following issues to improve your email's deliverability:` + : ''} +

    + + {spamRules.length > 0 && ( + Warning, + Description, + ]} + body={spamRules.map((rule) => ( + + {rule.name} + {rule.desc} + + ))} + /> + )} + + ) : null + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index b0bb7cbaf96..bd30ff54ea6 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -1,5 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect, useMemo, useState } from 'react' +import { debounce } from 'lodash' +import { Code, Monitor } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' @@ -9,18 +11,29 @@ import { FormActions } from 'components/ui/Forms/FormActions' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' import InformationBox from 'components/ui/InformationBox' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import type { FormSchema } from 'types' -import { AlertTitle_Shadcn_, Alert_Shadcn_, Form, Input, Tabs } from 'ui' -import { Code, Monitor, Info } from 'lucide-react' +import { Form, Input, Tabs } from 'ui' +import { Admonition } from 'ui-patterns' +import { SpamValidation } from './SpamValidation' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' interface TemplateEditorProps { template: FormSchema - authConfig: Record } -const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { +const TemplateEditor = ({ template }: TemplateEditorProps) => { const { ref: projectRef } = useParams() + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + + // [Joshen] Error state is handled in the parent + const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef }) + + const { mutate: validateSpam } = useValidateSpamMutation({ + onSuccess: (res) => setValidationResult(res), + }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation({ onError: (error) => toast.error(`Failed to update email templates: ${error.message}`), }) @@ -31,17 +44,27 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { const INITIAL_VALUES = useMemo(() => { const result: { [x: string]: string } = {} Object.keys(properties).forEach((key) => { - result[key] = (authConfig && authConfig[key]) ?? '' + result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string }) return result }, [authConfig, properties]) - const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` + const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig const messageProperty = properties[messageSlug] + const builtInSMTP = + isSuccess && + authConfig && + (!authConfig.SMTP_HOST || !authConfig.SMTP_USER || !authConfig.SMTP_PASS) + + const [validationResult, setValidationResult] = useState() const [bodyValue, setBodyValue] = useState((authConfig && authConfig[messageSlug]) ?? '') const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + const debounceValidateSpam = useCallback(debounce(validateSpam, 1000), []) + const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0) + const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0 + const onSubmit = (values: any, { resetForm }: any) => { const payload = { ...values } @@ -80,6 +103,21 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { } }, [hasUnsavedChanges]) + useEffect(() => { + if (projectRef && id && !!authConfig) { + const [subjectKey] = Object.keys(properties) + + validateSpam({ + projectRef, + template: { + subject: authConfig[subjectKey as keyof typeof authConfig] as string, + content: authConfig[messageSlug], + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + return (
    {({ resetForm, values, initialValues }: any) => { @@ -127,7 +165,7 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { })} - + {messageProperty && ( <> @@ -147,7 +185,8 @@ const TemplateEditor = ({ template, authConfig }: TemplateEditorProps) => { /> - } label="Source"> + } label="Source"> +
    { className="!mb-0 h-96 overflow-hidden rounded border" onInputChange={(e: string | undefined) => { setBodyValue(e ?? '') - if (bodyValue !== e) { - setHasUnsavedChanges(true) + if (bodyValue !== e) setHasUnsavedChanges(true) + + if (projectRef) { + const [subjectKey] = Object.keys(values) + debounceValidateSpam({ + projectRef, + template: { subject: values[subjectKey], content: e ?? '' }, + }) } }} - options={{ wordWrap: 'off', contextmenu: false }} + options={{ wordWrap: 'on', contextmenu: false }} value={bodyValue} />
    - } label="Preview"> - - - - The preview may differ slightly from the actual rendering in the email - client. - - + } label="Preview"> +