diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f7ea12c26a599..3feaae032a117 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,7 +14,7 @@ /docker/ @supabase/dev-workflows -/apps/studio/next.config.js @supabase/security +/apps/studio/csp.js @supabase/security /apps/studio/components/interfaces/Billing/Payment @supabase/security /apps/studio/components/interfaces/Organization/BillingSettings/ @supabase/security /apps/studio/components/interfaces/Organization/Documents/ @supabase/security diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index 5f4042a381a3e..420f18093ce36 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -11,6 +11,7 @@ import { genFaviconData } from 'common/MetaFavicons/app-router' import { GlobalProviders } from '~/features/app.providers' import { TopNavSkeleton } from '~/layouts/MainSkeleton' import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants' +import { TelemetryTagManager } from 'common' const metadata: Metadata = { applicationName: 'Supabase Docs', @@ -47,6 +48,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => { return ( + {children} diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json index 41bf9f6455c0b..218e751f61b35 100644 --- a/apps/docs/turbo.json +++ b/apps/docs/turbo.json @@ -33,6 +33,7 @@ "NEXT_PUBLIC_AUTH_PERSISTED_KEY", "NEXT_PUBLIC_AUTH_NAVIGATOR_LOCK_KEY", "NEXT_PUBLIC_AUTH_DETECT_SESSION_IN_URL", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", "NEXT_PUBLIC_GOTRUE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", // These envs are technically passthrough env vars because they're only used on the server side of Nextjs diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index f688c4340fa9d..dc6719685099c 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -37,3 +37,5 @@ yarn-error.log* /public/dashboard # Sentry Auth Token .sentryclirc + +certificates \ No newline at end of file diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 3eee09b85566a..263d20bfd4d5d 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -77,6 +77,7 @@ const Indexes = () => { projectRef: project.ref, connectionString: project.connectionString, name: index.name, + schema: selectedSchema, }) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx index dbebba3473166..e1b473bf68e23 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx @@ -1,4 +1,3 @@ -import { useQueryClient } from '@tanstack/react-query' import { Check, InfoIcon } from 'lucide-react' import Link from 'next/link' import { useMemo, useRef, useState } from 'react' @@ -11,7 +10,6 @@ import { } from 'components/interfaces/Billing/Subscription/Subscription.utils' import AlertError from 'components/ui/AlertError' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { organizationKeys } from 'data/organizations/keys' import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview' import { ProjectInfo } from 'data/projects/projects-query' import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation' @@ -115,6 +113,7 @@ export const SubscriptionPlanUpdateDialog = ({ ) const onSuccessfulPlanChange = () => { + setPaymentConfirmationLoading(false) toast.success( `Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!` ) @@ -133,6 +132,7 @@ export const SubscriptionPlanUpdateDialog = ({ onSuccessfulPlanChange() }, onError: (error) => { + setPaymentConfirmationLoading(false) toast.error(`Unable to update subscription: ${error.message}`) }, } @@ -168,9 +168,13 @@ export const SubscriptionPlanUpdateDialog = ({ if (!selectedOrganization?.slug) return console.error('org slug is required') if (!selectedTier) return console.error('Selected plan is required') + setPaymentConfirmationLoading(true) + const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod() if (paymentMethod) { setSelectedPaymentMethod(paymentMethod.id) + } else { + setPaymentConfirmationLoading(false) } if ( diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx index 8cfe89816f05d..4883fc624afa3 100644 --- a/apps/studio/components/interfaces/Reports/ReportChart.tsx +++ b/apps/studio/components/interfaces/Reports/ReportChart.tsx @@ -7,7 +7,7 @@ * This component acts as a bridge between the data-fetching logic and the * presentational chart component. */ -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import LogChartHandler from 'components/ui/Charts/LogChartHandler' import { useChartData } from 'hooks/useChartData' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' @@ -43,7 +43,7 @@ const ReportChart = ({ }) return ( - 0 ? chartAttributes : chart.attributes) as MultiAttribute[] diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 84f500221999d..1c90a17dd37a4 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -1,11 +1,5 @@ -/** - * ComposedChartHandler - * - * A presentational component for rendering charts. - * It is responsible only for rendering the chart UI based on the data and loading state passed to it as props. - * All the complex data fetching logic has been moved to the useChartData hook. - */ -import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' +import React, { PropsWithChildren, useState, useMemo, useEffect, useRef } from 'react' +import { useRouter } from 'next/router' import { Loader2 } from 'lucide-react' import { cn, WarningIcon } from 'ui' @@ -17,14 +11,14 @@ import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useChartHighlight } from './useChartHighlight' -import { getMockDataForAttribute } from 'data/reports/auth-charts' import type { ChartData } from './Charts.types' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' -import type { MultiAttribute } from './ComposedChart.utils' +import { MultiAttribute } from './ComposedChart.utils' -interface ComposedChartHandlerProps { +export interface ComposedChartHandlerProps { id?: string label: string attributes: MultiAttribute[] @@ -46,7 +40,6 @@ interface ComposedChartHandlerProps { updateDateRange: UpdateDateRange valuePrecision?: number isVisible?: boolean - titleTooltip?: string docsUrl?: string } @@ -98,6 +91,9 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => { const ComposedChartHandler = ({ label, attributes, + startDate, + endDate, + interval, customDateFormat, children = null, defaultChartStyle = 'bar', @@ -113,14 +109,131 @@ const ComposedChartHandler = ({ showTotal, updateDateRange, valuePrecision, - titleTooltip, + isVisible = true, id, ...otherProps }: PropsWithChildren) => { + const router = useRouter() + const { ref } = router.query + + const state = useDatabaseSelectorStateSnapshot() const [chartStyle, setChartStyle] = useState(defaultChartStyle) const chartHighlight = useChartHighlight() - if (isLoading) { + const databaseIdentifier = state.selectedDatabaseId + + // Use the custom hook at the top level of the component + const attributeQueries = useAttributeQueries( + attributes, + ref, + startDate, + endDate, + interval as AnalyticsInterval, + databaseIdentifier, + data, + isVisible + ) + + // Combine all the data into a single dataset + const combinedData = useMemo(() => { + if (data) return data + + const isLoading = attributeQueries.some((query: any) => query.isLoading) + if (isLoading) return undefined + + const hasError = attributeQueries.some((query: any) => !query.data) + if (hasError) return undefined + + // Get all unique timestamps from all datasets + const timestamps = new Set() + attributeQueries.forEach((query: any) => { + query.data?.data?.forEach((point: any) => { + if (point?.period_start) { + timestamps.add(point.period_start) + } + }) + }) + + const referenceLineQueries = attributeQueries.filter( + (_, index) => attributes[index].provider === 'reference-line' + ) + + // Combine data points for each timestamp + const combined = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { timestamp } + + // Add regular attributes + attributes.forEach((attr, index) => { + if (!attr) return + + // Handle custom value attributes (like disk size) + if (attr.customValue !== undefined) { + point[attr.attribute] = attr.customValue + return + } + + // Skip reference line attributes here, we'll add them below + if (attr.provider === 'reference-line') return + + const queryData = attributeQueries[index]?.data?.data + const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp) + let value = matchingPoint?.[attr.attribute] ?? 0 + + // Apply value manipulation if provided + if (attr.manipulateValue && typeof attr.manipulateValue === 'function') { + // Ensure value is a number before manipulation + const numericValue = typeof value === 'number' ? value : Number(value) || 0 + value = attr.manipulateValue(numericValue) + } + + point[attr.attribute] = value + }) + + // Add reference line values for each timestamp + referenceLineQueries.forEach((query: any) => { + const attr = query.data.attribute + const value = query.data.total + point[attr] = value + }) + + return point as DataPoint + }) + + return combined as DataPoint[] + }, [data, attributeQueries, attributes]) + + const loading = isLoading || attributeQueries.some((query: any) => query.isLoading) + + // Calculate highlighted value based on the first attribute's data + const _highlightedValue = useMemo(() => { + if (highlightedValue !== undefined) return highlightedValue + + const firstAttr = attributes[0] + const firstQuery = attributeQueries[0] + const firstData = firstQuery?.data + + if (!firstData) return undefined + + const shouldHighlightMaxValue = + firstAttr.provider === 'daily-stats' && + !firstAttr.attribute.includes('ingress') && + !firstAttr.attribute.includes('egress') && + 'maximum' in firstData + + const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData + + return shouldHighlightMaxValue + ? firstData.maximum + : firstAttr.provider === 'daily-stats' + ? firstData.total + : shouldHighlightTotalGroupedValue + ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] + : (firstData.data[firstData.data.length - 1] as any)?.[firstAttr.attribute] + }, [highlightedValue, attributes, attributeQueries]) + + if (loading) { return ( @@ -159,11 +272,11 @@ const ComposedChartHandler = ({
{children}
@@ -184,7 +296,7 @@ const ComposedChartHandler = ({ ) } -export const useAttributeQueries = ( +const useAttributeQueries = ( attributes: MultiAttribute[], ref: string | string[] | undefined, startDate: string, @@ -194,15 +306,16 @@ export const useAttributeQueries = ( data: ChartData | undefined, isVisible: boolean ) => { - const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' - - const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') - const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') - const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') - const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') + const infraAttributes = attributes + .filter((attr) => attr?.provider === 'infra-monitoring') + .map((attr) => attr.attribute as InfraMonitoringAttribute) + const dailyStatsAttributes = attributes + .filter((attr) => attr?.provider === 'daily-stats') + .map((attr) => attr.attribute as ProjectDailyStatsAttribute) + const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line') const infraQueries = useInfraMonitoringQueries( - infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute), + infraAttributes, ref, startDate, endDate, @@ -212,7 +325,7 @@ export const useAttributeQueries = ( isVisible ) const dailyStatsQueries = useProjectDailyStatsQueries( - dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), + dailyStatsAttributes, ref, startDate, endDate, @@ -222,48 +335,23 @@ export const useAttributeQueries = ( isVisible ) - let infraIdx = 0 - let dailyStatsIdx = 0 - return attributes - .filter((attr) => attr.provider !== 'logs') - .map((attr) => { - if (attr.provider === 'infra-monitoring') { - return { - ...infraQueries[infraIdx++], - data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, - } - } else if (attr.provider === 'daily-stats') { - return { - ...dailyStatsQueries[dailyStatsIdx++], - data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, - } - } else if (attr.provider === 'mock') { - const mockData = getMockDataForAttribute(attr.attribute) - return { - isLoading: false, - data: { ...mockData, provider: 'mock', attribute: attr.attribute }, - } - } else if (attr.provider === 'reference-line') { - let value = attr.value || 0 - return { - data: { - data: [], - attribute: attr.attribute, - total: value, - maximum: value, - totalGrouped: { [attr.attribute]: value }, - provider: 'reference-line', - }, - isLoading: false, - isError: false, - } - } else { - return { - isLoading: false, - data: undefined, - } - } - }) + const referenceLineQueries = referenceLines.map((line) => { + let value = line.value || 0 + + return { + data: { + data: [], // Will be populated in combinedData + attribute: line.attribute, + total: value, + maximum: value, + totalGrouped: { [line.attribute]: value }, + }, + isLoading: false, + isError: false, + } + }) + + return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries] } export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) { diff --git a/apps/studio/components/ui/Charts/LogChartHandler.tsx b/apps/studio/components/ui/Charts/LogChartHandler.tsx new file mode 100644 index 0000000000000..2db174a9d1918 --- /dev/null +++ b/apps/studio/components/ui/Charts/LogChartHandler.tsx @@ -0,0 +1,268 @@ +import React, { PropsWithChildren, useState, useEffect, useRef } from 'react' +import { Loader2 } from 'lucide-react' +import { cn, WarningIcon } from 'ui' + +import Panel from 'components/ui/Panel' +import ComposedChart from './ComposedChart' + +import { AnalyticsInterval, DataPoint } from 'data/analytics/constants' +import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' +import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries' +import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query' +import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries' +import { useChartHighlight } from './useChartHighlight' +import { getMockDataForAttribute } from 'data/reports/auth-charts' + +import type { ChartData } from './Charts.types' +import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' +import type { MultiAttribute } from './ComposedChart.utils' + +interface LogChartHandlerProps { + id?: string + label: string + attributes: MultiAttribute[] + startDate: string + endDate: string + interval: string + customDateFormat?: string + defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine' + hideChartType?: boolean + data?: ChartData + isLoading?: boolean + format?: string + highlightedValue?: string | number + className?: string + showTooltip?: boolean + showLegend?: boolean + showTotal?: boolean + showMaxValue?: boolean + updateDateRange: UpdateDateRange + valuePrecision?: number + isVisible?: boolean + titleTooltip?: string + docsUrl?: string +} + +/** + * Wrapper component that handles intersection observer logic for lazy loading + */ +const LazyChartWrapper = ({ children }: PropsWithChildren) => { + const [isVisible, setIsVisible] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true) + observer.disconnect() + } + }, + { + rootMargin: '150px 0px', // Start loading before the component enters viewport + threshold: 0, + } + ) + + const currentRef = ref.current + if (currentRef) { + observer.observe(currentRef) + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef) + } + } + }, []) + + return
{React.cloneElement(children as React.ReactElement, { isVisible })}
+} + +/** + * Controls chart display state. Optionally fetches static chart data if data is not provided. + * + * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render. + * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided. + * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown. + * + * Provided data must be in the expected chart format. + */ +const LogChartHandler = ({ + label, + attributes, + customDateFormat, + children = null, + defaultChartStyle = 'bar', + hideChartType = false, + data, + isLoading, + format, + highlightedValue, + className, + showTooltip, + showLegend, + showMaxValue, + showTotal, + updateDateRange, + valuePrecision, + titleTooltip, + id, + ...otherProps +}: PropsWithChildren) => { + const [chartStyle, setChartStyle] = useState(defaultChartStyle) + const chartHighlight = useChartHighlight() + + if (isLoading) { + return ( + + +

Loading data for {label}

+
+ ) + } + + if (!data) { + return ( +
+ +

Unable to load data for {label}

+
+ ) + } + + // Rest of the component remains similar, but pass all attributes to charts + return ( + + +
{children}
+ +
+
+ ) +} + +export const useAttributeQueries = ( + attributes: MultiAttribute[], + ref: string | string[] | undefined, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + databaseIdentifier: string | undefined, + data: ChartData | undefined, + isVisible: boolean +) => { + const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : '' + + const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring') + const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats') + const mockAttributes = attributes.filter((attr) => attr.provider === 'mock') + const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line') + + const infraQueries = useInfraMonitoringQueries( + infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + const dailyStatsQueries = useProjectDailyStatsQueries( + dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute), + ref, + startDate, + endDate, + interval, + databaseIdentifier, + data, + isVisible + ) + + let infraIdx = 0 + let dailyStatsIdx = 0 + return attributes + .filter((attr) => attr.provider !== 'logs') + .map((attr) => { + if (attr.provider === 'infra-monitoring') { + return { + ...infraQueries[infraIdx++], + data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' }, + } + } else if (attr.provider === 'daily-stats') { + return { + ...dailyStatsQueries[dailyStatsIdx++], + data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' }, + } + } else if (attr.provider === 'mock') { + const mockData = getMockDataForAttribute(attr.attribute) + return { + isLoading: false, + data: { ...mockData, provider: 'mock', attribute: attr.attribute }, + } + } else if (attr.provider === 'reference-line') { + let value = attr.value || 0 + return { + data: { + data: [], + attribute: attr.attribute, + total: value, + maximum: value, + totalGrouped: { [attr.attribute]: value }, + provider: 'reference-line', + }, + isLoading: false, + isError: false, + } + } else { + return { + isLoading: false, + data: undefined, + } + } + }) +} + +export default function LazyLogChartHandler(props: LogChartHandlerProps) { + return ( + + + + ) +} diff --git a/apps/studio/components/ui/GroupsTelemetry.tsx b/apps/studio/components/ui/GroupsTelemetry.tsx index 1c05ec91cb566..d138179ec2eba 100644 --- a/apps/studio/components/ui/GroupsTelemetry.tsx +++ b/apps/studio/components/ui/GroupsTelemetry.tsx @@ -8,7 +8,6 @@ import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identi import { useSendGroupsResetMutation } from 'data/telemetry/send-groups-reset-mutation' import { usePrevious } from 'hooks/deprecated' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useAppStateSnapshot } from 'state/app-state' import { IS_PLATFORM } from 'lib/constants' const getAnonId = async (id: string) => { diff --git a/apps/studio/csp.js b/apps/studio/csp.js new file mode 100644 index 0000000000000..f4ddabfcc7617 --- /dev/null +++ b/apps/studio/csp.js @@ -0,0 +1,187 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL + ? new URL(process.env.NEXT_PUBLIC_API_URL).origin + : '' +const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : '' +const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL + ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin + : '' +const SUPABASE_PROJECTS_URL = 'https://*.supabase.co' +const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co' + +// construct the URL for the Websocket Local URLs +let SUPABASE_LOCAL_PROJECTS_URL_WS = '' +if (SUPABASE_URL) { + const url = new URL(SUPABASE_URL) + const wsUrl = `${url.hostname}:${url.port}` + SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}` +} + +// Needed to test docs search in local dev +const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL + ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin + : '' + +// Needed to test docs content API in local dev +const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL + ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin + : '' + +const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' +const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' +const SUPABASE_COM_URL = 'https://supabase.com' +const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com' +const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com' +const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com' +const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com' +const CONFIGCAT_URL = 'https://cdn-global.configcat.com' +const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT ?? '') + ? 'https://configcat.supabase.green' + : 'https://configcat.supabase.com' +const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' +const STRIPE_JS_URL = 'https://js.stripe.com' +const STRIPE_NETWORK_URL = 'https://*.stripe.network' +const CLOUDFLARE_URL = 'https://www.cloudflare.com' +const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' +const VERCEL_URL = 'https://vercel.com' +const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' +const GITHUB_API_URL = 'https://api.github.com' +const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com' +const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com' +const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com' + +// This is a custom domain for Stape, which isused for GTM servers +const STAPE_URL = 'https://ss.supabase.com' + +const VERCEL_LIVE_URL = 'https://vercel.live' +const SENTRY_URL = + 'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io' +const SUPABASE_ASSETS_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? 'https://frontend-assets.supabase.green' + : 'https://frontend-assets.supabase.com' + +const USERCENTRICS_URLS = 'https://*.usercentrics.eu' +const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' + +// used by vercel live preview +const PUSHER_URL = 'https://*.pusher.com' +const PUSHER_URL_WS = 'wss://*.pusher.com' + +module.exports.getCSP = function getCSP() { + const DEFAULT_SRC_URLS = [ + API_URL, + SUPABASE_URL, + GOTRUE_URL, + SUPABASE_LOCAL_PROJECTS_URL_WS, + SUPABASE_PROJECTS_URL, + SUPABASE_PROJECTS_URL_WS, + HCAPTCHA_SUBDOMAINS_URL, + CONFIGCAT_URL, + CONFIGCAT_PROXY_URL, + STRIPE_SUBDOMAINS_URL, + STRIPE_NETWORK_URL, + CLOUDFLARE_URL, + ONE_ONE_ONE_ONE_URL, + VERCEL_INSIGHTS_URL, + GITHUB_API_URL, + GITHUB_USER_CONTENT_URL, + SUPABASE_ASSETS_URL, + USERCENTRICS_URLS, + STAPE_URL, + ] + const SCRIPT_SRC_URLS = [ + CLOUDFLARE_CDN_URL, + HCAPTCHA_JS_URL, + STRIPE_JS_URL, + SUPABASE_ASSETS_URL, + STAPE_URL, + ] + const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL] + const IMG_SRC_URLS = [ + SUPABASE_URL, + SUPABASE_COM_URL, + SUPABASE_PROJECTS_URL, + GITHUB_USER_AVATAR_URL, + GOOGLE_USER_AVATAR_URL, + SUPABASE_ASSETS_URL, + USERCENTRICS_APP_URL, + STAPE_URL, + ] + const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] + const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] + + const isDevOrStaging = + process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + + const defaultSrcDirective = [ + `default-src 'self'`, + ...DEFAULT_SRC_URLS, + ...(isDevOrStaging + ? [ + SUPABASE_STAGING_PROJECTS_URL, + SUPABASE_STAGING_PROJECTS_URL_WS, + VERCEL_LIVE_URL, + SUPABASE_DOCS_PROJECT_URL, + SUPABASE_CONTENT_API_URL, + ] + : []), + PUSHER_URL_WS, + SENTRY_URL, + ].join(' ') + + const imgSrcDirective = [ + `img-src 'self'`, + `blob:`, + `data:`, + ...IMG_SRC_URLS, + ...(isDevOrStaging ? [SUPABASE_STAGING_PROJECTS_URL, VERCEL_URL] : []), + ].join(' ') + + const scriptSrcDirective = [ + `script-src 'self'`, + `'unsafe-eval'`, + `'unsafe-inline'`, + ...SCRIPT_SRC_URLS, + VERCEL_LIVE_URL, + PUSHER_URL, + ].join(' ') + + const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ') + + const styleSrcDirective = [ + `style-src 'self'`, + `'unsafe-inline'`, + ...STYLE_SRC_URLS, + VERCEL_LIVE_URL, + ].join(' ') + + const fontSrcDirective = [`font-src 'self'`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ') + + const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ') + + const cspDirectives = [ + defaultSrcDirective, + imgSrcDirective, + scriptSrcDirective, + frameSrcDirective, + styleSrcDirective, + fontSrcDirective, + workerSrcDirective, + `object-src 'none'`, + `base-uri 'self'`, + `form-action 'self'`, + `frame-ancestors 'none'`, + `block-all-mixed-content`, + ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && + process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' + ? [`upgrade-insecure-requests`] + : []), + ] + + const csp = cspDirectives.join('; ') + ';' + + // Replace newline characters and spaces + return csp.replace(/\s{2,}/g, ' ').trim() +} diff --git a/apps/studio/data/database-indexes/index-delete-mutation.ts b/apps/studio/data/database-indexes/index-delete-mutation.ts index 13bd3e835daf4..47dea94fe6d04 100644 --- a/apps/studio/data/database-indexes/index-delete-mutation.ts +++ b/apps/studio/data/database-indexes/index-delete-mutation.ts @@ -9,14 +9,16 @@ export type DatabaseIndexDeleteVariables = { projectRef: string connectionString?: string | null name: string + schema: string } export async function deleteDatabaseIndex({ projectRef, connectionString, name, + schema, }: DatabaseIndexDeleteVariables) { - const sql = `drop index if exists "${name}"` + const sql = `drop index if exists "${schema}"."${name}"` const { result } = await executeSql({ projectRef, diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts index a413d7ee72684..003d4bab529ea 100644 --- a/apps/studio/hooks/useChartData.ts +++ b/apps/studio/hooks/useChartData.ts @@ -16,7 +16,7 @@ import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' import { useAuthLogsReport } from 'data/reports/auth-report-query' import type { ChartData } from 'components/ui/Charts/Charts.types' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { useAttributeQueries } from 'components/ui/Charts/ComposedChartHandler' +import { useAttributeQueries } from 'components/ui/Charts/LogChartHandler' export const useChartData = ({ attributes, diff --git a/apps/studio/lib/profile.tsx b/apps/studio/lib/profile.tsx index 161cb88549f1f..dd6edd2f722a8 100644 --- a/apps/studio/lib/profile.tsx +++ b/apps/studio/lib/profile.tsx @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/nextjs' -import { useIsLoggedIn } from 'common' +import { useIsLoggedIn, useUser } from 'common' import { useRouter } from 'next/router' import { PropsWithChildren, createContext, useContext, useMemo } from 'react' import { toast } from 'sonner' @@ -30,6 +30,7 @@ export const ProfileContext = createContext({ }) export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { + const user = useUser() const isLoggedIn = useIsLoggedIn() const router = useRouter() const signOut = useSignOut() @@ -38,6 +39,16 @@ export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { const { mutate: createProfile, isLoading: isCreatingProfile } = useProfileCreateMutation({ onSuccess: () => { sendEvent({ action: 'sign_up', properties: { category: 'conversion' } }) + + if (user) { + // Send an event to GTM, will do nothing if GTM is not enabled + const thisWindow = window as any + thisWindow.dataLayer = thisWindow.dataLayer || [] + thisWindow.dataLayer.push({ + event: 'sign_up', + email: user.email, + }) + } }, onError: (error) => { if (error.code === 409) { diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 9d03a64c76f29..3803368a57322 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -2,115 +2,11 @@ const { withSentryConfig } = require('@sentry/nextjs') const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }) +const { getCSP } = require('./csp') // Required for nextjs standalone build const path = require('path') -const API_URL = process.env.NEXT_PUBLIC_API_URL - ? new URL(process.env.NEXT_PUBLIC_API_URL).origin - : '' -const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : '' -const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL - ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin - : '' -const SUPABASE_PROJECTS_URL = 'https://*.supabase.co' -const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co' - -// construct the URL for the Websocket Local URLs -let SUPABASE_LOCAL_PROJECTS_URL_WS = '' -if (SUPABASE_URL) { - const url = new URL(SUPABASE_URL) - const wsUrl = `${url.hostname}:${url.port}` - SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}` -} - -// Needed to test docs search in local dev -const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL - ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin - : '' - -// Needed to test docs content API in local dev -const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL - ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin - : '' - -const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' -const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' -const SUPABASE_COM_URL = 'https://supabase.com' -const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com' -const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com' -const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com' -const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com' -const CONFIGCAT_URL = 'https://cdn-global.configcat.com' -const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) - ? 'https://configcat.supabase.green' - : 'https://configcat.supabase.com' -const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' -const STRIPE_JS_URL = 'https://js.stripe.com' -const STRIPE_NETWORK_URL = 'https://*.stripe.network' -const CLOUDFLARE_URL = 'https://www.cloudflare.com' -const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' -const VERCEL_URL = 'https://vercel.com' -const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' -const GITHUB_API_URL = 'https://api.github.com' -const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com' -const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com' -const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com' -const VERCEL_LIVE_URL = 'https://vercel.live' -const SENTRY_URL = - 'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io' -const SUPABASE_ASSETS_URL = - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? 'https://frontend-assets.supabase.green' - : 'https://frontend-assets.supabase.com' - -const USERCENTRICS_URLS = 'https://*.usercentrics.eu' -const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' - -// used by vercel live preview -const PUSHER_URL = 'https://*.pusher.com' -const PUSHER_URL_WS = 'wss://*.pusher.com' - -const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${CONFIGCAT_PROXY_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_URLS}` -const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL} ${SUPABASE_ASSETS_URL}` -const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}` -const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL} ${GOOGLE_USER_AVATAR_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_APP_URL}` -const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` -const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` - -const csp = [ - ...(process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || - process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' - ? [ - `default-src 'self' ${DEFAULT_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${SUPABASE_STAGING_PROJECTS_URL_WS} ${VERCEL_LIVE_URL} ${PUSHER_URL_WS} ${SUPABASE_DOCS_PROJECT_URL} ${SUPABASE_CONTENT_API_URL} ${SENTRY_URL};`, - `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`, - `frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`, - `img-src 'self' blob: data: ${IMG_SRC_URLS} ${SUPABASE_STAGING_PROJECTS_URL} ${VERCEL_URL};`, - `style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS} ${VERCEL_LIVE_URL};`, - `font-src 'self' ${FONT_SRC_URLS} ${VERCEL_LIVE_URL};`, - `worker-src 'self' blob: data:;`, - ] - : [ - `default-src 'self' ${DEFAULT_SRC_URLS} ${PUSHER_URL_WS} ${SENTRY_URL};`, - `script-src 'self' 'unsafe-eval' 'unsafe-inline' ${SCRIPT_SRC_URLS} ${VERCEL_LIVE_URL} ${PUSHER_URL};`, - `frame-src 'self' ${FRAME_SRC_URLS} ${VERCEL_LIVE_URL};`, - `img-src 'self' blob: data: ${IMG_SRC_URLS} ;`, - `style-src 'self' 'unsafe-inline' ${STYLE_SRC_URLS} ${VERCEL_LIVE_URL};`, - `font-src 'self' ${FONT_SRC_URLS} ${VERCEL_LIVE_URL};`, - `worker-src 'self' blob: data:;`, - ]), - `object-src 'none';`, - `base-uri 'self';`, - `form-action 'self';`, - `frame-ancestors 'none';`, - `block-all-mixed-content;`, - ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && - process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' - ? [`upgrade-insecure-requests;`] - : []), -].join(' ') - function getAssetPrefix() { // If not force enabled, but not production env, disable CDN if (process.env.FORCE_ASSET_CDN !== '1' && process.env.VERCEL_ENV !== 'production') { @@ -122,6 +18,11 @@ function getAssetPrefix() { return undefined } + const SUPABASE_ASSETS_URL = + process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' + ? 'https://frontend-assets.supabase.green' + : 'https://frontend-assets.supabase.com' + return `${SUPABASE_ASSETS_URL}/${process.env.SITE_NAME}/${process.env.VERCEL_GIT_COMMIT_SHA.substring(0, 12)}` } @@ -511,7 +412,8 @@ const nextConfig = { }, { key: 'Content-Security-Policy', - value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' ? csp : "frame-ancestors 'none';", + value: + process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' ? getCSP() : "frame-ancestors 'none';", }, { key: 'Referrer-Policy', diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index ebe3f4a9bc91e..bbf227f10cbf3 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -31,7 +31,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { FeatureFlagProvider, ThemeProvider, useThemeSandbox } from 'common' +import { FeatureFlagProvider, TelemetryTagManager, ThemeProvider, useThemeSandbox } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' import { RouteValidationWrapper } from 'components/interfaces/App' import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext' @@ -150,6 +150,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + ) } diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 3a1952fc28aad..7e88300548970 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -21,6 +21,7 @@ import ChartHandler from 'components/ui/Charts/ChartHandler' import Panel from 'components/ui/Panel' import ShimmerLine from 'components/ui/ShimmerLine' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' +import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' import { DateRangePicker } from 'components/ui/DateRangePicker' import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner' @@ -36,8 +37,8 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' -import ReportChart from 'components/interfaces/Reports/ReportChart' import type { NextPageWithLayout } from 'types' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -207,10 +208,15 @@ const DatabaseUsage = () => { return ( <> +
+
+ +
+
-
+
{
{dateRange && REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => ( - ))}
diff --git a/apps/ui-library/app/layout.tsx b/apps/ui-library/app/layout.tsx index 1400f1053893a..ca4c57c190428 100644 --- a/apps/ui-library/app/layout.tsx +++ b/apps/ui-library/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next' import '@/styles/globals.css' import { API_URL } from '@/lib/constants' -import { FeatureFlagProvider } from 'common' +import { FeatureFlagProvider, TelemetryTagManager } from 'common' import { genFaviconData } from 'common/MetaFavicons/app-router' import { Inter } from 'next/font/google' import { ThemeProvider } from './Providers' @@ -43,6 +43,7 @@ export default async function Layout({ children }: RootLayoutProps) { + + ) } diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index e0e00c0f511ed..02f6d214b1a73 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -3,11 +3,12 @@ import { components } from 'api-types' import { useRouter } from 'next/compat/router' import { usePathname } from 'next/navigation' +import Script from 'next/script' import { useCallback, useEffect, useRef } from 'react' import { useLatest } from 'react-use' import { useUser } from './auth' import { hasConsented } from './consent-state' -import { LOCAL_STORAGE_KEYS } from './constants' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' import { ensurePlatformSuffix, isBrowser } from './helpers' @@ -17,10 +18,28 @@ import { getSharedTelemetryData } from './telemetry-utils' const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS +// Reexports GoogleTagManager with the right API key set +export const TelemetryTagManager = () => { + const isGTMEnabled = Boolean(IS_PLATFORM && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID) + + if (!isGTMEnabled) { + return + } + + return ( +