From 01c178e159ee3872ef28f0d2a16ecc628d4788f9 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Tue, 10 Mar 2026 17:03:58 +0900 Subject: [PATCH 01/36] chore(studio): graduate homeNew experiment (#43437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `homeNew` PostHog experiment has concluded. This PR graduates it by making the new homepage (`ProjectHome`, formerly `HomeV2`) the permanent default for all users, and removes all dead code from the old experiment. ## Changes - Remove `homeNew` PostHog feature flag checks and `home_new` experiment exposure tracking from 3 files - Rename `HomeNew/` → `ProjectHome/` directory and `HomeV2` → `ProjectHome` export - Delete old `Home/Home.tsx` component (shared components like `ProjectList/` are kept — still used by org pages) - Delete `pages/project/[ref]/building.tsx` and add a server-side redirect from `/project/:ref/building` → `/project/:ref` to prevent 404s during rollout (old cached JS bundles may still route to `/building`) - Simplify `ContentWrapper` building-state logic in `ProjectLayout` — always redirect building projects to home, always suppress building interstitial on home page - Always route to `/project/{ref}` after project creation (remove `/building` path) - Update all Observability imports from `HomeNew` → `ProjectHome` ## Self-hosted behavior change Self-hosted Studio previously showed the old `Home` component (client libraries + example projects) since PostHog flags don't load. This PR changes self-hosted to show `ProjectHome` (TopSection with service status + instance diagram, advisor, custom reports). All sections query backend APIs that exist on self-hosted. E2E tests pass against the self-hosted build. ## Testing - [x] `pnpm turbo run build --filter=studio` passes - [x] No remaining references to `homeNew`, `home_new`, or `HomeNew` in codebase - [x] No broken imports to deleted files - [x] Self-hosted E2E tests pass (145 passed, 1 flaky, 4 skipped) - [x] `/building` redirect added to both platform and self-hosted config blocks **Quick test:** 1. Navigate to any project homepage — should render the ProjectHome component 2. Create a new project — should redirect to `/project/{ref}` (not `/building`) 3. Visit a project in `COMING_UP` state on a non-home route — should redirect to home 4. Visit `/project/{ref}/building` directly — should 302 redirect to `/project/{ref}` ## Linear - fixes GROWTH-671 --- .../studio/.github/eslint-rule-baselines.json | 1 - .../components/interfaces/Home/Home.tsx | 286 ------------------ .../ObservabilityOverview.utils.test.ts | 2 +- .../ObservabilityOverview.utils.ts | 4 +- .../Observability/ServiceHealthCard.tsx | 2 +- .../Observability/ServiceHealthTable.tsx | 2 +- .../usePostgrestOverviewMetrics.ts | 3 +- .../Observability/useServiceHealthMetrics.ts | 4 +- .../useServiceHealthMetrics.utils.test.ts | 2 +- .../useServiceHealthMetrics.utils.ts | 4 +- .../ActivityStats.tsx | 0 .../AdvisorSection.tsx | 0 .../ChartDataTransform.utils.test.ts | 0 .../ChartDataTransform.utils.ts | 1 + .../CustomReportSection.tsx | 79 ++--- .../GettingStarted/GettingStarted.tsx | 0 .../GettingStarted/GettingStarted.types.ts | 0 .../GettingStarted/GettingStarted.utils.tsx | 0 .../GettingStarted/GettingStartedSection.tsx | 0 .../useGettingStartedProgress.ts | 0 .../{HomeNew => ProjectHome}/Home.tsx | 37 +-- .../ProjectConnectionHoverCard.tsx | 0 .../ProjectUsage.metrics.test.ts | 3 +- .../ProjectUsage.metrics.ts | 0 .../ProjectUsageSection.tsx | 0 .../ServiceStatus.tsx | 0 .../SnippetDropdown.tsx | 0 .../SortableSection.tsx | 0 .../{HomeNew => ProjectHome}/TopSection.tsx | 4 +- .../layouts/ProjectLayout/index.tsx | 12 +- apps/studio/next.config.js | 10 + apps/studio/pages/new/[slug].tsx | 3 +- apps/studio/pages/project/[ref]/building.tsx | 36 --- apps/studio/pages/project/[ref]/index.tsx | 19 +- packages/common/first-referrer-cookie.ts | 4 +- packages/common/telemetry.tsx | 6 +- 36 files changed, 73 insertions(+), 451 deletions(-) delete mode 100644 apps/studio/components/interfaces/Home/Home.tsx rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ActivityStats.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/AdvisorSection.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ChartDataTransform.utils.test.ts (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ChartDataTransform.utils.ts (99%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/CustomReportSection.tsx (89%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/GettingStarted/GettingStarted.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/GettingStarted/GettingStarted.types.ts (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/GettingStarted/GettingStarted.utils.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/GettingStarted/GettingStartedSection.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/GettingStarted/useGettingStartedProgress.ts (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/Home.tsx (86%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ProjectConnectionHoverCard.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ProjectUsage.metrics.test.ts (96%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ProjectUsage.metrics.ts (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ProjectUsageSection.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/ServiceStatus.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/SnippetDropdown.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/SortableSection.tsx (100%) rename apps/studio/components/interfaces/{HomeNew => ProjectHome}/TopSection.tsx (97%) delete mode 100644 apps/studio/pages/project/[ref]/building.tsx diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index d4354369771..26365fdd019 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -326,7 +326,6 @@ "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx": 1, "components/interfaces/Functions/EdgeFunctionsListItem.tsx": 2, "components/interfaces/Functions/FunctionsNav.tsx": 1, - "components/interfaces/Home/Home.tsx": 6, "components/interfaces/Home/ProjectList/ProjectList.tsx": 1, "components/interfaces/Home/ProjectUsage.tsx": 1, "components/interfaces/Home/ServiceStatus.tsx": 1, diff --git a/apps/studio/components/interfaces/Home/Home.tsx b/apps/studio/components/interfaces/Home/Home.tsx deleted file mode 100644 index 45d54e0fdae..00000000000 --- a/apps/studio/components/interfaces/Home/Home.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import dayjs from 'dayjs' -import Link from 'next/link' -import { useEffect, useRef } from 'react' - -import { useParams } from 'common' -import { AdvisorWidget } from 'components/interfaces/Home/AdvisorWidget' -import { ClientLibrary } from 'components/interfaces/Home/ClientLibrary' -import { ExampleProject } from 'components/interfaces/Home/ExampleProject' -import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' -import { NewProjectPanel } from 'components/interfaces/Home/NewProjectPanel/NewProjectPanel' -import { ProjectUsageSection } from 'components/interfaces/Home/ProjectUsageSection' -import { ServiceStatus } from 'components/interfaces/Home/ServiceStatus' -import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' -import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' -import { InlineLink } from 'components/ui/InlineLink' -import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner' -import { useBranchesQuery } from 'data/branches/branches-query' -import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' -import { useProjectDetailQuery } from 'data/projects/project-detail-query' -import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { useTablesQuery } from 'data/tables/tables-query' -import { useCustomContent } from 'hooks/custom-content/useCustomContent' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { DOCS_URL, IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import { useAppStateSnapshot } from 'state/app-state' -import { - Badge, - cn, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' -import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' - -export const Home = () => { - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const { data: parentProject } = useProjectDetailQuery({ ref: project?.parent_project_ref }) - const isOrioleDb = useIsOrioleDb() - const snap = useAppStateSnapshot() - const { ref, enableBranching } = useParams() - - const { projectHomepageExampleProjects, projectHomepageClientLibraries: clientLibraries } = - useCustomContent(['project_homepage:example_projects', 'project_homepage:client_libraries']) - - const { - projectHomepageShowInstanceSize: showInstanceSize, - projectHomepageShowExamples: showExamples, - } = useIsFeatureEnabled(['project_homepage:show_instance_size', 'project_homepage:show_examples']) - - const hasShownEnableBranchingModalRef = useRef(false) - const isPaused = project?.status === PROJECT_STATUS.INACTIVE - const isNewProject = dayjs(project?.inserted_at).isAfter(dayjs().subtract(2, 'day')) - - useEffect(() => { - if (enableBranching && !hasShownEnableBranchingModalRef.current) { - hasShownEnableBranchingModalRef.current = true - snap.setShowCreateBranchModal(true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enableBranching]) - - const { data: tablesData, isPending: isLoadingTables } = useTablesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: 'public', - }) - const { data: functionsData, isPending: isLoadingFunctions } = useEdgeFunctionsQuery({ - projectRef: project?.ref, - }) - const { data: replicasData, isPending: isLoadingReplicas } = useReadReplicasQuery({ - projectRef: project?.ref, - }) - - const { data: branches } = useBranchesQuery({ - projectRef: project?.parent_project_ref ?? project?.ref, - }) - - const mainBranch = branches?.find((branch) => branch.is_default) - const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref) - const isMainBranch = currentBranch?.name === mainBranch?.name - let projectName = 'Welcome to your project' - - if (currentBranch && !isMainBranch) { - projectName = currentBranch?.name - } else if (project?.name) { - projectName = project?.name - } - - const tablesCount = Math.max(0, tablesData?.length ?? 0) - const functionsCount = Math.max(0, functionsData?.length ?? 0) - // [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally - const replicasCount = Math.max(0, (replicasData?.length ?? 1) - 1) - - if (isPaused) { - return ( -
- -
- ) - } - - return ( -
-
-
-
-
-
- {!isMainBranch && ( - - {parentProject?.name} - - )} -

{projectName}

-
-
- {isOrioleDb && ( - - - OrioleDB - - - This project is using Postgres with OrioleDB which is currently in preview and - not suitable for production workloads. View our{' '} - - documentation - {' '} - for all limitations. - - - )} - {showInstanceSize && ( - - )} -
-
-
- {project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( -
-
- - Tables - - - {isLoadingTables ? ( - - ) : ( -

{tablesCount}

- )} -
- -
- - Functions - - {isLoadingFunctions ? ( - - ) : ( -

{functionsCount}

- )} -
- - {IS_PLATFORM && ( -
- - Replicas - - {isLoadingReplicas ? ( - - ) : ( -

{replicasCount}

- )} -
- )} -
- )} - {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( -
- -
- )} -
-
- -
-
- - <> -
-
- {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && ( - <>{isNewProject ? : } - )} - {!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && } -
-
- -
-
- {project?.status !== PROJECT_STATUS.INACTIVE && ( - <> -
-

Client libraries

-
- {clientLibraries!.map((library) => ( - // [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. - - ))} -
-
- {showExamples && ( -
-

Example projects

- {!!projectHomepageExampleProjects ? ( -
- {/* [Alaister]: Looks like the useCustomContent has wonky types. I'll look at a fix later. */} - {(projectHomepageExampleProjects as any) - .sort((a: any, b: any) => a.title.localeCompare(b.title)) - .map((project: any) => ( - - ))} -
- ) : ( -
- - - App Frameworks - - Mobile Frameworks - - - -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'app') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
- -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
-
-
- )} -
- )} - - )} -
-
- -
- ) -} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts index c6b45882e84..4a0e63b1827 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { calculateErrorRate, calculateSuccessRate, diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts index fe929555f5c..3aaa3c386ba 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts @@ -3,8 +3,8 @@ import { sumErrors, sumTotal, sumWarnings, -} from '../HomeNew/ProjectUsage.metrics' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +} from '../ProjectHome/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { useServiceHealthMetrics } from './useServiceHealthMetrics' export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx index 308396df92e..d8e6cd6624e 100644 --- a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx +++ b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, cn, Loading } from 'ui' import { LogsBarChart } from 'ui-patterns/LogsBarChart' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils' const colorClassMap: Record = { diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx index ee2754c9ed2..0a13b6feaed 100644 --- a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -4,7 +4,7 @@ import { Badge, Card, CardContent, Loading, Tooltip, TooltipContent, TooltipTrig import { LogsBarChart } from 'ui-patterns/LogsBarChart' import { ButtonTooltip } from '../../ui/ButtonTooltip' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { getHealthStatus, type ServiceKey } from './ObservabilityOverview.utils' type ServiceConfig = { diff --git a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts index 4180fb3b3d5..4c20f915c21 100644 --- a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts +++ b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts @@ -1,7 +1,8 @@ import { useQuery } from '@tanstack/react-query' import { get } from 'data/fetchers' + +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { generateRegexpWhere } from '../Reports/Reports.constants' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' type PostgrestMetricsVariables = { projectRef: string diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts index cb9e65c7833..73cb8c5f71c 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts @@ -4,15 +4,15 @@ import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted import useTimeseriesUnixToIso from 'hooks/analytics/useTimeseriesUnixToIso' import { useMemo } from 'react' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { LogsTableName } from '../Settings/Logs/Logs.constants' import { genChartQuery } from '../Settings/Logs/Logs.utils' import { - type RawChartData, calculateAggregatedMetrics, calculateDateRange, calculateHealthMetrics, transformToBarChartData, + type RawChartData, } from './useServiceHealthMetrics.utils' export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts index 5f71475be39..5faf3138bad 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { calculateAggregatedMetrics, calculateDateRange, diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts index cb3d7c8da0b..dd0cdbf7339 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts @@ -1,12 +1,12 @@ import dayjs from 'dayjs' -import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../ProjectHome/ProjectUsage.metrics' import { computeSuccessAndNonSuccessRates, sumErrors, sumTotal, sumWarnings, -} from '../HomeNew/ProjectUsage.metrics' +} from '../ProjectHome/ProjectUsage.metrics' /** * Calculates the date range for fetching service health metrics diff --git a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ActivityStats.tsx rename to apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx rename to apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx diff --git a/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts b/apps/studio/components/interfaces/ProjectHome/ChartDataTransform.utils.test.ts similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts rename to apps/studio/components/interfaces/ProjectHome/ChartDataTransform.utils.test.ts diff --git a/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts b/apps/studio/components/interfaces/ProjectHome/ChartDataTransform.utils.ts similarity index 99% rename from apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts rename to apps/studio/components/interfaces/ProjectHome/ChartDataTransform.utils.ts index b2884fb9355..c732b0d656b 100644 --- a/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts +++ b/apps/studio/components/interfaces/ProjectHome/ChartDataTransform.utils.ts @@ -1,4 +1,5 @@ import dayjs from 'dayjs' + import type { LogsBarChartDatum } from './ProjectUsage.metrics' /** diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx similarity index 89% rename from apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx rename to apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx index 75aa631a02f..540250376f2 100644 --- a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/CustomReportSection.tsx @@ -1,21 +1,16 @@ import { + closestCenter, DndContext, DragEndEvent, PointerSensor, - closestCenter, useSensor, useSensors, } from '@dnd-kit/core' -import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable' +import { arrayMove, rectSortingStrategy, SortableContext, useSortable } from '@dnd-kit/sortable' import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' -import { Plus, RefreshCw } from 'lucide-react' -import type { CSSProperties, DragEvent, ReactNode } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' - +import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' -import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown' +import { SnippetDropdown } from 'components/interfaces/ProjectHome/SnippetDropdown' import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' @@ -29,17 +24,20 @@ import { UpsertContentPayload, useContentUpsertMutation, } from 'data/content/content-upsert-mutation' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import dayjs from 'dayjs' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { useTrack } from 'lib/telemetry/track' +import { Plus, RefreshCw } from 'lucide-react' +import type { CSSProperties, DragEvent, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { Dashboards } from 'types' import { Button } from 'ui' import { Row } from 'ui-patterns' -import { keepPreviousData } from '@tanstack/react-query' export function CustomReportSection() { const startDate = dayjs().subtract(7, 'day').toISOString() @@ -48,8 +46,7 @@ export function CustomReportSection() { const { ref } = useParams() const { profile } = useProfile() const state = useDatabaseSelectorStateSnapshot() - const { data: organization } = useSelectedOrganizationQuery() - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const { invalidateInfraMonitoringQuery } = useInvalidateAnalyticsQuery() const { data: project } = useSelectedProjectQuery() @@ -203,19 +200,7 @@ export function CustomReportSection() { }, }) - if (ref && organization?.slug) { - sendEvent({ - action: 'home_custom_report_block_added', - properties: { - block_id: snippet.id, - position: 0, - }, - groups: { - project: ref, - organization: organization.slug, - }, - }) - } + track('home_custom_report_block_added', { block_id: snippet.id, position: 0 }) return } const current = [...editableReport.layout] @@ -225,19 +210,10 @@ export function CustomReportSection() { setEditableReport(updated) persistReport(updated) - if (ref && organization?.slug) { - sendEvent({ - action: 'home_custom_report_block_added', - properties: { - block_id: snippet.id, - position: current.length - 1, - }, - groups: { - project: ref, - organization: organization.slug, - }, - }) - } + track('home_custom_report_block_added', { + block_id: snippet.id, + position: current.length - 1, + }) }, [ editableReport, @@ -245,8 +221,7 @@ export function CustomReportSection() { ref, profile, upsertContent, - organization, - sendEvent, + track, findNextPlacement, createSnippetChartBlock, persistReport, @@ -265,17 +240,8 @@ export function CustomReportSection() { setEditableReport(updated) persistReport(updated) - if (ref && organization?.slug && removedChart) { - sendEvent({ - action: 'home_custom_report_block_removed', - properties: { - block_id: String(removedChart.id), - }, - groups: { - project: ref, - organization: organization.slug, - }, - }) + if (removedChart) { + track('home_custom_report_block_removed', { block_id: String(removedChart.id) }) } } @@ -325,12 +291,9 @@ export function CustomReportSection() { // Handle success optimistically toast.success(`Successfully created new query: ${label}`, { id: toastId }) addSnippetToReport({ id: payload.id, name: label }) - sendEvent({ - action: 'custom_report_assistant_sql_block_added', - groups: { project: ref, organization: organization?.slug ?? 'Unknown' }, - }) + track('custom_report_assistant_sql_block_added') }, - [ref, profile, project, upsertContent, addSnippetToReport, sendEvent, organization] + [ref, profile, project, upsertContent, addSnippetToReport, track] ) const handleDragOver = (e: DragEvent) => { diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.tsx b/apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.tsx rename to apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.tsx diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.types.ts b/apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.types.ts similarity index 100% rename from apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.types.ts rename to apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.types.ts diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.utils.tsx b/apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.utils.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStarted.utils.tsx rename to apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStarted.utils.tsx diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx b/apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStartedSection.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/GettingStarted/GettingStartedSection.tsx rename to apps/studio/components/interfaces/ProjectHome/GettingStarted/GettingStartedSection.tsx diff --git a/apps/studio/components/interfaces/HomeNew/GettingStarted/useGettingStartedProgress.ts b/apps/studio/components/interfaces/ProjectHome/GettingStarted/useGettingStartedProgress.ts similarity index 100% rename from apps/studio/components/interfaces/HomeNew/GettingStarted/useGettingStartedProgress.ts rename to apps/studio/components/interfaces/ProjectHome/GettingStarted/useGettingStartedProgress.ts diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/ProjectHome/Home.tsx similarity index 86% rename from apps/studio/components/interfaces/HomeNew/Home.tsx rename to apps/studio/components/interfaces/ProjectHome/Home.tsx index 2a017ac00d2..ebe2082109f 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/ProjectHome/Home.tsx @@ -1,32 +1,30 @@ import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' -import dayjs from 'dayjs' -import { useEffect, useRef } from 'react' - import { IS_PLATFORM, useFlag, useParams } from 'common' import { ProjectUsageSection as ProjectUsageSectionV1 } from 'components/interfaces/Home/ProjectUsageSection' -import { SortableSection } from 'components/interfaces/HomeNew/SortableSection' -import { TopSection } from 'components/interfaces/HomeNew/TopSection' +import { SortableSection } from 'components/interfaces/ProjectHome/SortableSection' +import { TopSection } from 'components/interfaces/ProjectHome/TopSection' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import dayjs from 'dayjs' import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' +import { useTrack } from 'lib/telemetry/track' +import { useEffect, useRef } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { cn } from 'ui' + import { AdvisorSection } from './AdvisorSection' import { CustomReportSection } from './CustomReportSection' import { type GettingStartedState } from './GettingStarted/GettingStarted.types' import { GettingStartedSection } from './GettingStarted/GettingStartedSection' import { ProjectUsageSection as ProjectUsageSectionV2 } from './ProjectUsageSection' -export const HomeV2 = () => { +export const ProjectHome = () => { const { enableBranching } = useParams() const snap = useAppStateSnapshot() const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const { mutate: sendEvent } = useSendEventMutation() + const track = useTrack() const showHomepageUsageV2 = useFlag('newHomepageUsageV2') @@ -58,20 +56,11 @@ export const HomeV2 = () => { const newIndex = items.indexOf(String(over.id)) if (oldIndex === -1 || newIndex === -1) return items - if (project?.ref && organization?.slug) { - sendEvent({ - action: 'home_section_rows_moved', - properties: { - section_moved: String(active.id), - old_position: oldIndex, - new_position: newIndex, - }, - groups: { - project: project.ref, - organization: organization.slug, - }, - }) - } + track('home_section_rows_moved', { + section_moved: String(active.id), + old_position: oldIndex, + new_position: newIndex, + }) return arrayMove(items, oldIndex, newIndex) }) diff --git a/apps/studio/components/interfaces/HomeNew/ProjectConnectionHoverCard.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectConnectionHoverCard.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ProjectConnectionHoverCard.tsx rename to apps/studio/components/interfaces/ProjectHome/ProjectConnectionHoverCard.tsx diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts b/apps/studio/components/interfaces/ProjectHome/ProjectUsage.metrics.test.ts similarity index 96% rename from apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts rename to apps/studio/components/interfaces/ProjectHome/ProjectUsage.metrics.test.ts index 33ac93f7d22..3b2479c1789 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts +++ b/apps/studio/components/interfaces/ProjectHome/ProjectUsage.metrics.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' + import { computeSuccessAndNonSuccessRates, sumErrors, diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts b/apps/studio/components/interfaces/ProjectHome/ProjectUsage.metrics.ts similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts rename to apps/studio/components/interfaces/ProjectHome/ProjectUsage.metrics.ts diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx rename to apps/studio/components/interfaces/ProjectHome/ProjectUsageSection.tsx diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx rename to apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx diff --git a/apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx b/apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/SnippetDropdown.tsx rename to apps/studio/components/interfaces/ProjectHome/SnippetDropdown.tsx diff --git a/apps/studio/components/interfaces/HomeNew/SortableSection.tsx b/apps/studio/components/interfaces/ProjectHome/SortableSection.tsx similarity index 100% rename from apps/studio/components/interfaces/HomeNew/SortableSection.tsx rename to apps/studio/components/interfaces/ProjectHome/SortableSection.tsx diff --git a/apps/studio/components/interfaces/HomeNew/TopSection.tsx b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx similarity index 97% rename from apps/studio/components/interfaces/HomeNew/TopSection.tsx rename to apps/studio/components/interfaces/ProjectHome/TopSection.tsx index 27f1c511934..659de090a63 100644 --- a/apps/studio/components/interfaces/HomeNew/TopSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx @@ -1,5 +1,5 @@ -import { ActivityStats } from 'components/interfaces/HomeNew/ActivityStats' -import { ProjectConnectionHoverCard } from 'components/interfaces/HomeNew/ProjectConnectionHoverCard' +import { ActivityStats } from 'components/interfaces/ProjectHome/ActivityStats' +import { ProjectConnectionHoverCard } from 'components/interfaces/ProjectHome/ProjectConnectionHoverCard' import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' import { InlineLink } from 'components/ui/InlineLink' diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 8cb791c9d6d..4cdec8313e2 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -35,7 +35,6 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { withAuth } from '@/hooks/misc/withAuth' -import { usePHFlag } from '@/hooks/ui/useFlag' import { PROJECT_STATUS } from '@/lib/constants' import { buildStudioPageTitle } from '@/lib/page-title' import { useAppStateSnapshot } from '@/state/app-state' @@ -277,8 +276,6 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp const { ref } = useParams() const state = useDatabaseSelectorStateSnapshot() const { data: selectedProject } = useSelectedProjectQuery() - const isHomeNew = usePHFlag('homeNew') === 'new-home' - const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups') const isHomePage = router.pathname === '/project/[ref]' @@ -298,13 +295,10 @@ const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapp const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE' - // handle redirect to home for building state - const shouldRedirectToHomeForBuilding = - isProjectBuilding && requiresDbConnection && isHomeNew && !isHomePage + const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage - // We won't be showing the building state with the new home page - const shouldShowBuildingState = - isProjectBuilding && requiresDbConnection && !(isHomeNew && isHomePage) + // Don't show building state on the home page — it handles building state inline + const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage useEffect(() => { if (shouldRedirectToHomeForBuilding && ref) { diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index fe823976497..a81b145ff77 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -88,6 +88,11 @@ const nextConfig = { destination: '/sign-in', permanent: false, }, + { + source: '/project/:ref/building', + destination: '/project/:ref', + permanent: false, + }, ] : [ { @@ -120,6 +125,11 @@ const nextConfig = { destination: '/project/default', permanent: false, }, + { + source: '/project/:ref/building', + destination: '/project/:ref', + permanent: false, + }, ]), { source: '/project/:ref/auth', diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index 13a3cac2222..7a7322ea882 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -85,7 +85,6 @@ const Wizard: NextPageWithLayout = () => { const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') const showPostgresVersionSelector = useFlag('showPostgresVersionSelector') const cloudProviderEnabled = useFlag('enableFlyCloudProvider') - const isHomeNew = usePHFlag('homeNew') === 'new-home' const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '') @@ -253,7 +252,7 @@ const Wizard: NextPageWithLayout = () => { organization: res.organization_slug, } ) - router.push(isHomeNew ? `/project/${res.ref}` : `/project/${res.ref}/building`) + router.push(`/project/${res.ref}`) }, }) diff --git a/apps/studio/pages/project/[ref]/building.tsx b/apps/studio/pages/project/[ref]/building.tsx deleted file mode 100644 index 73d694b1bbc..00000000000 --- a/apps/studio/pages/project/[ref]/building.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import DefaultLayout from 'components/layouts/DefaultLayout' -import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout' -import { useRouter } from 'next/router' -import { useEffect } from 'react' -import type { NextPageWithLayout } from 'types' - -const ProjectBuildingPage: NextPageWithLayout = () => { - return -} - -ProjectBuildingPage.getLayout = (page) => ( - - {page} - -) - -export default ProjectBuildingPage - -const RedirectToDashboard = () => { - const router = useRouter() - const { ref } = router.query - - useEffect(() => { - // Use redirect to reload store data properly after project has been - // been created or unpaused, this is necessarily especially for unpausing - // so that the dashboard fetches the updated connection strings - // window.location.replace(`/project/${ref}`) - - // TODO: for experiment only - // Cos we already pulling new connectionString after project building/restoring - // just use normal router.push is enough - router.push(`/project/${ref}`) - }, []) - - return null -} diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index a81026a90f4..fdc368a43c6 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -1,25 +1,10 @@ -import { IS_PLATFORM } from 'common' -import { Home } from 'components/interfaces/Home/Home' -import { HomeV2 } from 'components/interfaces/HomeNew/Home' +import { ProjectHome } from 'components/interfaces/ProjectHome/Home' import DefaultLayout from 'components/layouts/DefaultLayout' import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout' -import { useTrackExperimentExposure } from 'hooks/misc/useTrackExperimentExposure' -import { usePHFlag } from 'hooks/ui/useFlag' import type { NextPageWithLayout } from 'types' const HomePage: NextPageWithLayout = () => { - const homeNewVariant = usePHFlag('homeNew') - const isHomeNew = homeNewVariant === 'new-home' - - useTrackExperimentExposure( - 'home_new', - IS_PLATFORM && typeof homeNewVariant !== 'boolean' ? homeNewVariant : undefined - ) - - if (isHomeNew) { - return - } - return + return } HomePage.getLayout = (page) => ( diff --git a/packages/common/first-referrer-cookie.ts b/packages/common/first-referrer-cookie.ts index c7a7cb50705..4f9a8436fb6 100644 --- a/packages/common/first-referrer-cookie.ts +++ b/packages/common/first-referrer-cookie.ts @@ -286,9 +286,7 @@ export interface MwDiagData { export function parseMwDiagCookie(cookieHeader: string): MwDiagData | null { try { const cookies = cookieHeader.split(';') - const match = cookies - .map((c) => c.trim()) - .find((c) => c.startsWith(`${MW_DIAG_COOKIE_NAME}=`)) + const match = cookies.map((c) => c.trim()).find((c) => c.startsWith(`${MW_DIAG_COOKIE_NAME}=`)) if (!match) return null diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index b9c2045025e..0ea50a3931e 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -13,7 +13,11 @@ import { IS_PLATFORM, IS_PROD, LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' import type { FirstReferrerData, MwDiagData } from './first-referrer-cookie' -import { isExternalReferrer, parseFirstReferrerCookie, parseMwDiagCookie } from './first-referrer-cookie' +import { + isExternalReferrer, + parseFirstReferrerCookie, + parseMwDiagCookie, +} from './first-referrer-cookie' import { ensurePlatformSuffix, isBrowser } from './helpers' import { useParams, useTelemetryCookie } from './hooks' import { posthogClient, type ClientTelemetryEvent } from './posthog-client' From 8cfdc8576eccd48ebecef089c0b23a85ffddda64 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Tue, 10 Mar 2026 13:10:35 +0100 Subject: [PATCH 02/36] docs: Expo User Management auth changes (#43568) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/ --- .../tutorials/with-expo-react-native.mdx | 504 +- .../expo-user-management/App.tsx | 24 +- .../components/Account.tsx | 15 +- .../components/Avatar.tsx | 64 +- .../expo-user-management/lib/supabase.ts | 6 +- .../expo-user-management/package-lock.json | 14328 ++++------------ .../expo-user-management/package.json | 36 +- 7 files changed, 3502 insertions(+), 11475 deletions(-) diff --git a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx index 9d2cd33ca85..08abb969fa0 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -14,15 +14,18 @@ If you get stuck while working through this guide, refer to the [full example on -<$Partial path="project_setup.mdx" variables={{ "framework": "exporeactnative", "tab": "mobiles" }} /> +<$Partial +path="project_setup.mdx" +variables={{ "framework": "exporeactnative", "tab": "mobiles" }} +/> ## Building the app -Let's start building the React Native app from scratch. +Start by building the React Native app from scratch. ### Initialize a React Native app -We can use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize +Use [`expo`](https://docs.expo.dev/get-started/create-a-new-app/) to initialize an app called `expo-user-management`: ```bash @@ -31,14 +34,14 @@ npx create-expo-app -t expo-template-blank-typescript expo-user-management cd expo-user-management ``` -Then let's install the additional dependencies: [supabase-js](https://github.com/supabase/supabase-js) +Then install the additional dependencies: ```bash npx expo install @supabase/supabase-js @rneui/themed expo-sqlite ``` -Now let's create a helper file to initialize the Supabase client. -We need the API URL and the key that you copied [earlier](#get-api-details). +Now create a helper file to initialize the Supabase client using the API URL and the key that you copied [earlier](#get-api-details). + These variables are safe to expose in your Expo app since Supabase has [Row Level Security](/docs/guides/database/postgres/row-level-security) enabled on your Database. @@ -51,26 +54,11 @@ These variables are safe to expose in your Expo app since Supabase has > - <$CodeTabs> - - ```ts name=lib/supabase.ts - import 'expo-sqlite/localStorage/install'; - import { createClient } from '@supabase/supabase-js' - - const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL - const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY - - export const supabase = createClient(supabaseUrl, supabasePublishableKey, { - auth: { - storage: localStorage, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - }, - }) - ``` - - + <$CodeSample + path="/user-management/expo-user-management/lib/supabase.ts" + lines={[[1, -1]]} + meta="name=lib/supabase.ts" + /> @@ -78,6 +66,7 @@ These variables are safe to expose in your Expo app since Supabase has If you wish to encrypt the user's session information, you can use `aes-js` and store the encryption key in [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore). The [`aes-js` library](https://github.com/ricmoo/aes-js) is a reputable JavaScript-only implementation of the AES encryption algorithm in CTR mode. A new 256-bit encryption key is generated using the `react-native-get-random-values` library. This key is stored inside Expo's SecureStore, while the value is encrypted and placed inside AsyncStorage. Make sure that: + - You keep the `expo-secure-storage`, `aes-js` and `react-native-get-random-values` libraries up-to-date. - Choose the correct [`SecureStoreOptions`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestoreoptions) for your app's needs. E.g. [`SecureStore.WHEN_UNLOCKED`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestorewhen_unlocked) regulates when the data can be accessed. - Carefully consider optimizations or other modifications to the above example, as those can lead to introducing subtle security vulnerabilities. @@ -169,110 +158,14 @@ These variables are safe to expose in your Expo app since Supabase has ### Set up a login component -Let's set up a React Native component to manage logins and sign ups. -Users would be able to sign in with their email and password. +Set up a React Native component to manage logins and sign ups. +Users should be able to sign in with their email and password. -<$CodeTabs> - -```tsx name=components/Auth.tsx -import React, { useState } from 'react' -import { Alert, StyleSheet, View, AppState } from 'react-native' -import { supabase } from '../lib/supabase' -import { Button, Input } from '@rneui/themed' - -// Tells Supabase Auth to continuously refresh the session automatically if -// the app is in the foreground. When this is added, you will continue to receive -// `onAuthStateChange` events with the `TOKEN_REFRESHED` or `SIGNED_OUT` event -// if the user's session is terminated. This should only be registered once. -AppState.addEventListener('change', (state) => { - if (state === 'active') { - supabase.auth.startAutoRefresh() - } else { - supabase.auth.stopAutoRefresh() - } -}) - -export default function Auth() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [loading, setLoading] = useState(false) - - async function signInWithEmail() { - setLoading(true) - const { error } = await supabase.auth.signInWithPassword({ - email: email, - password: password, - }) - - if (error) Alert.alert(error.message) - setLoading(false) - } - - async function signUpWithEmail() { - setLoading(true) - const { - data: { session }, - error, - } = await supabase.auth.signUp({ - email: email, - password: password, - }) - - if (error) Alert.alert(error.message) - if (!session) Alert.alert('Please check your inbox for email verification!') - setLoading(false) - } - - return ( - - - setEmail(text)} - value={email} - placeholder="email@address.com" - autoCapitalize={'none'} - /> - - - setPassword(text)} - value={password} - secureTextEntry={true} - placeholder="Password" - autoCapitalize={'none'} - /> - - - + )} + + + + ))} + +
+ +
+ + + ) +} + +export default NewComputePricingCalculator diff --git a/apps/www/components/Pricing/NewPricingComputeSection.tsx b/apps/www/components/Pricing/NewPricingComputeSection.tsx new file mode 100644 index 00000000000..c180ac11a24 --- /dev/null +++ b/apps/www/components/Pricing/NewPricingComputeSection.tsx @@ -0,0 +1,205 @@ +import { ChevronDownIcon } from '@heroicons/react/outline' +import Link from 'next/link' +import React, { useEffect, useRef, useState } from 'react' +import { useWindowSize } from 'react-use' +import { plans as allPlans } from 'shared-data/plans' +import { Button, cn } from 'ui' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { ToggleGroup, ToggleGroupItem } from 'ui/src/components/shadcn/ui/toggle-group' + +import Panel from '../Panel' +import ComputePricingTable from './ComputePricingTable' +import NewComputePricingCalculator from './NewComputePricingCalculator' +import PricingComputeAnimation from './PricingComputeAnimation' + +const plans = allPlans + .filter((plan) => plan.planId === 'pro' || plan.planId === 'team') + .map((plan) => ({ + name: plan.name, + price: plan.priceMonthly as number, + })) + +const NewPricingComputeSection = () => { + const ref = useRef(null) + const [showTable, setShowTable] = useState(false) + const { width } = useWindowSize() + const [height, setHeight] = useState(ref?.current?.clientHeight) + const [activePlan, setActivePlan] = useState(plans[0]) + + useEffect(() => { + setHeight(ref?.current?.clientHeight) + }, [width]) + + return ( + +
+
+
+

1. Choose your plan

+
+ { + const selectedPlan = plans.find((p) => p.name === value) + if (selectedPlan) setActivePlan(selectedPlan) + }} + className="grid grid-cols-2 gap-2 w-full bg-surface-200 rounded-md" + > + {plans.map((plan) => ( + + {plan.name} + + ))} + + +
+

{activePlan.name}

+
+

+ ${activePlan.price} + /month +

+
+
+ {activePlan.name === 'Pro' && ( + <> +

+ Everything in the Free Plan, plus: +

+

100K monthly active users

+

8 GB disk size per project

+

250 GB bandwidth

+

Daily backups (7 day retention)

+

Email support

+ + )} + {activePlan.name === 'Team' && ( + <> +

+ Everything in the Pro Plan, plus: +

+

SOC2

+

Project-scoped and read-only access

+

HIPAA available as paid add-on

+

SSO for Supabase Dashboard

+

Priority email support & SLAs

+

Daily backups (14 day retention)

+ + )} +

+ Paid plans include $10/mo in compute credits, enough to cover one Micro instance. +

+
+
+
+
+

+ 2. Configure compute for your projects +

+

+ Add projects and adjust compute to estimate your monthly cost. +

+ + +
+
+
+
+
+
+

+ + Starts from $10/month + +

+

+ Scale compute up to +
64 cores and 256 GB RAM +

+
+ + +
+
+
+
+ + +
+
+

Choose the best compute size for you

+

+ Every project on the Supabase Platform comes with its own dedicated Postgres + instance. Select the compute size that fits your needs. +

+

+ Compute instances are billed hourly and you can scale up or down at any time. Paid + Plans come with $10/month in compute credits to cover + one Micro instance or offset the cost of any other instance. +

+

+ Read more on{' '} + + usage-based billing for compute + {' '} + or{' '} + + Compute Add-ons + + . +

+
+
+ +
+
+
+
+ +
+ + ) +} + +export default NewPricingComputeSection diff --git a/apps/www/public/rss.xml b/apps/www/public/rss.xml index 5092c77217f..18f8f9cccd0 100644 --- a/apps/www/public/rss.xml +++ b/apps/www/public/rss.xml @@ -13,6 +13,13 @@ Supabase Pro users can now send their Supabase logs to their own logging backend, enabling them to debug in the same place as the rest of their stack. Thu, 05 Mar 2026 00:00:00 -0700 + + https://supabase.com/blog/supabase-storage-performance-security-reliability-updates + Supabase Storage: major performance, security, and reliability updates + https://supabase.com/blog/supabase-storage-performance-security-reliability-updates + Storage ships a rewritten object listing system, path traversal prevention, idempotent migrations, OpenTelemetry metrics, and fixes for the most common reliability issues. + Thu, 05 Mar 2026 00:00:00 -0700 + https://supabase.com/blog/supabase-incident-on-february-12-2026 Supabase incident on February 12, 2026 diff --git a/packages/common/posthog-client.ts b/packages/common/posthog-client.ts index 24284840a02..acff08b0b9e 100644 --- a/packages/common/posthog-client.ts +++ b/packages/common/posthog-client.ts @@ -33,6 +33,7 @@ class PostHogClient { private config: PostHogClientConfig private readonly maxPendingEvents = MAX_PENDING_EVENTS private devListeners: Set = new Set() + private pendingFeatureFlagCallbacks: Set<() => void> = new Set() constructor(config: PostHogClientConfig = {}) { const apiHost = @@ -107,6 +108,10 @@ class PostHogClient { this.initStarted = true posthog.init(this.config.apiKey, config) + + // Register any feature flag callbacks that were queued before init + this.pendingFeatureFlagCallbacks.forEach((cb) => posthog.onFeatureFlags(cb)) + this.pendingFeatureFlagCallbacks.clear() } capturePageView(properties: Record, hasConsent: boolean = true) { @@ -247,6 +252,52 @@ class PostHogClient { return undefined } + /** + * Returns a PostHog feature flag value directly from the client-side SDK. + * Use this for www/docs pages where server-side evaluation lacks full person context. + * In local dev, DevToolbar overrides (x-ph-flag-overrides cookie) take priority. + */ + getFeatureFlag(key: string): string | boolean | undefined { + if (typeof document === 'undefined') return undefined + + if (process.env.NODE_ENV === 'development') { + try { + const cookieEntry = document.cookie + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith('x-ph-flag-overrides=')) + if (cookieEntry) { + const overrides = JSON.parse( + decodeURIComponent(cookieEntry.substring('x-ph-flag-overrides='.length)) + ) + if (key in overrides) return overrides[key] + } + } catch {} + } + + if (!this.initialized) return undefined + + try { + return posthog.getFeatureFlag(key) + } catch { + return undefined + } + } + + /** + * Subscribe to PostHog feature flag loads/reloads. + * Returns an unsubscribe function. + */ + onFeatureFlags(callback: () => void): () => void { + if (!this.initStarted) { + // Queue until init() is called + this.pendingFeatureFlagCallbacks.add(callback) + return () => this.pendingFeatureFlagCallbacks.delete(callback) + } + if (typeof posthog.onFeatureFlags !== 'function') return () => {} + return posthog.onFeatureFlags(callback) ?? (() => {}) + } + /** * Returns PostHog's session_id for the current session. * Returns undefined until PostHog's `loaded` callback fires. diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 4eb26581df4..fffc6a4eede 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2675,6 +2675,27 @@ export interface RlsEventTriggerBannerCreateButtonClickedEvent { groups: TelemetryGroups } +/** + * User was exposed to the pricing calculator experiment on the /pricing page. + * + * @group Events + * @source www + * @page /pricing + */ +export interface PricingCalculatorExperimentExposedEvent { + action: 'pricing_calculator_experiment_exposed' + properties: { + /** + * Experiment identifier for tracking + */ + experiment_id: 'pricingCalculatorExperiment' + /** + * Experiment variant: 'control' (existing compute section) or 'test' (new compute section) + */ + variant: 'control' | 'test' + } +} + /** * User clicked the Run button in the log explorer. * @@ -2842,3 +2863,4 @@ export type TelemetryEvent = | IntegrationInstallFailedEvent | IntegrationUninstallCompletedEvent | RlsEventTriggerBannerCreateButtonClickedEvent + | PricingCalculatorExperimentExposedEvent diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index b32860d63c5..82a20684e1d 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -50,7 +50,7 @@ export const plans: PricingInformation[] = [ costUnit: '/ month', href: 'https://supabase.com/dashboard/new?plan=pro', priceLabel: 'From', - warning: '$10 in compute credits included', + warning: 'Includes one project running on Micro compute.', priceMonthly: 25, description: 'For production applications with the power to scale.', features: [ @@ -75,7 +75,7 @@ export const plans: PricingInformation[] = [ costUnit: '/ month', href: 'https://supabase.com/dashboard/new?plan=team', priceLabel: 'From', - warning: '$10 in compute credits included', + warning: 'Includes one project running on Micro compute.', priceMonthly: 599, description: 'Add features such as SSO, control over backups, and industry certifications.', features: [ From 7cadc12136e82f50c0fbc36fe4fb3c90c01ed91e Mon Sep 17 00:00:00 2001 From: Illia Basalaiev <44750366+Ellba@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:47:11 +0100 Subject: [PATCH 05/36] =?UTF-8?q?chore:=20add=20copy=20button=20to=20proje?= =?UTF-8?q?ct=20reference=20custom=20domain=20flow,=20add=20n=E2=80=A6=20(?= =?UTF-8?q?#43585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …ote on subdomain ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature ## What is the current behavior? CleanShot 2026-03-10 at 09 59 59 ## What is the new behavior? - The endpoint value in the CNAME setup instructions can now be copied with a single click. - Also adds a note clarifying that some DNS providers expect only the subdomain label while others accept the full hostname. https://github.com/user-attachments/assets/8e80ad33-b100-4313-9375-7c43b43c6254 --- .../CustomDomainsConfigureHostname.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx index 5555efc79f6..6540d699299 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainsConfigureHostname.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import CopyButton from 'components/ui/CopyButton' import { DocsButton } from 'components/ui/DocsButton' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation' @@ -70,6 +71,7 @@ export const CustomDomainsConfigureHostname = () => { } const domain = form.watch('domain') + const trimmedDomain = domain.trim() const isSubmitting = isCheckingRecord || isCreating return ( @@ -112,12 +114,31 @@ export const CustomDomainsConfigureHostname = () => { {domain ? {domain} : 'your custom domain'}{' '} resolving to{' '} {endpoint ? ( - {endpoint} + + {endpoint} + + ) : ( "your project's API URL" )}{' '} with as low a TTL as possible. If you're using Cloudflare as your DNS provider, disable the proxy option. +
+ {trimmedDomain.includes('.') ? ( + <> + Some DNS providers expect only the subdomain label{' '} + {trimmedDomain.split('.')[0]}, while + others accept the full hostname{' '} + {trimmedDomain}. + + ) : ( + 'Some DNS providers expect only the subdomain label, while others accept the full hostname.' + )}

From 92bdbb2f57d674763242db4a9649ef44ead3309d Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Tue, 10 Mar 2026 14:47:26 +0100 Subject: [PATCH 06/36] chore: explicit permissions on actions (#43526) enforces minimal set of permissions, the same as other actions in the repo --- .github/workflows/docs-js-libs-update.yml | 9 ++++++--- .github/workflows/publish_image.yml | 3 +++ .github/workflows/trigger-nimbus-sync.yml | 3 +++ .github/workflows/update-js-libs.yml | 14 ++++++++------ .github/workflows/validate-pr.yml | 3 +++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs-js-libs-update.yml b/.github/workflows/docs-js-libs-update.yml index 398ebf0b5fb..9f94c4addbb 100644 --- a/.github/workflows/docs-js-libs-update.yml +++ b/.github/workflows/docs-js-libs-update.yml @@ -42,10 +42,13 @@ jobs: - name: Regenerate JS client libraries tsdoc files working-directory: apps/docs/spec + env: + SOURCE: ${{ github.event.inputs.source }} + VERSION: ${{ github.event.inputs.version }} run: | echo "Regenerating tsdoc files for JS client libraries..." - echo "Source: ${{ github.event.inputs.source }}" - echo "Version: ${{ github.event.inputs.version }}" + echo "Source: ${SOURCE}" + echo "Version: ${VERSION}" make - name: Generate new typespec snapshot @@ -68,7 +71,7 @@ jobs: commit-message: 'docs: update js sdk docs (${{ github.event.inputs.version }})' title: 'docs: update js sdk docs (${{ github.event.inputs.version }})' body: | - Updates JS sdk documentation following stable release. + Updates JS sdk documentation following stable release. Ran `make` in apps/docs/spec to regenerate tsdoc files. **Details:** diff --git a/.github/workflows/publish_image.yml b/.github/workflows/publish_image.yml index 9865b284630..10143775f32 100644 --- a/.github/workflows/publish_image.yml +++ b/.github/workflows/publish_image.yml @@ -6,6 +6,9 @@ on: - cron: '0 4 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: settings: runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/.github/workflows/trigger-nimbus-sync.yml b/.github/workflows/trigger-nimbus-sync.yml index 8e47cc31e5c..082e15905fe 100644 --- a/.github/workflows/trigger-nimbus-sync.yml +++ b/.github/workflows/trigger-nimbus-sync.yml @@ -5,6 +5,9 @@ on: branches: [master] workflow_dispatch: # Allow manual triggering +permissions: + contents: read + jobs: trigger-nimbus-sync: runs-on: ubuntu-latest diff --git a/.github/workflows/update-js-libs.yml b/.github/workflows/update-js-libs.yml index ef6a611e712..3fea336ff5c 100644 --- a/.github/workflows/update-js-libs.yml +++ b/.github/workflows/update-js-libs.yml @@ -38,32 +38,34 @@ jobs: cache: 'pnpm' - name: Update @supabase/*-js packages in pnpm-workspace.yaml + env: + VERSION: ${{ github.event.inputs.version }} run: | # Update @supabase/supabase-js - sed -i "s/'@supabase\/supabase-js': .*/'@supabase\/supabase-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + sed -i "s|'@supabase/supabase-js': .*|'@supabase/supabase-js': ${VERSION}|" pnpm-workspace.yaml # Update @supabase/auth-js - sed -i "s/'@supabase\/auth-js': .*/'@supabase\/auth-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + sed -i "s|'@supabase/auth-js': .*|'@supabase/auth-js': ${VERSION}|" pnpm-workspace.yaml # Update @supabase/realtime-js - sed -i "s/'@supabase\/realtime-js': .*/'@supabase\/realtime-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + sed -i "s|'@supabase/realtime-js': .*|'@supabase/realtime-js': ${VERSION}|" pnpm-workspace.yaml # Update @supabase/postgrest-js - sed -i "s/'@supabase\/postgrest-js': .*/'@supabase\/postgrest-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + sed -i "s|'@supabase/postgrest-js': .*|'@supabase/postgrest-js': ${VERSION}|" pnpm-workspace.yaml echo "Updated pnpm-workspace.yaml:" cat pnpm-workspace.yaml - name: Install dependencies run: pnpm install --no-frozen-lockfile - + - name: Generate token id: app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ secrets.GH_AUTOFIX_APP_ID }} private-key: ${{ secrets.GH_AUTOFIX_PRIVATE_KEY }} - + - name: Create pull request uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index ffb9c43ca95..af68e012b3d 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -5,6 +5,9 @@ on: pull_request: types: [opened, labeled, unlabeled, synchronize, ready_for_review] +permissions: + contents: read + jobs: validate-pr: runs-on: ubuntu-latest From 423ec30bec2662008c9a610fb81fef48b9c4618e Mon Sep 17 00:00:00 2001 From: Jaafoura <73950268+J43fura@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:53:16 +0100 Subject: [PATCH 07/36] docs: Fix docs typo in "schedule-functions" (#42499) Updated the secret creation command for Supabase Vault to use 'anon_key' instead of 'publishable_key'. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? docs https://supabase.com/docs/guides/functions/schedule-functions ## Summary by CodeRabbit * **Documentation** * Updated code examples in the schedule functions guide to reflect correct Supabase key naming conventions. Co-authored-by: Chris Chinchilla --- apps/docs/content/guides/functions/schedule-functions.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/functions/schedule-functions.mdx b/apps/docs/content/guides/functions/schedule-functions.mdx index bef637ca04e..254a37dad92 100644 --- a/apps/docs/content/guides/functions/schedule-functions.mdx +++ b/apps/docs/content/guides/functions/schedule-functions.mdx @@ -31,7 +31,7 @@ Store `project_url` and `anon_key` in Supabase Vault: ```sql select vault.create_secret('https://project-ref.supabase.co', 'project_url'); -select vault.create_secret('YOUR_SUPABASE_PUBLISHABLE_KEY', 'publishable_key'); +select vault.create_secret('YOUR_SUPABASE_ANON_KEY', 'anon_key'); ``` Make a POST request to a Supabase Edge Function every minute: From 87581e02cb85c67bba9f90650efa3e51d00100ee Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:10:47 +0100 Subject: [PATCH 08/36] fix: Prevent users from loosing changes when navigating away (#43577) ## Problem When editing email templates or edge functions, users may navigate away from the page without a warning indicating they may loose their changes. This is because we only handle the `beforeunload` event when we should also handle NextJS routing events. This is actually done for the observability reports. ## Solution Extract the logic from the observability reports into a reusable component and use it where needed ## How to test On staging, for each case: - Authentication email templates - Observability reports - Edge functions creation - Edge functions edition Test: - Modify the template/report/function - Navigate away using either the sidebar link, the browser back button or closing the tab - Cancel navigation in the confirmation dialog - Navigation should be prevented and you should not loose your changes Test: - Modify the template/report/function - Navigate away using either the sidebar link, the browser back button or closing the tab - Confirm navigation in the confirmation dialog - Navigation should not be prevented and you should have lost your changes --- .../Auth/EmailTemplates/TemplateEditor.tsx | 17 +---- .../components/interfaces/Reports/Reports.tsx | 51 +------------- .../DiscardChangesConfirmationDialog.tsx | 2 +- .../PreventNavigationOnUnsavedChanges.tsx | 70 +++++++++++++++++++ .../[ref]/functions/[functionSlug]/code.tsx | 43 ++++-------- .../pages/project/[ref]/functions/new.tsx | 18 +---- 6 files changed, 92 insertions(+), 109 deletions(-) create mode 100644 apps/studio/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges.tsx diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 8997336afd9..9e3e61eab1f 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -29,6 +29,7 @@ import { import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SpamValidation } from './SpamValidation' +import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' interface TemplateEditorProps { template: FormSchema @@ -202,21 +203,6 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { } }, [authConfig, properties, messageSlug, form]) - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [hasUnsavedChanges]) - useEffect(() => { if (projectRef && id && !!authConfig) { const [subjectKey] = Object.keys(properties) @@ -376,6 +362,7 @@ export const TemplateEditor = ({ template }: TemplateEditorProps) => { )} + ) } diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index ca74c461aef..98f4b82639d 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -37,6 +37,7 @@ import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' import { GridResize } from './GridResize' import { MetricOptions } from './MetricOptions' import { LAYOUT_COLUMN_COUNT } from './Reports.constants' +import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' const DEFAULT_CHART_COLUMN_COUNT = 1 const DEFAULT_CHART_ROW_COUNT = 1 @@ -57,9 +58,6 @@ const Reports = () => { const [hasEdits, setHasEdits] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) - const [navigateUrl, setNavigateUrl] = useState() - const [confirmNavigate, setConfirmNavigate] = useState(false) - const { data: userContents, isPending: isLoading, @@ -364,31 +362,6 @@ const Reports = () => { checkEditState() }, [config]) - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasEdits) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - - const handleBrowseAway = (url: string) => { - if (hasEdits && !confirmNavigate) { - setNavigateUrl(url) - throw 'Route change declined' // Just to prevent the route change - } else { - setNavigateUrl(undefined) - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - router.events.on('routeChangeStart', handleBrowseAway) - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - router.events.off('routeChangeStart', handleBrowseAway) - } - }, [hasEdits, confirmNavigate, router]) - if (isLoading || isLoadingPermissions) { return } @@ -528,27 +501,7 @@ const Reports = () => {
)} - { - setConfirmNavigate(true) - let urlToNavigate = navigateUrl ?? '/' - if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) { - urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/' - } - if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}` - setNavigateUrl(undefined) - router.push(urlToNavigate) - }} - onCancel={() => setNavigateUrl(undefined)} - > -

- Unsaved changes will be lost, are you sure you want to navigate away? -

-
+ ) } diff --git a/apps/studio/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog.tsx b/apps/studio/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog.tsx index b7d920062ed..d1cb633596a 100644 --- a/apps/studio/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog.tsx +++ b/apps/studio/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog.tsx @@ -14,7 +14,7 @@ import { AlertDialogTitle, } from 'ui' -interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps { +export interface DiscardChangesConfirmationDialogProps extends ConfirmOnCloseModalProps { title?: ReactNode description?: ReactNode confirmLabel?: ReactNode diff --git a/apps/studio/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges.tsx b/apps/studio/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges.tsx new file mode 100644 index 00000000000..a7e0a8f3dcc --- /dev/null +++ b/apps/studio/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges.tsx @@ -0,0 +1,70 @@ +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { BASE_PATH } from 'lib/constants' + +import { + DiscardChangesConfirmationDialog, + type DiscardChangesConfirmationDialogProps, +} from './DiscardChangesConfirmationDialog' + +export const PreventNavigationOnUnsavedChanges = ({ + hasChanges, + ...props +}: { hasChanges: boolean } & Omit< + DiscardChangesConfirmationDialogProps, + 'visible' | 'onClose' | 'onCancel' +>) => { + const router = useRouter() + const [navigateUrl, setNavigateUrl] = useState() + const [confirmNavigate, setConfirmNavigate] = useState(false) + + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasChanges) { + e.preventDefault() + e.returnValue = '' // deprecated, but older browsers still require this + } + } + + const handleBrowseAway = (url: string) => { + if (hasChanges && !confirmNavigate) { + setNavigateUrl(url) + throw 'Route change declined' // Just to prevent the route change + return + } + setNavigateUrl(undefined) + } + window.addEventListener('beforeunload', handleBeforeUnload) + router.events.on('routeChangeStart', handleBrowseAway) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + router.events.off('routeChangeStart', handleBrowseAway) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [confirmNavigate, hasChanges]) + + const handleCancel = () => { + setNavigateUrl(undefined) + } + + const handleClose = () => { + setConfirmNavigate(true) + let urlToNavigate = navigateUrl ?? '/' + if (BASE_PATH && urlToNavigate.startsWith(BASE_PATH)) { + urlToNavigate = urlToNavigate.slice(BASE_PATH.length) || '/' + } + if (!urlToNavigate.startsWith('/')) urlToNavigate = `/${urlToNavigate}` + setNavigateUrl(undefined) + router.push(urlToNavigate) + } + + return ( + + ) +} diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index 76cc03bb879..a8de7c3f472 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -1,12 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { isEqual } from 'lodash' -import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' -import { toast } from 'sonner' - -import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils' -import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' -import { useLatest } from '@/hooks/misc/useLatest' import { IS_PLATFORM, useParams } from 'common' import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal' import { DefaultLayout } from 'components/layouts/DefaultLayout' @@ -21,8 +13,16 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' +import { isEqual } from 'lodash' +import { AlertCircle, CornerDownLeft, Loader2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' import { LogoLoader } from 'ui' +import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils' +import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' +import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' + const CodePage = () => { const { ref, functionSlug } = useParams() const { data: project } = useSelectedProjectQuery() @@ -146,27 +146,11 @@ const CodePage = () => { setFiles(initialFiles) }, [initialFiles]) - // [Joshen] Probably a candidate for useStaticEffectEvent - const filesRef = useLatest(files) - const initialFilesRef = useLatest(initialFiles) - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - const normalizeFiles = (list: FileData[]) => - list.map(({ id, name, content }) => ({ id, name, content })) - const hasUnsavedChanges = !isEqual( - normalizeFiles(initialFilesRef.current), - normalizeFiles(filesRef.current) - ) - - if (hasUnsavedChanges) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const hasUnsavedChanges = useMemo(() => { + const normalizeFiles = (list: FileData[]) => + list.map(({ id, name, content }) => ({ id, name, content })) + return !isEqual(normalizeFiles(initialFiles), normalizeFiles(files)) + }, [initialFiles, files]) return (
@@ -252,6 +236,7 @@ const CodePage = () => { onConfirm={handleDeployConfirm} isDeploying={isDeploying} /> +
) } diff --git a/apps/studio/pages/project/[ref]/functions/new.tsx b/apps/studio/pages/project/[ref]/functions/new.tsx index b8bb08b7321..a6291c202db 100644 --- a/apps/studio/pages/project/[ref]/functions/new.tsx +++ b/apps/studio/pages/project/[ref]/functions/new.tsx @@ -45,6 +45,7 @@ import { } from 'ui' import * as z from 'zod' +import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges' import { FileData } from '@/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.types' import { useLatest } from '@/hooks/misc/useLatest' @@ -276,21 +277,7 @@ const NewFunctionPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [template]) - // [Joshen] Probably a candidate for useStaticEffectEvent - const filesRef = useLatest(files) - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - const hasUnsavedChanges = !isEqual(INITIAL_FILES, filesRef.current) - - if (hasUnsavedChanges) { - e.preventDefault() - e.returnValue = '' // deprecated, but older browsers still require this - } - } - window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const hasUnsavedChanges = useMemo(() => !isEqual(INITIAL_FILES, files), [files]) return ( { + ) } From 3eb00222641c2e7ab404a044c7ad140879117669 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 10 Mar 2026 23:11:59 +0900 Subject: [PATCH 09/36] =?UTF-8?q?[FE-2158]=20(part=202)=20=E2=80=93=20feat?= =?UTF-8?q?(studio):=20Add=20exposed=20functions=20config=20to=20Postgrest?= =?UTF-8?q?=20settings=20(#43478)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Changes:** - Add function exposure controls to Data API settings - Adds a per-function GRANT/REVOKE UI to the Data API settings page, alongside the existing table selector. - Empty state: shows "No tables/functions available" instead of "0 of 0 X exposed" when there's nothing to show - Renamed update-exposed-tables-mutation → update-exposed-entities-mutation to handle both tables and functions in one save **Decisions of note:** - Functions are grouped by schema.name across overloads — toggling one entry grants/revokes `EXECUTE` on all overloads - Only normal and window functions are shown (`prokind in ('f', 'w')`) — triggers and aggregates are excluded --- **To test:** - Table and schema selectors still works end-to-end - Toggling a function and saving updates the count label and applies the grant (check via SQL Editor: select * from information_schema.role_routine_grants where routine_schema = 'public') - Functions in unexposed schemas are dimmed and unclickable - With no functions in the selected schemas, the selector label reads "No functions available" and the dropdown shows the same --- .../Settings/API/ExposedFunctionSelector.tsx | 305 ++++++++++++++++ .../Settings/API/ExposedTableSelector.tsx | 344 +++++++++--------- .../Settings/API/PostgrestConfig.tsx | 70 +++- .../exposed-function-counts-query.ts | 62 ++++ .../exposed-functions-infinite-query.ts | 90 +++++ apps/studio/data/privileges/keys.ts | 10 + apps/studio/data/privileges/privileges.sql.ts | 182 +++++++-- .../update-exposed-entities-mutation.ts | 70 ++++ .../update-exposed-tables-mutation.ts | 88 ----- 9 files changed, 925 insertions(+), 296 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedFunctionSelector.tsx create mode 100644 apps/studio/data/privileges/exposed-function-counts-query.ts create mode 100644 apps/studio/data/privileges/exposed-functions-infinite-query.ts create mode 100644 apps/studio/data/privileges/update-exposed-entities-mutation.ts delete mode 100644 apps/studio/data/privileges/update-exposed-tables-mutation.ts diff --git a/apps/studio/components/interfaces/Settings/API/ExposedFunctionSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedFunctionSelector.tsx new file mode 100644 index 00000000000..10ec9730c52 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ExposedFunctionSelector.tsx @@ -0,0 +1,305 @@ +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks' +import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Button, + cn, + Command_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { exposedFunctionCountsQueryOptions } from '@/data/privileges/exposed-function-counts-query' +import { exposedFunctionsInfiniteQueryOptions } from '@/data/privileges/exposed-functions-infinite-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { pluralize } from '@/lib/helpers' + +interface ExposedFunctionSelectorProps { + disabled?: boolean + selectedSchemas: string[] + pendingAddFunctionNames: string[] + pendingRemoveFunctionNames: string[] + onTogglePendingAdd: (functionName: string) => void + onTogglePendingRemove: (functionName: string) => void +} + +export const ExposedFunctionSelector = ({ + disabled = false, + selectedSchemas, + pendingAddFunctionNames, + pendingRemoveFunctionNames, + onTogglePendingAdd, + onTogglePendingRemove, +}: ExposedFunctionSelectorProps) => { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 300) + + const { data: project } = useSelectedProjectQuery() + + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + + const { data: countsData, isPending: isCountsPending } = useQuery({ + ...exposedFunctionCountsQueryOptions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + selectedSchemas, + }), + placeholderData: keepPreviousData, + }) + const pendingCount = pendingAddFunctionNames.length + pendingRemoveFunctionNames.length + + const totalCount = countsData?.total_count ?? 0 + const grantsCount = countsData?.grants_count ?? 0 + + const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = + useInfiniteQuery({ + ...exposedFunctionsInfiniteQueryOptions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + search: search.length === 0 ? undefined : debouncedSearch || undefined, + }), + placeholderData: search.length > 0 ? keepPreviousData : undefined, + }) + + const functions = useMemo( + () => data?.pages.flatMap((page) => page.functions) ?? [], + [data?.pages] + ) + + const pendingAddSet = useMemo(() => new Set(pendingAddFunctionNames), [pendingAddFunctionNames]) + const pendingRemoveSet = useMemo( + () => new Set(pendingRemoveFunctionNames), + [pendingRemoveFunctionNames] + ) + + useEffect(() => { + if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage]) + + return ( + + + + + + + + + + {isPending ? ( + <> +
+ +
+
+ +
+ + ) : isError ? ( +
+

Failed to retrieve functions

+
+ ) : ( + <> + {functions.length === 0 && ( +

+ {search.length > 0 ? 'No functions found' : 'No functions available'} +

+ )} + 7 ? 'h-[210px]' : ''} + > + {functions.map((fn) => { + const key = `${fn.schema}.${fn.name}` + const isSchemaExposed = selectedSchemas.includes(fn.schema) + const hasPendingAdd = pendingAddSet.has(key) + const hasPendingRemove = pendingRemoveSet.has(key) + + const isCustom = fn.status === 'custom' + const isGranted = fn.status === 'granted' + + const isCustomNeutral = isCustom && !hasPendingAdd && !hasPendingRemove + const isExposed = + isSchemaExposed && + (isCustom ? hasPendingAdd : isGranted ? !hasPendingRemove : hasPendingAdd) + + const customGrantsTooltip = getCustomGrantsTooltip({ + hasPendingAdd, + hasPendingRemove, + }) + + return ( + { + if (!isSchemaExposed) return + + if (isCustom) { + if (hasPendingAdd) { + onTogglePendingAdd(key) + onTogglePendingRemove(key) + } else if (hasPendingRemove) { + onTogglePendingRemove(key) + onTogglePendingAdd(key) + } else { + onTogglePendingAdd(key) + } + return + } + + if (isGranted) { + onTogglePendingRemove(key) + } else { + onTogglePendingAdd(key) + } + }} + > +
+
+ {isExposed && } + {!isSchemaExposed && ( + + + + + + The schema "{fn.schema}" must be exposed before enabling this + function. + + + )} +
+ + {key} + + +
+ {isCustom && ( + + +
+ +
+
+ + {customGrantsTooltip} + +
+ )} +
+
+
+ ) + })} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + + + + + ) +} + +const getCustomGrantsTooltip = ({ + hasPendingAdd, + hasPendingRemove, +}: { + hasPendingAdd: boolean + hasPendingRemove: boolean +}) => { + if (hasPendingAdd) { + return 'This function has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.' + } + + if (hasPendingRemove) { + return 'This function has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.' + } + + return 'This function has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.' +} diff --git a/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx index 18371b96ad7..2b0bdc40826 100644 --- a/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx +++ b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx @@ -26,7 +26,6 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { pluralize } from '@/lib/helpers' interface ExposedTableSelectorProps { - className?: string disabled?: boolean selectedSchemas: string[] pendingAddTableIds: number[] @@ -36,7 +35,6 @@ interface ExposedTableSelectorProps { } export const ExposedTableSelector = ({ - className, disabled = false, selectedSchemas, pendingAddTableIds, @@ -92,197 +90,193 @@ export const ExposedTableSelector = ({ }, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage]) return ( -
- - - - - - - - - - {isPending ? ( - <> -
- -
-
- -
- - ) : isError ? ( -
-

Failed to retrieve tables

+

+
+ + + + + + + + {isPending ? ( + <> +
+
- ) : ( - <> - {search.length > 0 && tables.length === 0 && ( -

- No tables found -

- )} - 7 ? 'h-[210px]' : ''} - > - {tables.map((table) => { - const isSchemaExposed = selectedSchemas.includes(table.schema) - const hasPendingAdd = pendingAddSet.has(table.id) - const hasPendingRemove = pendingRemoveSet.has(table.id) +
+ +
+ + ) : isError ? ( +
+

Failed to retrieve tables

+
+ ) : ( + <> + {tables.length === 0 && ( +

+ {search.length > 0 ? 'No tables found' : 'No tables available'} +

+ )} + 7 ? 'h-[210px]' : ''}> + {tables.map((table) => { + const isSchemaExposed = selectedSchemas.includes(table.schema) + const hasPendingAdd = pendingAddSet.has(table.id) + const hasPendingRemove = pendingRemoveSet.has(table.id) - const isCustomTable = table.status === 'custom' - const isGranted = table.status === 'granted' + const isCustomTable = table.status === 'custom' + const isGranted = table.status === 'granted' - const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove - const isExposed = - isSchemaExposed && - (isCustomTable - ? hasPendingAdd - : isGranted - ? !hasPendingRemove - : hasPendingAdd) + const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove + const isExposed = + isSchemaExposed && + (isCustomTable + ? hasPendingAdd + : isGranted + ? !hasPendingRemove + : hasPendingAdd) - const customGrantsTooltip = getCustomGrantsTooltip({ - hasPendingAdd, - hasPendingRemove, - }) + const customGrantsTooltip = getCustomGrantsTooltip({ + hasPendingAdd, + hasPendingRemove, + }) - return ( - { - if (!isSchemaExposed) return + return ( + { + if (!isSchemaExposed) return - if (isCustomTable) { - if (hasPendingAdd) { - onTogglePendingAdd(table.id) - onTogglePendingRemove(table.id) - } else if (hasPendingRemove) { - onTogglePendingRemove(table.id) - onTogglePendingAdd(table.id) - } else { - onTogglePendingAdd(table.id) - } - return - } - - if (isGranted) { + if (isCustomTable) { + if (hasPendingAdd) { + onTogglePendingAdd(table.id) onTogglePendingRemove(table.id) + } else if (hasPendingRemove) { + onTogglePendingRemove(table.id) + onTogglePendingAdd(table.id) } else { onTogglePendingAdd(table.id) } - }} - > -
-
- {isExposed && } -
- - {`${table.schema}.${table.name}`} - + return + } -
- {isCustomTable && ( - - -
- -
-
- +
+
+ {isExposed && } + {!isSchemaExposed && ( + + + - - - {`The schema "${table.schema}" must be exposed before enabling this table.`} - - - )} -
+ + + + + The schema "{table.schema}" must be exposed before enabling this + table. + + + )}
- - ) - })} -
- {hasNextPage && ( -
- -
- )} - - - )} - - - - - -
+ + {`${table.schema}.${table.name}`} + + +
+ {isCustomTable && ( + + +
+ +
+
+ + {customGrantsTooltip} + +
+ )} +
+
+ + ) + })} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + + + + ) } diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 24df32ed986..6be39205375 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -35,6 +35,7 @@ import { z } from 'zod' import { ExposedSchemaSelector } from './ExposedSchemaSelector' import { HardenAPIModal } from './HardenAPIModal' +import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector' import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector' import { FormActions } from '@/components/ui/Forms/FormActions' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' @@ -42,7 +43,7 @@ import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-p import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { privilegeKeys } from '@/data/privileges/keys' -import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation' +import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled' import useLatest from '@/hooks/misc/useLatest' @@ -66,6 +67,8 @@ const formSchema = z.object({ // Fields for expose toggles tableIdsToAdd: z.array(z.number()), tableIdsToRemove: z.array(z.number()), + functionNamesToAdd: z.array(z.string()), + functionNamesToRemove: z.array(z.string()), }) export const PostgrestConfig = () => { @@ -119,7 +122,7 @@ export const PostgrestConfig = () => { const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation() - const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation() + const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation() const [isUpdating, setIsUpdating] = useState(false) @@ -143,6 +146,8 @@ export const PostgrestConfig = () => { dbPool: config?.db_pool, tableIdsToAdd: [] as number[], tableIdsToRemove: [] as number[], + functionNamesToAdd: [] as string[], + functionNamesToRemove: [] as string[], } }, [config, configDbSchemas]) @@ -165,11 +170,13 @@ export const PostgrestConfig = () => { let dbSchema = values.dbSchema.join(',') if (isApiGrantTogglesEnabled) { - await updateExposedTables({ + await updateExposedEntities({ projectRef, connectionString: project?.connectionString, tableIdsToAdd: values.tableIdsToAdd, tableIdsToRemove: values.tableIdsToRemove, + functionNamesToAdd: values.functionNamesToAdd, + functionNamesToRemove: values.functionNamesToRemove, }) } @@ -191,6 +198,12 @@ export const PostgrestConfig = () => { queryClient.invalidateQueries({ queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema), }), + queryClient.invalidateQueries({ + queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef), + }), + queryClient.invalidateQueries({ + queryKey: privilegeKeys.exposedFunctionCounts(projectRef, watchedDbSchema), + }), ]) toast.success('Successfully saved settings') @@ -204,6 +217,8 @@ export const PostgrestConfig = () => { dbPool: values.dbPool, tableIdsToAdd: [], tableIdsToRemove: [], + functionNamesToAdd: [], + functionNamesToRemove: [], }) } catch (error) { toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error') @@ -227,6 +242,14 @@ export const PostgrestConfig = () => { control: form.control, name: 'tableIdsToRemove', }) + const watchedFunctionNamesToAdd = useWatch_Shadcn_({ + control: form.control, + name: 'functionNamesToAdd', + }) + const watchedFunctionNamesToRemove = useWatch_Shadcn_({ + control: form.control, + name: 'functionNamesToRemove', + }) return ( @@ -312,6 +335,47 @@ export const PostgrestConfig = () => { /> + + { + const current = form.getValues('functionNamesToAdd') + if (current.includes(functionName)) { + form.setValue( + 'functionNamesToAdd', + current.filter((x) => x !== functionName), + { shouldDirty: true } + ) + } else { + form.setValue('functionNamesToAdd', [...current, functionName], { + shouldDirty: true, + }) + } + }} + onTogglePendingRemove={(functionName) => { + const current = form.getValues('functionNamesToRemove') + if (current.includes(functionName)) { + form.setValue( + 'functionNamesToRemove', + current.filter((x) => x !== functionName), + { shouldDirty: true } + ) + } else { + form.setValue('functionNamesToRemove', [...current, functionName], { + shouldDirty: true, + }) + } + }} + /> + + {watchedDbSchema.length === 0 && ( { + if (!projectRef) throw new Error('projectRef is required') + if (!selectedSchemas) throw new Error('selectedSchemas is required') + + const sql = getExposedFunctionCountsSql({ selectedSchemas }) + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql, + queryKey: ['exposed-function-counts', selectedSchemas], + }, + signal + ) + + return result[0] as ExposedFunctionCountsResponse +} + +export type ExposedFunctionCountsData = Awaited> +export type ExposedFunctionCountsError = ResponseError + +export const exposedFunctionCountsQueryOptions = ( + { projectRef, connectionString, selectedSchemas }: ExposedFunctionCountsVariables, + { enabled = true }: { enabled?: boolean } = {} +) => { + return queryOptions({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query + queryKey: privilegeKeys.exposedFunctionCounts(projectRef, selectedSchemas), + queryFn: ({ signal }) => + getExposedFunctionCounts( + { + projectRef, + connectionString, + selectedSchemas, + }, + signal + ), + enabled: enabled && typeof projectRef !== 'undefined', + }) +} diff --git a/apps/studio/data/privileges/exposed-functions-infinite-query.ts b/apps/studio/data/privileges/exposed-functions-infinite-query.ts new file mode 100644 index 00000000000..dda8ec5151a --- /dev/null +++ b/apps/studio/data/privileges/exposed-functions-infinite-query.ts @@ -0,0 +1,90 @@ +import { infiniteQueryOptions } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' + +import { privilegeKeys } from './keys' +import { getExposedFunctionsSql } from './privileges.sql' + +export const EXPOSED_FUNCTIONS_PAGE_LIMIT = 50 + +export type ExposedFunctionsVariables = { + projectRef?: string + connectionString?: string | null + search?: string +} + +export type ExposedFunction = { + schema: string + name: string + status: 'granted' | 'revoked' | 'custom' +} + +export type ExposedFunctionsResponse = { + total_count: number + functions: ExposedFunction[] +} + +export async function getExposedFunctions( + { + projectRef, + connectionString, + search, + page = 0, + limit = EXPOSED_FUNCTIONS_PAGE_LIMIT, + }: ExposedFunctionsVariables & { page?: number; limit?: number }, + signal?: AbortSignal +): Promise { + if (!projectRef) throw new Error('projectRef is required') + + const offset = page * limit + + const sql = getExposedFunctionsSql({ search, offset, limit }) + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql, + queryKey: ['exposed-functions', page], + }, + signal + ) + + return result[0] as ExposedFunctionsResponse +} + +export type ExposedFunctionsData = Awaited> +export type ExposedFunctionsError = ResponseError + +export const exposedFunctionsInfiniteQueryOptions = ( + { projectRef, connectionString, search }: ExposedFunctionsVariables, + { enabled = true }: { enabled?: boolean } = {} +) => { + return infiniteQueryOptions({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query + queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef, search), + queryFn: ({ signal, pageParam }) => + getExposedFunctions( + { + projectRef, + connectionString, + search, + page: pageParam, + }, + signal + ), + enabled: enabled && typeof projectRef !== 'undefined', + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const currentTotalCount = page * EXPOSED_FUNCTIONS_PAGE_LIMIT + const totalCount = lastPage.total_count ?? 0 + + if (currentTotalCount >= totalCount) { + return undefined + } + + return page + }, + }) +} diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 328e39c325f..95f1b3519b1 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -13,4 +13,14 @@ export const privilegeKeys = { ] as const, exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) => ['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const, + exposedFunctionsInfinite: (projectRef: string | undefined, search?: string) => + [ + 'projects', + projectRef, + 'privileges', + 'exposed-functions-infinite', + ...(search ? ([{ search }] as const) : []), + ] as const, + exposedFunctionCounts: (projectRef: string | undefined, selectedSchemas: string[]) => + ['projects', projectRef, 'privileges', 'exposed-function-counts', ...selectedSchemas] as const, } diff --git a/apps/studio/data/privileges/privileges.sql.ts b/apps/studio/data/privileges/privileges.sql.ts index 2f72c9699f2..46e08d894ff 100644 --- a/apps/studio/data/privileges/privileges.sql.ts +++ b/apps/studio/data/privileges/privileges.sql.ts @@ -121,42 +121,164 @@ export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: return /* SQL */ ` with ${getTableGrantsCTEs()} - select + select count(*)::int as total_count, (count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count - from table_grants + from table_grants ` } -export function getExposedSchemasSql() { +/** + * Builds the shared `function_privileges` and `function_grants` CTEs used by + * both the exposed-functions list query and the counts-only query. + * + * Returns SQL text meant to follow `WITH` (no leading `WITH` keyword). + * Callers that append additional CTEs should add a comma after interpolation. + */ +function getFunctionGrantsCTEs({ search }: { search?: string } = {}) { return /* SQL */ ` - select coalesce( - ( - select jsonb_agg(distinct schema_name order by schema_name) - from ( - select n.nspname as schema_name - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true - where c.relkind in ('r', 'p', 'v', 'm', 'f') - and n.nspname not in (${IGNORED_SCHEMAS_LIST}) - group by c.oid, n.nspname - having - bool_or( - pg_catalog.pg_get_userbyid(acl.grantee) = 'anon' - and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + function_privileges as ( + select + n.nspname as schema_name, + p.proname as name, + + -- Aggregate EXECUTE across all overloads + all 3 roles + bool_or(pr.rolname = 'anon' and acl.privilege_type = 'EXECUTE') as anon_execute, + bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'EXECUTE') as auth_execute, + bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'EXECUTE') as srv_execute + + from pg_proc p + join pg_namespace n + on n.oid = p.pronamespace + left join lateral aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) as acl + on true + left join pg_roles pr + on pr.oid = acl.grantee + where p.prokind in ('f', 'w') + and n.nspname not in (${IGNORED_SCHEMAS_LIST}) + ${search ? `and (n.nspname || '.' || p.proname) ilike '%${search}%'` : ''} + group by n.nspname, p.proname + ), + function_grants as ( + select + schema_name, + name, + case + when anon_execute and auth_execute and srv_execute then 'granted' + when not (anon_execute or auth_execute or srv_execute) then 'revoked' + else 'custom' + end as status + from function_privileges + ) + ` +} + +export function getExposedFunctionsSql({ + search, + offset, + limit, +}: { + search?: string + offset: number + limit: number +}) { + return /* SQL */ ` + with ${getFunctionGrantsCTEs({ search })} + select + (select count(*)::int from function_grants) as total_count, + coalesce( + ( + select jsonb_agg( + jsonb_build_object( + 'schema', fg.schema_name, + 'name', fg.name, + 'status', fg.status ) - and bool_or( - pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated' - and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') - ) - and bool_or( - pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role' - and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') - ) - ) t - ), - '[]'::jsonb - ) as schemas; + ) + from ( + select * + from function_grants + order by schema_name, name + offset ${offset} + limit ${limit} + ) fg + ), + '[]'::jsonb + ) as functions; + ` +} + +export function getExposedFunctionCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) { + const schemasList = + selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''" + + return /* SQL */ ` + with ${getFunctionGrantsCTEs()} + select + count(*)::int as total_count, + (count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count + from function_grants + ` +} + +export const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => { + if (oids.length === 0) return '' + + const privilegeClause = + action === 'grant' + ? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role' + : 'revoke all on table %I.%I from anon, authenticated, service_role' + + return /* SQL */ ` + do $$ + declare + nspname name; + relname name; + begin + for nspname, relname in + select n.nspname, c.relname + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where c.oid in (${oids.join(', ')}) + loop + execute format('${privilegeClause}', nspname, relname); + end loop; + end $$; + ` +} + +export const buildFunctionPrivilegesSql = (schemaNames: string[], action: 'grant' | 'revoke') => { + if (schemaNames.length === 0) return '' + + const tuples = schemaNames + .map((sn) => { + const dotIdx = sn.indexOf('.') + const schema = sn.slice(0, dotIdx) + const name = sn.slice(dotIdx + 1) + return `('${schema}','${name}')` + }) + .join(', ') + + const privilegeClause = + action === 'grant' + ? 'grant execute on function %I.%I(%s) to anon, authenticated, service_role' + : 'revoke all on function %I.%I(%s) from anon, authenticated, service_role' + + return /* SQL */ ` + do $$ + declare + nspname name; + proname name; + arg_types text; + begin + for nspname, proname, arg_types in + select n.nspname, p.proname, pg_get_function_identity_arguments(p.oid) + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where (n.nspname, p.proname) in (${tuples}) + loop + execute format('${privilegeClause}', nspname, proname, arg_types); + end loop; + end $$; ` } diff --git a/apps/studio/data/privileges/update-exposed-entities-mutation.ts b/apps/studio/data/privileges/update-exposed-entities-mutation.ts new file mode 100644 index 00000000000..85671da1795 --- /dev/null +++ b/apps/studio/data/privileges/update-exposed-entities-mutation.ts @@ -0,0 +1,70 @@ +import { useMutation } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { toast } from 'sonner' +import type { UseCustomMutationOptions } from 'types' + +import type { ConnectionVars } from '../common.types' +import { buildFunctionPrivilegesSql, buildTablePrivilegesSql } from './privileges.sql' + +export type UpdateExposedEntitiesVariables = ConnectionVars & { + tableIdsToAdd: number[] + tableIdsToRemove: number[] + functionNamesToAdd: string[] + functionNamesToRemove: string[] +} + +export async function updateExposedEntities({ + projectRef, + connectionString, + tableIdsToAdd, + tableIdsToRemove, + functionNamesToAdd, + functionNamesToRemove, +}: UpdateExposedEntitiesVariables): Promise { + if (!projectRef) throw new Error('projectRef is required') + + const sqlParts: string[] = [] + + if (tableIdsToAdd.length > 0) { + sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant')) + } + + if (tableIdsToRemove.length > 0) { + sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke')) + } + + if (functionNamesToAdd.length > 0) { + sqlParts.push(buildFunctionPrivilegesSql(functionNamesToAdd, 'grant')) + } + + if (functionNamesToRemove.length > 0) { + sqlParts.push(buildFunctionPrivilegesSql(functionNamesToRemove, 'revoke')) + } + + await executeSql({ + projectRef, + connectionString, + sql: sqlParts.join('\n'), + queryKey: ['update-exposed-entities'], + }) +} + +type UpdateExposedEntitiesData = Awaited> + +export const useUpdateExposedEntitiesMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars: UpdateExposedEntitiesVariables) => updateExposedEntities(vars), + onError(error: Error) { + toast.error(`Failed to update entity access: ${error.message}`) + }, + ...(onError ? { onError } : {}), + ...options, + }) +} diff --git a/apps/studio/data/privileges/update-exposed-tables-mutation.ts b/apps/studio/data/privileges/update-exposed-tables-mutation.ts deleted file mode 100644 index 47b576d0209..00000000000 --- a/apps/studio/data/privileges/update-exposed-tables-mutation.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useMutation } from '@tanstack/react-query' -import { executeSql } from 'data/sql/execute-sql-query' -import { toast } from 'sonner' -import type { UseCustomMutationOptions } from 'types' - -import type { ConnectionVars } from '../common.types' -import { getExposedSchemasSql } from './privileges.sql' - -export type UpdateExposedTablesVariables = ConnectionVars & { - tableIdsToAdd: number[] - tableIdsToRemove: number[] -} - -const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => { - if (oids.length === 0) return '' - - const privilegeClause = - action === 'grant' - ? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role' - : 'revoke all on table %I.%I from anon, authenticated, service_role' - - return /* SQL */ ` - do $$ - declare - relname name; - nspname name; - begin - for nspname, relname in - select n.nspname, c.relname - from pg_class c - join pg_namespace n on n.oid = c.relnamespace - where c.oid in (${oids.join(', ')}) - loop - execute format('${privilegeClause}', relname, nspname); - end loop; - end $$; - ` -} - -export async function updateExposedTables({ - projectRef, - connectionString, - tableIdsToAdd, - tableIdsToRemove, -}: UpdateExposedTablesVariables): Promise { - if (!projectRef) throw new Error('projectRef is required') - - const sqlParts: string[] = [] - - if (tableIdsToAdd.length > 0) { - sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant')) - } - - if (tableIdsToRemove.length > 0) { - sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke')) - } - - sqlParts.push(getExposedSchemasSql()) - - const { result } = await executeSql({ - projectRef, - connectionString, - sql: sqlParts.join('\n'), - queryKey: ['update-exposed-tables'], - }) - - return (result[0] as { schemas: string[] }).schemas -} - -type UpdateExposedTablesData = Awaited> - -export const useUpdateExposedTablesMutation = ({ - onSuccess, - onError, - ...options -}: Omit< - UseCustomMutationOptions, - 'mutationFn' -> = {}) => { - return useMutation({ - mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars), - onError(error: Error) { - toast.error(`Failed to update table access: ${error.message}`) - }, - ...(onError ? { onError } : {}), - ...options, - }) -} From 02dd0b46b84af19b01c1dd1bac181894f7534ce9 Mon Sep 17 00:00:00 2001 From: John Fitzgerald Date: Tue, 10 Mar 2026 14:21:05 +0000 Subject: [PATCH 10/36] Added John Fitzgerald to humans.txt (#42253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? docs update, added my name ## What is the current behavior? N/A ## What is the new behavior? N/A ## Additional context Onboarding task ## Summary by CodeRabbit * **Chores** * Updated team roster documentation. ✏️ Tip: You can customize this high-level summary in your review settings. Co-authored-by: Chris Chinchilla --- 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 d62be0e15e0..6fbfe487d4d 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -115,6 +115,7 @@ Jim Chanco Jr Joakim Ahrlin Joaquim Moreno Joel Low +John Fitzgerald Joel Martin Johan Bergström John Pena From 64e800c0c1ce39b9019c3215c04ef519cbd0cba8 Mon Sep 17 00:00:00 2001 From: Gergely Bator <99259850+batorlancs@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:24:16 +0100 Subject: [PATCH 11/36] docs: remove duplicate table assignment in Python generated types example (#42185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate table assignment using undefined `supabase` variable ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? docs update ## What is the current behavior? - ## What is the new behavior? - ## Additional context - ## Summary by CodeRabbit * **Documentation** * Simplified the Python API client examples in the generating Python types guide by removing redundant code assignments, making the documentation clearer and easier to follow. ✏️ Tip: You can customize this high-level summary in your review settings. Co-authored-by: Chris Chinchilla --- apps/docs/content/guides/api/rest/generating-python-types.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/docs/content/guides/api/rest/generating-python-types.mdx b/apps/docs/content/guides/api/rest/generating-python-types.mdx index a0d7e0090d5..8e3b42acbcb 100644 --- a/apps/docs/content/guides/api/rest/generating-python-types.mdx +++ b/apps/docs/content/guides/api/rest/generating-python-types.mdx @@ -79,8 +79,6 @@ from supabase import create_client client = create_client("YOUR_SUPABASE_URL", "YOUR_SUPABASE_KEY") movies = client.table("movies") -movies = supabase.table("movies") - # Select selected = [PublicMovies(m) for m in movies.select("*").execute().data] From 68e6edd5e6d41b93815e3526f6823e00fbfeb8e3 Mon Sep 17 00:00:00 2001 From: Shashwat Raj <65155843+darthvader58@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:51:17 -0700 Subject: [PATCH 12/36] show/hide password logic fixed (#41694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This PR changes the logic for show/hide password icon in the sign up form. Fix #39399 and #41459 ## What is the current behavior? For Password Field Eye Icon Label
  • When password is hidden: Button shows "Hide password" label (incorrect).
  • When password is visible: Button shows "Show password" label (incorrect)
For Edge Functions Secrets
  • All secret values are always visible in plain text
  • No visibility toggle functionality exists
  • No way to hide/show individual secret values
## What is the new behavior? Sign-up Form Password Field:
  • When password is hidden: Button correctly shows "Show password" label
  • When password is visible: Button correctly shows "Hide password" label
Labels now accurately describe what clicking the button will do

Edge Functions Secrets:
  • Secret values are hidden by default (security-first approach)
  • Each secret has its own independent visibility toggle button
  • Hidden secrets display as bullet points (•••••••••••••••••••••)
  • Eye icon appears when secret is hidden - click to reveal
  • EyeOff icon appears when secret is visible - click to hide
  • Tooltip shows "Hidden" when value is masked, actual value when visible
--------- Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> Co-authored-by: Alaister Young --- .../EdgeFunctionSecret.tsx | 3 +-- .../interfaces/SignIn/SignUpForm.tsx | 20 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 4fbdd2e7a2e..207e280314e 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -1,9 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Edit2, MoreVertical, Trash } from 'lucide-react' - import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { ProjectSecret } from 'data/secrets/secrets-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Edit2, MoreVertical, Trash } from 'lucide-react' import { Button, DropdownMenu, diff --git a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx index c8e54c963d6..03a4aa3bf69 100644 --- a/apps/studio/components/interfaces/SignIn/SignUpForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignUpForm.tsx @@ -1,29 +1,29 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { zodResolver } from '@hookform/resolvers/zod' +import { useSignUpMutation } from 'data/misc/signup-mutation' import { motion } from 'framer-motion' +import { BASE_PATH } from 'lib/constants' +import { buildPathWithParams } from 'lib/gotrue' import { CheckCircle, Eye, EyeOff } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsString, useQueryStates } from 'nuqs' import { useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' -import z from 'zod' - -import { useSignUpMutation } from 'data/misc/signup-mutation' -import { BASE_PATH } from 'lib/constants' -import { buildPathWithParams } from 'lib/gotrue' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, + cn, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - Form_Shadcn_, Input_Shadcn_, - cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import z from 'zod' + import PasswordConditionsHelper from './PasswordConditionsHelper' const schema = z.object({ @@ -183,8 +183,8 @@ export const SignUpForm = () => { />
@@ -164,6 +178,23 @@ export const TableNode = ({ className={cn(hiddenNodeConnector, '!right-0')} /> )} + + + + + Edit column +
))}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index d0f96cdad89..a6168620d14 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -243,6 +243,7 @@ export const ColumnEditor = ({ General}> onUpdateField({ name: event.target.value })} /> t[sortByProperty]) as PostgresTable[] } - return data as Omit[] + return data as PostgresTable[] } export type TablesData = Awaited> diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index 3058602b6c4..fd257460c5d 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -10,8 +10,6 @@ import { waitForDatabaseToLoad, } from '../utils/wait-for-response.js' -const databaseColumnName = 'pw_database_column' - test.describe('Database', () => { test.describe('Schema Visualizer', () => { test('actions works as expected', async ({ page, ref }) => { @@ -36,7 +34,7 @@ test.describe('Database', () => { // validates table and column exists await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible() - await expect(page.getByText(databaseColumnName)).toBeVisible() + await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible() // copies schema definition to clipboard await page.getByRole('button', { name: 'Copy as SQL' }).click() @@ -60,21 +58,61 @@ test.describe('Database', () => { await page.getByTestId('schema-selector').click() await page.getByRole('option', { name: 'auth' }).click() await waitForDatabaseToLoad(page, ref, 'auth') - await expect(page.getByText('users')).toBeVisible() - await expect(page.getByText('sso_providers')).toBeVisible() - await expect(page.getByText('saml_providers')).toBeVisible() + await expect(page.getByText('users', { exact: true })).toBeVisible() + await expect(page.getByText('sso_providers', { exact: true })).toBeVisible() + await expect(page.getByText('saml_providers', { exact: true })).toBeVisible() // navigate to table editor when icon is clicked - const samlProvidersHeader = await page.getByText('saml_providers') + const samlProvidersHeader = await page.getByText('saml_providers', { exact: true }) await samlProvidersHeader.locator('..').getByRole('link').click() await page.waitForURL(/.*\/editor\/\d+/) await page.getByRole('button', { name: 'View saml_providers', exact: true }).click() }) + + test('columns actions work as expected', async ({ page, ref }) => { + const databaseTableName = 'pw_database_schema_columns_actions' + const databaseColumnName = 'pw_database_schema_column_actions' + await using _ = await withSetupCleanup( + async () => { + await createTable(databaseTableName, databaseColumnName) + }, + async () => { + await dropTable(databaseTableName) + } + ) + const wait = createApiResponseWaiter( + page, + 'pg-meta', + ref, + 'tables?include_columns=true&included_schemas=public' + ) + await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/schemas?schema=public`)) + await wait + + // validates table and column exists + await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible() + await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible() + // test we can edit the column + await page.getByText(databaseColumnName, { exact: true }).hover() + await page.getByText(`Edit ${databaseTableName} ${databaseColumnName} column`).click() + await page.getByLabel('Description').fill('Bazinga') + await page.getByRole('button', { name: 'Save' }).click() + await expect( + page.getByText(`Successfully updated column "${databaseColumnName}"`) + ).toBeVisible() + await expect(page.getByRole('dialog')).not.toBeVisible() + + // test the schema view has been refreshed + await page.getByText(databaseColumnName, { exact: true }).hover() + await page.getByText(`Edit ${databaseTableName} ${databaseColumnName} column`).click() + await expect(page.getByLabel('Description')).toHaveValue('Bazinga') + }) }) test.describe('Tables', () => { test('actions works as expected', async ({ page, ref }) => { - const databaseTableName = 'pw_database_actions_table' + const databaseTableName = 'pw_database_table_actions' + const databaseColumnName = 'pw_database_column_actions' await using _ = await withSetupCleanup( async () => { await createTable(databaseTableName, databaseColumnName) @@ -124,10 +162,11 @@ test.describe('Database', () => { }) test('CRUD operations and copy works as expected', async ({ page, ref }) => { - const databaseTableName = 'pw_database_tablecrud_table' - const databaseTableNameNew = 'pw_database_table_new' - const databaseTableNameUpdated = 'pw_database_table_updated' - const databaseTableNameDuplicate = 'pw_database_table_duplicate' + const databaseTableName = 'pw_database_table_crud_table' + const databaseTableNameNew = 'pw_database_table_crud_new' + const databaseTableNameUpdated = 'pw_database_table_crud_updated' + const databaseTableNameDuplicate = 'pw_database_table_crud_duplicate' + const databaseColumnName = 'pw_database_column_table_crud' await using _ = await withSetupCleanup( async () => { @@ -238,8 +277,9 @@ test.describe('Database', () => { test.describe('Tables columns', () => { test('can view, create, update, delete, and filter table columns', async ({ page, ref }) => { const databaseTableName = 'pw_database_columns_table' - const databaseColumnName2 = 'pw_database_column_2' - const databaseColumnName3 = 'pw_database_column_3' + const databaseColumnName = 'pw_database_column_crud' + const databaseColumnName2 = 'pw_database_column_crud_2' + const databaseColumnName3 = 'pw_database_column_crud_3' await using _ = await withSetupCleanup( async () => { @@ -267,9 +307,8 @@ test.describe('Database', () => { // create a new table column await page.getByRole('button', { name: 'New column' }).click() - await page - .getByRole('textbox', { name: 'column_name', exact: true }) - .fill('pw_database_column_2') + await expect(page.getByRole('dialog')).toBeVisible() + await page.getByLabel('name').fill(databaseColumnName2) await page.getByText('Choose a column type...').click() await page.getByText('numeric', { exact: true }).click() const columnCreateWait = createApiResponseWaiter( @@ -296,7 +335,7 @@ test.describe('Database', () => { // update table column await columnDatabase2Row.getByRole('button').click() await page.getByRole('button', { name: 'Edit column' }).click() - await page.getByRole('textbox', { name: 'column_name' }).fill(databaseColumnName3) + await page.getByLabel('name').fill(databaseColumnName3) const columnUpdateWait = createApiResponseWaiter( page, 'pg-meta', @@ -375,6 +414,7 @@ test.describe('Database', () => { test('CRUD operations works as expected', async ({ page, ref }) => { const databaseTableName = 'pw_database_trigger_table' + const databaseColumnName = 'pw_database_column_trigger' const databaseTriggerName = 'pw_database_trigger' const databaseTriggerNameUpdated = 'pw_database_trigger_updated' @@ -513,6 +553,7 @@ test.describe('Database', () => { test('CRUD operations works as expected', async ({ page, ref }) => { const databaseTableName = 'pw_database_indexes_table' + const databaseColumnName = 'pw_database_column_index' const databaseIndexName = 'pw_database_index' await using _ = await withSetupCleanup( @@ -754,8 +795,8 @@ test.describe('Database Functions', () => { // Nothing }, async () => { - await query(`drop function if exists ${databaseFunctionName}`); - await query(`drop function if exists ${databaseFunctionNameUpdated}`); + await query(`drop function if exists ${databaseFunctionName}`) + await query(`drop function if exists ${databaseFunctionNameUpdated}`) } ) From 818cbd36fd82166e08228dc3c094917f72adf7eb Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Tue, 10 Mar 2026 16:26:58 -0600 Subject: [PATCH 24/36] fix: new filter updates in table editor better mobile view (#43604) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Right now the new filter bar just does not work on mobile. It fully collapses and does not appear correctly. This make it stack on smaller screens and shows it to be visible. Works on smaller tabs Note: there is a bit of awkwardness on certain screen sizes where its one long row, will fix that later. This is better than nothing right now --- .../grid/components/header/HeaderNew.tsx | 25 ++++++------------- .../header/filter/FilterPopoverNew.tsx | 2 +- .../ui-patterns/src/FilterBar/FilterGroup.tsx | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/apps/studio/components/grid/components/header/HeaderNew.tsx b/apps/studio/components/grid/components/header/HeaderNew.tsx index 9d03a76d271..c675bef87f5 100644 --- a/apps/studio/components/grid/components/header/HeaderNew.tsx +++ b/apps/studio/components/grid/components/header/HeaderNew.tsx @@ -65,15 +65,20 @@ export const HeaderNew = ({ return (
-
+
{customHeader ? ( customHeader ) : snap.selectedRows.size > 0 ? ( ) : ( - +
+ +
)} -
+
+ {!customHeader && snap.selectedRows.size === 0 && ( + + )} {showInsertButton && }
@@ -82,20 +87,6 @@ export const HeaderNew = ({ ) } -const DefaultHeader = ({ - tableQueriesEnabled = true, - isRefetching, -}: Pick) => { - return ( - <> -
- -
- - - ) -} - const InsertButton = () => { const { ref: projectRef } = useParams() const { data: org } = useSelectedOrganizationQuery() diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx index 9a731fc4edf..58f21498df6 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverNew.tsx @@ -207,7 +207,7 @@ export const FilterPopoverNew = ({ isRefetching = false }: FilterPopoverProps) = actions={actions} isLoading={isGenerating} variant="pill" - className="bg-transparent border-0" + className="bg-transparent border-0 overflow-visible" icon={icon} />
diff --git a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx index 0e2c1258c6f..c7ed5178693 100644 --- a/packages/ui-patterns/src/FilterBar/FilterGroup.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterGroup.tsx @@ -143,7 +143,7 @@ export function FilterGroup({ group, path }: FilterGroupProps) { >
Date: Wed, 11 Mar 2026 12:38:02 +1100 Subject: [PATCH 25/36] feat(studio): page titles for project surfaces (#43534) Stacked PR 2/5 for page title improvements. Includes Auth, Database, editors, and Edge Functions layout title adoption. _Base: [dnywh/feat/page-titles](https://github.com/supabase/supabase/pull/43538)_ --- ## What kind of change does this PR introduce? - Resolves FE-1960 - Resolves FE-1983 - Resolves DEPR-207 ## What is the current behavior? Page titles between surfaces are inconsistent and vague. Sometimes they say the product name: ``` My Project | My Org | Supabase ``` ...even when on a specific surface like Database > Tables. Other times they show the entity name but skip over the project or org name : ``` Edge Functions | Supabase ``` ## What is the new behavior? Auth, Database, editors, and Edge Functions adopt the layout title format introduced in https://github.com/supabase/supabase/pull/43538: ``` users | Table Editor | My Project | My Org | Supabase hello-world | Logs | Edge Functions | My Project | My Org | Supabase Backups | Database | My Project | My Org | Supabase Authentication | My Project | My Org | Supabase ``` That format is: entity, section, surface, project, org, brand ## Additional context Related stacked PRs also based on the the original [dnywh/feat/page-titles](https://github.com/supabase/supabase/pull/43538) branch: - https://github.com/supabase/supabase/pull/43535 - https://github.com/supabase/supabase/pull/43536 - https://github.com/supabase/supabase/pull/43537 --------- Co-authored-by: Joshen Lim --- .../layouts/AuthLayout/AuthEmailsLayout.tsx | 6 ++--- .../layouts/AuthLayout/AuthLayout.tsx | 5 ++-- .../AuthLayout/AuthProvidersLayout.tsx | 6 ++--- .../layouts/DatabaseLayout/DatabaseLayout.tsx | 11 ++++++--- .../DatabaseLayout/DatabaseTriggersLayout.tsx | 10 ++++---- .../EdgeFunctionDetailsLayout.tsx | 24 ++++++++++++------- .../EdgeFunctionsLayout.tsx | 20 ++++++++++++---- .../layouts/ProjectLayout/index.tsx | 1 + .../layouts/editors/EditorBaseLayout.tsx | 21 ++++++++++++++++ .../pages/project/[ref]/auth/audit-logs.tsx | 3 +-- .../studio/pages/project/[ref]/auth/hooks.tsx | 3 +-- .../studio/pages/project/[ref]/auth/index.tsx | 15 ------------ apps/studio/pages/project/[ref]/auth/mfa.tsx | 3 +-- .../pages/project/[ref]/auth/oauth-apps.tsx | 2 +- .../pages/project/[ref]/auth/oauth-server.tsx | 2 +- .../pages/project/[ref]/auth/overview.tsx | 7 +++--- .../pages/project/[ref]/auth/performance.tsx | 3 +-- .../pages/project/[ref]/auth/policies.tsx | 2 +- .../pages/project/[ref]/auth/protection.tsx | 3 +-- .../pages/project/[ref]/auth/rate-limits.tsx | 3 +-- .../pages/project/[ref]/auth/sessions.tsx | 3 +-- .../[ref]/auth/templates/[templateId].tsx | 15 ++++++------ .../pages/project/[ref]/auth/third-party.tsx | 3 +-- .../project/[ref]/auth/url-configuration.tsx | 3 +-- .../studio/pages/project/[ref]/auth/users.tsx | 2 +- .../project/[ref]/database/backups/pitr.tsx | 6 ++--- .../backups/restore-to-new-project.tsx | 2 +- .../[ref]/database/backups/scheduled.tsx | 2 +- .../project/[ref]/database/extensions.tsx | 3 +-- .../project/[ref]/database/functions.tsx | 2 +- .../pages/project/[ref]/database/index.tsx | 15 ------------ .../[ref]/database/publications/[id].tsx | 5 +--- .../[ref]/database/publications/index.tsx | 3 +-- .../database/replication/[pipelineId].tsx | 7 +++--- .../[ref]/database/replication/index.tsx | 2 +- .../replication/replica/[replicaId].tsx | 18 +++++++------- .../pages/project/[ref]/database/roles.tsx | 2 +- .../pages/project/[ref]/database/schemas.tsx | 5 ++-- .../pages/project/[ref]/database/settings.tsx | 2 +- .../project/[ref]/database/tables/[id].tsx | 5 ++-- .../project/[ref]/database/tables/index.tsx | 5 ++-- .../project/[ref]/database/triggers/data.tsx | 2 +- .../project/[ref]/database/triggers/event.tsx | 2 +- .../pages/project/[ref]/database/types.tsx | 2 +- .../[ref]/functions/[functionSlug]/code.tsx | 2 +- .../functions/[functionSlug]/details.tsx | 2 +- .../[ref]/functions/[functionSlug]/index.tsx | 2 +- .../functions/[functionSlug]/invocations.tsx | 2 +- .../[ref]/functions/[functionSlug]/logs.tsx | 2 +- .../pages/project/[ref]/functions/index.tsx | 2 +- .../pages/project/[ref]/functions/new.tsx | 2 +- .../pages/project/[ref]/functions/secrets.tsx | 2 +- 52 files changed, 137 insertions(+), 145 deletions(-) delete mode 100644 apps/studio/pages/project/[ref]/auth/index.tsx delete mode 100644 apps/studio/pages/project/[ref]/database/index.tsx diff --git a/apps/studio/components/layouts/AuthLayout/AuthEmailsLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthEmailsLayout.tsx index 1d2754ffc5c..4d2644f7330 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthEmailsLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthEmailsLayout.tsx @@ -1,9 +1,9 @@ -import { PropsWithChildren } from 'react' - import { useParams } from 'common' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { UnknownInterface } from 'components/ui/UnknownInterface' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { PropsWithChildren } from 'react' + import AuthLayout from './AuthLayout' export const AuthEmailsLayout = ({ children }: PropsWithChildren<{}>) => { @@ -23,7 +23,7 @@ export const AuthEmailsLayout = ({ children }: PropsWithChildren<{}>) => { ] return ( - + {showEmails ? ( { useAuthConfigPrefetch({ projectRef }) const page = router.pathname.split('/')[4] - const menu = useGenerateAuthMenu() return } -const AuthLayout = ({ children }: PropsWithChildren<{}>) => { +const AuthLayout = ({ title, children }: PropsWithChildren<{ title: string }>) => { return ( } isBlocking={false} diff --git a/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx index 81f3c445ed0..7831f8d382d 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthProvidersLayout.tsx @@ -1,9 +1,9 @@ -import { PropsWithChildren } from 'react' - import { useParams } from 'common' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { UnknownInterface } from 'components/ui/UnknownInterface' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { PropsWithChildren } from 'react' + import AuthLayout from './AuthLayout' export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => { @@ -29,7 +29,7 @@ export const AuthProvidersLayout = ({ children }: PropsWithChildren<{}>) => { ] return ( - + {authenticationSignInProviders ? ( { @@ -18,9 +18,14 @@ const DatabaseProductMenu = () => { return } -const DatabaseLayout = ({ children }: PropsWithChildren) => { +const DatabaseLayout = ({ children, title }: PropsWithChildren) => { return ( - } isBlocking={false}> + } + isBlocking={false} + > {children} ) diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx index 92b81b398df..d2527b8fc39 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseTriggersLayout.tsx @@ -1,15 +1,15 @@ -import { PropsWithChildren } from 'react' - import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { PropsWithChildren } from 'react' + import DatabaseLayout from './DatabaseLayout' type DatabaseTriggersLayoutProps = PropsWithChildren -const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => { +export const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => { const { ref } = useParams() const { can: canReadTriggers, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, @@ -28,7 +28,7 @@ const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => { ] return ( - + {isPermissionsLoaded && !canReadTriggers ? ( ) : ( @@ -44,5 +44,3 @@ const DatabaseTriggersLayout = ({ children }: DatabaseTriggersLayoutProps) => { ) } - -export default DatabaseTriggersLayout diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index 394d0b66546..847804170e6 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Download, FileArchive, Send } from 'lucide-react' -import { useRouter } from 'next/router' -import React, { useEffect, useState, type PropsWithChildren } from 'react' -import { toast } from 'sonner' - import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' import { IS_PLATFORM, useParams } from 'common' import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' @@ -18,7 +13,11 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' import { DOCS_URL } from 'lib/constants' +import { Download, FileArchive, Send } from 'lucide-react' import Link from 'next/link' +import { useRouter } from 'next/router' +import React, { useEffect, useState, type PropsWithChildren } from 'react' +import { toast } from 'sonner' import { BreadcrumbItem_Shadcn_ as BreadcrumbItem, BreadcrumbLink_Shadcn_ as BreadcrumbLink, @@ -42,11 +41,12 @@ import { PageHeaderSummary, PageHeaderTitle, } from 'ui-patterns/PageHeader' + import { ProjectLayout } from '../ProjectLayout' import EdgeFunctionsLayout from './EdgeFunctionsLayout' interface EdgeFunctionDetailsLayoutProps { - title?: string + title: string } const EdgeFunctionDetailsLayout = ({ @@ -91,6 +91,10 @@ const EdgeFunctionDetailsLayout = ({ ) const name = selectedFunction?.name || '' + const browserTitle = { + entity: functionSlug ? name || functionSlug : undefined, + section: title, + } const breadcrumbItems = [ { @@ -215,14 +219,18 @@ const EdgeFunctionDetailsLayout = ({ if (!isLoading && !canReadFunctions) { return ( - + ) } return ( - +
{breadcrumbItems.length > 0 && ( diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx index b2967dfe84b..dd2f03aa7ba 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout.tsx @@ -1,9 +1,9 @@ -import { useRouter } from 'next/router' -import { PropsWithChildren } from 'react' - import { useParams } from 'common' import { ProductMenu } from 'components/ui/ProductMenu' import { withAuth } from 'hooks/misc/withAuth' +import { useRouter } from 'next/router' +import { ComponentProps, PropsWithChildren } from 'react' + import { ProjectLayout } from '../ProjectLayout' const EdgeFunctionsProductMenu = () => { @@ -35,11 +35,21 @@ const EdgeFunctionsProductMenu = () => { return } -const EdgeFunctionsLayout = ({ children }: PropsWithChildren<{}>) => { +interface EdgeFunctionsLayoutProps { + title: string + browserTitle?: ComponentProps['browserTitle'] +} + +const EdgeFunctionsLayout = ({ + children, + title, + browserTitle, +}: PropsWithChildren) => { return ( } isBlocking={false} > diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 4cdec8313e2..a12b3310441 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -67,6 +67,7 @@ const routesToIgnorePostgrestConnection = [ ] export interface ProjectLayoutProps { + /** @deprecated Use browserTitle.section instead. */ title?: string isLoading?: boolean isBlocking?: boolean diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index fa4ecf5890a..60b46ee06f2 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -22,6 +22,7 @@ export const EditorBaseLayout = ({ product, productMenuClassName, productMenu, + browserTitle, }: ExplorerLayoutProps) => { const { ref } = useParams() const pathname = usePathname() @@ -33,11 +34,31 @@ export const EditorBaseLayout = ({ const hideTabs = pathname === `/project/${ref}/editor` || pathname === `/project/${ref}/sql` || hasNoOpenTabs + const activeEditorTab = tabs.activeTab ? tabs.tabsMap[tabs.activeTab] : undefined + const activeEditorTabEntity = + activeEditorTab === undefined + ? undefined + : editor === 'sql' + ? activeEditorTab.type === 'sql' + ? activeEditorTab.metadata?.name || activeEditorTab.label + : undefined + : editor === 'table' + ? activeEditorTab.type !== 'sql' + ? activeEditorTab.metadata?.name || activeEditorTab.label + : undefined + : undefined + + const mergedBrowserTitle = { + ...browserTitle, + entity: browserTitle?.entity ?? activeEditorTabEntity, + } + return ( diff --git a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx index d3d5b1725ec..00f1987dc9b 100644 --- a/apps/studio/pages/project/[ref]/auth/audit-logs.tsx +++ b/apps/studio/pages/project/[ref]/auth/audit-logs.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { AuditLogsForm } from 'components/interfaces/Auth/AuditLogsForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -62,7 +61,7 @@ const AuditLogsPage: NextPageWithLayout = () => { AuditLogsPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/hooks.tsx b/apps/studio/pages/project/[ref]/auth/hooks.tsx index e4eed3e87a5..6a2056593fc 100644 --- a/apps/studio/pages/project/[ref]/auth/hooks.tsx +++ b/apps/studio/pages/project/[ref]/auth/hooks.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { HooksListing } from 'components/interfaces/Auth/Hooks/HooksListing' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -60,7 +59,7 @@ const Hooks: NextPageWithLayout = () => { Hooks.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/index.tsx b/apps/studio/pages/project/[ref]/auth/index.tsx deleted file mode 100644 index 8bef5974447..00000000000 --- a/apps/studio/pages/project/[ref]/auth/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import type { NextPageWithLayout } from 'types' - -const Authentication: NextPageWithLayout = () => { - return <>{/*

Use this as a template for authentication pages

*/} -} - -Authentication.getLayout = (page) => ( - - {page} - -) - -export default Authentication diff --git a/apps/studio/pages/project/[ref]/auth/mfa.tsx b/apps/studio/pages/project/[ref]/auth/mfa.tsx index f9237a052e1..4c6170b5023 100644 --- a/apps/studio/pages/project/[ref]/auth/mfa.tsx +++ b/apps/studio/pages/project/[ref]/auth/mfa.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' import { MfaAuthSettingsForm } from 'components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -66,7 +65,7 @@ const MfaPage: NextPageWithLayout = () => { MfaPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx index 1afacb80cc8..b3a16cfbc8c 100644 --- a/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx +++ b/apps/studio/pages/project/[ref]/auth/oauth-apps.tsx @@ -38,7 +38,7 @@ const OAuthApps: NextPageWithLayout = () => ( OAuthApps.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx index 169a032c191..340c771b1ae 100644 --- a/apps/studio/pages/project/[ref]/auth/oauth-server.tsx +++ b/apps/studio/pages/project/[ref]/auth/oauth-server.tsx @@ -44,7 +44,7 @@ const ProvidersPage: NextPageWithLayout = () => { ProvidersPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/overview.tsx b/apps/studio/pages/project/[ref]/auth/overview.tsx index 6caef37cf1c..0855d7a60f7 100644 --- a/apps/studio/pages/project/[ref]/auth/overview.tsx +++ b/apps/studio/pages/project/[ref]/auth/overview.tsx @@ -1,6 +1,3 @@ -import { useRouter } from 'next/router' -import { useContext, useEffect } from 'react' - import { FeatureFlagContext, useFlag, useParams } from 'common' import { OverviewLearnMore } from 'components/interfaces/Auth/Overview/OverviewLearnMore' import { OverviewMetrics } from 'components/interfaces/Auth/Overview/OverviewMetrics' @@ -9,6 +6,8 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { DocsButton } from 'components/ui/DocsButton' import { useAuthOverviewQuery } from 'data/auth/auth-overview-query' import { DOCS_URL } from 'lib/constants' +import { useRouter } from 'next/router' +import { useContext, useEffect } from 'react' import { NextPageWithLayout } from 'types' import { PageContainer } from 'ui-patterns/PageContainer' import { @@ -69,7 +68,7 @@ const AuthOverview: NextPageWithLayout = () => { AuthOverview.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/performance.tsx b/apps/studio/pages/project/[ref]/auth/performance.tsx index 745e9c45fd7..04967ab9d9d 100644 --- a/apps/studio/pages/project/[ref]/auth/performance.tsx +++ b/apps/studio/pages/project/[ref]/auth/performance.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' import { PerformanceSettingsForm } from 'components/interfaces/Auth/PerformanceSettingsForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -66,7 +65,7 @@ const PerformancePage: NextPageWithLayout = () => { PerformancePage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index f6ce2a7f3f1..3ba6e76f7dd 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -367,7 +367,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { AuthPoliciesPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/protection.tsx b/apps/studio/pages/project/[ref]/auth/protection.tsx index 7f1c6666926..ffff8d7a779 100644 --- a/apps/studio/pages/project/[ref]/auth/protection.tsx +++ b/apps/studio/pages/project/[ref]/auth/protection.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' import { ProtectionAuthSettingsForm } from 'components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -66,7 +65,7 @@ const ProtectionPage: NextPageWithLayout = () => { ProtectionPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx index fd626ae0ee2..25a4ce7bc98 100644 --- a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx +++ b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' import { RateLimits } from 'components/interfaces/Auth/RateLimits/RateLimits' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -74,7 +73,7 @@ const RateLimitsPage: NextPageWithLayout = () => { RateLimitsPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx index 3d1751f787a..96cf40900ba 100644 --- a/apps/studio/pages/project/[ref]/auth/sessions.tsx +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { SessionsAuthSettingsForm } from 'components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -56,7 +55,7 @@ const SessionsPage: NextPageWithLayout = () => { SessionsPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx index d16a6ac2acb..f9dfc1323e1 100644 --- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx +++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx @@ -1,12 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import Link from 'next/link' -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 { TEMPLATES_SCHEMAS } from 'components/interfaces/Auth/AuthTemplatesValidation' import { slugifyTitle } from 'components/interfaces/Auth/EmailTemplates/EmailTemplates.utils' @@ -19,6 +12,11 @@ import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' import type { NextPageWithLayout } from 'types' import { Button, @@ -56,6 +54,7 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from 'ui/src/components/shadcn/ui/breadcrumb' +import { z } from 'zod' const TemplatePage: NextPageWithLayout = () => { return @@ -287,7 +286,7 @@ const RedirectToTemplates = () => { TemplatePage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/third-party.tsx b/apps/studio/pages/project/[ref]/auth/third-party.tsx index 1ce5724e89e..e2621ec8376 100644 --- a/apps/studio/pages/project/[ref]/auth/third-party.tsx +++ b/apps/studio/pages/project/[ref]/auth/third-party.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { useParams } from 'common' import { ThirdPartyAuthForm } from 'components/interfaces/Auth/ThirdPartyAuthForm' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -23,7 +22,7 @@ const ThirdPartyPage: NextPageWithLayout = () => { if (!showThirdPartyAuth) { return ( - + ) diff --git a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx index 9f74bc06b88..0fee7d3fbb3 100644 --- a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { RedirectUrls } from 'components/interfaces/Auth/RedirectUrls/RedirectUrls' import SiteUrl from 'components/interfaces/Auth/SiteUrl/SiteUrl' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' @@ -60,7 +59,7 @@ const URLConfiguration: NextPageWithLayout = () => { URLConfiguration.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/auth/users.tsx b/apps/studio/pages/project/[ref]/auth/users.tsx index 33cde059efa..35ee67c1d73 100644 --- a/apps/studio/pages/project/[ref]/auth/users.tsx +++ b/apps/studio/pages/project/[ref]/auth/users.tsx @@ -9,7 +9,7 @@ const UsersPage: NextPageWithLayout = () => { UsersPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 96b0fd6a934..da113de653b 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -1,6 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { AlertCircle } from 'lucide-react' - import { useParams } from 'common' import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav' import { PITRNotice } from 'components/interfaces/Database/Backups/PITR/PITRNotice' @@ -16,6 +14,7 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useIsOrioleDbInAws, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL, PROJECT_STATUS } from 'lib/constants' +import { AlertCircle } from 'lucide-react' import type { NextPageWithLayout } from 'types' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui' import { Admonition } from 'ui-patterns' @@ -29,6 +28,7 @@ import { } from 'ui-patterns/PageHeader' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' const DatabasePhysicalBackups: NextPageWithLayout = () => { @@ -59,7 +59,7 @@ const DatabasePhysicalBackups: NextPageWithLayout = () => { DatabasePhysicalBackups.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx index afa77cc8358..4f120488697 100644 --- a/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/restore-to-new-project.tsx @@ -51,7 +51,7 @@ const RestoreToNewProjectPage: NextPageWithLayout = () => { RestoreToNewProjectPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index fdb99fae7de..d79d687d345 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -125,7 +125,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { DatabaseScheduledBackups.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index a3b7b8e55d7..628c21b13ea 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { Extensions } from 'components/interfaces/Database/Extensions/Extensions' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -57,7 +56,7 @@ const DatabaseExtensions: NextPageWithLayout = () => { DatabaseExtensions.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index 18f07bf4076..61b37d0363a 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -52,7 +52,7 @@ const DatabaseFunctionsPage: NextPageWithLayout = () => { DatabaseFunctionsPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/index.tsx b/apps/studio/pages/project/[ref]/database/index.tsx deleted file mode 100644 index adbea34ac88..00000000000 --- a/apps/studio/pages/project/[ref]/database/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import type { NextPageWithLayout } from 'types' - -const Database: NextPageWithLayout = () => { - return <>{/*

Use this as a page template for database

*/} -} - -Database.getLayout = (page) => ( - - {page} - -) - -export default Database diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 2905ae2a7c1..1d82acf59e3 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -30,9 +29,7 @@ const DatabasePublications: NextPageWithLayout = () => { DatabasePublications.getLayout = (page) => ( - - {page} - + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx index 587808a83f0..9a241e4cedd 100644 --- a/apps/studio/pages/project/[ref]/database/publications/index.tsx +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -1,5 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -47,7 +46,7 @@ const DatabasePublications: NextPageWithLayout = () => { DatabasePublications.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx index 8eab0e175f2..255c0efca44 100644 --- a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx @@ -1,6 +1,3 @@ -import { useRouter } from 'next/router' -import { useContext, useEffect } from 'react' - import { FeatureFlagContext, useParams } from 'common' import { ReplicationPipelineStatus } from 'components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus' import { useIsETLPrivateAlpha } from 'components/interfaces/Database/Replication/useIsETLPrivateAlpha' @@ -8,6 +5,8 @@ import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' +import { useRouter } from 'next/router' +import { useContext, useEffect } from 'react' import { PipelineRequestStatusProvider } from 'state/replication-pipeline-request-status' import type { NextPageWithLayout } from 'types' @@ -43,7 +42,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { DatabaseReplicationPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/replication/index.tsx b/apps/studio/pages/project/[ref]/database/replication/index.tsx index 46c71518f67..b11a66035a5 100644 --- a/apps/studio/pages/project/[ref]/database/replication/index.tsx +++ b/apps/studio/pages/project/[ref]/database/replication/index.tsx @@ -47,7 +47,7 @@ const DatabaseReplicationPage: NextPageWithLayout = () => { DatabaseReplicationPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx index c50ac678475..3d7730e40d5 100644 --- a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx @@ -1,7 +1,15 @@ +import { useParams } from 'common' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import { DefaultLayout } from 'components/layouts/DefaultLayout' +import { Database } from 'icons' import { Loader2, Trash } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' +import { AWS_REGIONS } from 'shared-data' +import type { NextPageWithLayout } from 'types' +import { Badge, Button } from 'ui' +import { ShimmeringLoader } from 'ui-patterns' import { ReadReplicaDetails } from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails' import { @@ -20,14 +28,6 @@ import { ReplicaInitializationStatus, useReadReplicasStatusesQuery, } from '@/data/read-replicas/replicas-status-query' -import { useParams } from 'common' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import { DefaultLayout } from 'components/layouts/DefaultLayout' -import { Database } from 'icons' -import { AWS_REGIONS } from 'shared-data' -import type { NextPageWithLayout } from 'types' -import { Badge, Button } from 'ui' -import { ShimmeringLoader } from 'ui-patterns' const DatabaseReadReplicaPage: NextPageWithLayout = () => { const router = useRouter() @@ -179,7 +179,7 @@ const DatabaseReadReplicaPage: NextPageWithLayout = () => { DatabaseReadReplicaPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/roles.tsx b/apps/studio/pages/project/[ref]/database/roles.tsx index c4ed69b5c5f..bde0ff8163c 100644 --- a/apps/studio/pages/project/[ref]/database/roles.tsx +++ b/apps/studio/pages/project/[ref]/database/roles.tsx @@ -48,7 +48,7 @@ const DatabaseRoles: NextPageWithLayout = () => { DatabaseRoles.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/schemas.tsx b/apps/studio/pages/project/[ref]/database/schemas.tsx index 3b65542418b..7cfcb036b1f 100644 --- a/apps/studio/pages/project/[ref]/database/schemas.tsx +++ b/apps/studio/pages/project/[ref]/database/schemas.tsx @@ -1,8 +1,7 @@ -import { ReactFlowProvider } from 'reactflow' - import { SchemaGraph } from 'components/interfaces/Database/Schemas/SchemaGraph' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' +import { ReactFlowProvider } from 'reactflow' import type { NextPageWithLayout } from 'types' const SchemasPage: NextPageWithLayout = () => { @@ -17,7 +16,7 @@ const SchemasPage: NextPageWithLayout = () => { SchemasPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/settings.tsx b/apps/studio/pages/project/[ref]/database/settings.tsx index 45260e8a107..4c90c843d39 100644 --- a/apps/studio/pages/project/[ref]/database/settings.tsx +++ b/apps/studio/pages/project/[ref]/database/settings.tsx @@ -76,7 +76,7 @@ const ProjectSettings: NextPageWithLayout = () => { ProjectSettings.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index 3401092bacc..94b4a029eda 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -1,5 +1,3 @@ -import { ChevronRight } from 'lucide-react' - import { useParams } from 'common' import { ColumnList } from 'components/interfaces/Database/Tables/ColumnList' import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs' @@ -11,6 +9,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { ChevronRight } from 'lucide-react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import type { NextPageWithLayout } from 'types' @@ -68,7 +67,7 @@ const DatabaseTables: NextPageWithLayout = () => { DatabaseTables.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index 40c0367c00a..be5c94888f3 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -1,6 +1,4 @@ import { PostgresTable } from '@supabase/postgres-meta' -import { useState } from 'react' - import { useParams } from 'common' import { TableList } from 'components/interfaces/Database/Tables/TableList' import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs' @@ -8,6 +6,7 @@ import { SidePanelEditor } from 'components/interfaces/TableGridEditor/SidePanel import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' import { Entity, isTableLike, postgresTableToEntity } from 'data/table-editor/table-editor-types' +import { useState } from 'react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import type { NextPageWithLayout } from 'types' @@ -75,7 +74,7 @@ const DatabaseTables: NextPageWithLayout = () => { DatabaseTables.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/database/triggers/data.tsx b/apps/studio/pages/project/[ref]/database/triggers/data.tsx index 11d2a3b31bd..2d947170ab9 100644 --- a/apps/studio/pages/project/[ref]/database/triggers/data.tsx +++ b/apps/studio/pages/project/[ref]/database/triggers/data.tsx @@ -1,5 +1,5 @@ import { TriggersList } from 'components/interfaces/Database/Triggers/TriggersList/TriggersList' -import DatabaseTriggersLayout from 'components/layouts/DatabaseLayout/DatabaseTriggersLayout' +import { DatabaseTriggersLayout } from 'components/layouts/DatabaseLayout/DatabaseTriggersLayout' import { DefaultLayout } from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' import { PageContainer } from 'ui-patterns/PageContainer' diff --git a/apps/studio/pages/project/[ref]/database/triggers/event.tsx b/apps/studio/pages/project/[ref]/database/triggers/event.tsx index 3f9a07af2dc..0bb86a88cb9 100644 --- a/apps/studio/pages/project/[ref]/database/triggers/event.tsx +++ b/apps/studio/pages/project/[ref]/database/triggers/event.tsx @@ -1,5 +1,5 @@ import { EventTriggersList } from 'components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList' -import DatabaseTriggersLayout from 'components/layouts/DatabaseLayout/DatabaseTriggersLayout' +import { DatabaseTriggersLayout } from 'components/layouts/DatabaseLayout/DatabaseTriggersLayout' import { DefaultLayout } from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' import { PageContainer } from 'ui-patterns/PageContainer' diff --git a/apps/studio/pages/project/[ref]/database/types.tsx b/apps/studio/pages/project/[ref]/database/types.tsx index edb6bec6861..ddee121bc4f 100644 --- a/apps/studio/pages/project/[ref]/database/types.tsx +++ b/apps/studio/pages/project/[ref]/database/types.tsx @@ -38,7 +38,7 @@ const DatabaseEnumeratedTypes: NextPageWithLayout = () => { DatabaseEnumeratedTypes.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index a8de7c3f472..1947ece5ee4 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -244,7 +244,7 @@ const CodePage = () => { CodePage.getLayout = (page: React.ReactNode) => { return ( - {page} + {page} ) } diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx index 44850089399..d64f95d72d3 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx @@ -7,7 +7,7 @@ const PageLayout: NextPageWithLayout = () => PageLayout.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 62863ee2593..dc2d55a6d0f 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -431,7 +431,7 @@ const PageLayout: NextPageWithLayout = () => { PageLayout.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx index d585211d813..d1aebb10ac1 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx @@ -28,7 +28,7 @@ export const LogPage: NextPageWithLayout = () => { LogPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx index 1861a026530..6f96e611922 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx @@ -29,7 +29,7 @@ export const LogPage: NextPageWithLayout = () => { LogPage.getLayout = (page) => ( - {page} + {page} ) diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 9737243ae13..d3cd3f47005 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -178,7 +178,7 @@ const EdgeFunctionsPage: NextPageWithLayout = () => { EdgeFunctionsPage.getLayout = (page: React.ReactElement) => { return ( - +
diff --git a/apps/studio/pages/project/[ref]/functions/new.tsx b/apps/studio/pages/project/[ref]/functions/new.tsx index a6291c202db..42d0b7bd125 100644 --- a/apps/studio/pages/project/[ref]/functions/new.tsx +++ b/apps/studio/pages/project/[ref]/functions/new.tsx @@ -423,7 +423,7 @@ const NewFunctionPage = () => { NewFunctionPage.getLayout = (page: React.ReactNode) => { return ( - {page} + {page} ) } diff --git a/apps/studio/pages/project/[ref]/functions/secrets.tsx b/apps/studio/pages/project/[ref]/functions/secrets.tsx index 6ee8617a4a5..8794a94ea0e 100644 --- a/apps/studio/pages/project/[ref]/functions/secrets.tsx +++ b/apps/studio/pages/project/[ref]/functions/secrets.tsx @@ -29,7 +29,7 @@ const SecretsPage: NextPageWithLayout = () => { SecretsPage.getLayout = (page) => { return ( - +
From c38ff7134339f3751ac5b0237cd56fa444115fe6 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:17:39 +1100 Subject: [PATCH 26/36] tooltip shorten --- apps/studio/components/ui/PartnerIcon.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/ui/PartnerIcon.tsx b/apps/studio/components/ui/PartnerIcon.tsx index ec66d2f1e06..65ed214f766 100644 --- a/apps/studio/components/ui/PartnerIcon.tsx +++ b/apps/studio/components/ui/PartnerIcon.tsx @@ -1,6 +1,7 @@ import { MANAGED_BY } from 'lib/constants/infrastructure' import type { Organization } from 'types' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + import { PARTNER_TO_NAME } from './PartnerManagedResource' interface PartnerIconProps { @@ -91,7 +92,7 @@ function PartnerIcon({ ) } - const defaultTooltipText = `This organization is managed by ${PARTNER_TO_NAME[organization.managed_by]}` + const defaultTooltipText = `Organization managed by ${PARTNER_TO_NAME[organization.managed_by]}` return ( From e19d47d75ce6334ed761c080939481257b1b273a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:55:42 +1100 Subject: [PATCH 27/36] first pass --- .../interfaces/App/AppBannerWrapper.tsx | 2 + .../ProjectOAuthIntegrationsBanner.test.tsx | 120 ++++++++++++++++++ .../ProjectOAuthIntegrationsBanner.tsx | 49 +++++++ ...ojectOAuthIntegrationsBanner.utils.test.ts | 64 ++++++++++ .../ProjectOAuthIntegrationsBanner.utils.ts | 43 +++++++ 5 files changed, 278 insertions(+) create mode 100644 apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx create mode 100644 apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx create mode 100644 apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts create mode 100644 apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 215efbfa17d..8570638f92d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,6 +1,7 @@ import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' +import { ProjectOAuthIntegrationsBanner } from 'components/layouts/AppLayout/ProjectOAuthIntegrationsBanner' import { StatusPageBanner } from 'components/layouts/AppLayout/StatusPageBanner' import { PropsWithChildren } from 'react' @@ -16,6 +17,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { {showNoticeBanner && } + {clockSkewBanner && }
{children} diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx new file mode 100644 index 00000000000..a7e0081bc4c --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx @@ -0,0 +1,120 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import { render } from 'tests/helpers' +import { routerMock } from 'tests/lib/route-mock' +import { ProjectOAuthIntegrationsBanner } from './ProjectOAuthIntegrationsBanner' + +const mockUseSelectedOrganizationQuery = vi.fn() +const mockUseAuthorizedAppsQuery = vi.fn() + +vi.mock('hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => mockUseSelectedOrganizationQuery(), +})) + +vi.mock('data/oauth/authorized-apps-query', () => ({ + useAuthorizedAppsQuery: (...args: unknown[]) => mockUseAuthorizedAppsQuery(...args), +})) + +const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ + id: 'authorized-app-1', + app_id: 'oauth-app-1', + icon: null, + name: 'Lovable', + website: 'https://example.com', + created_by: 'user-1', + authorized_at: new Date().toISOString(), + ...overrides, +}) + +describe('ProjectOAuthIntegrationsBanner', () => { + beforeEach(() => { + vi.clearAllMocks() + routerMock.setCurrentUrl('/project/default/editor') + + mockUseSelectedOrganizationQuery.mockReturnValue({ + data: { slug: 'acme' }, + }) + + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [createAuthorizedApp({ name: 'Lovable' })], + isError: false, + }) + }) + + it('renders on project routes when authorized apps are available', () => { + render() + + expect(screen.getByText('Connected to Lovable')).toBeInTheDocument() + expect( + screen.getByText('Dashboard changes can affect connected tools in this organization.') + ).toBeInTheDocument() + + const manageLink = screen.getByRole('link', { name: 'Manage' }) + expect(manageLink).toHaveAttribute('href', '/org/acme/apps') + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: true }) + ) + }) + + it('renders a condensed title when multiple apps are authorized', () => { + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [ + createAuthorizedApp({ id: '1', app_id: '1', name: 'Lovable' }), + createAuthorizedApp({ id: '2', app_id: '2', name: 'Bolt' }), + createAuthorizedApp({ id: '3', app_id: '3', name: 'Replit' }), + ], + isError: false, + }) + + render() + + expect(screen.getByText('Connected to Lovable, Bolt, and 1 other')).toBeInTheDocument() + }) + + it('does not render on non-project routes', () => { + routerMock.setCurrentUrl('/org/acme/apps') + + render() + + expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: false }) + ) + }) + + it('does not render when there are no authorized apps', () => { + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [], + isError: false, + }) + + render() + + expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + }) + + it('does not render when app lookup fails', () => { + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [createAuthorizedApp({ name: 'Lovable' })], + isError: true, + }) + + render() + + expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + }) + + it('does not render when organization context is unavailable', () => { + mockUseSelectedOrganizationQuery.mockReturnValue({ + data: undefined, + }) + + render() + + expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx new file mode 100644 index 00000000000..d39921e0a03 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx @@ -0,0 +1,49 @@ +import { Settings2 } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' + +import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { + getAuthorizedAppNames, + getConnectedAppsTitle, + isProjectRoute, +} from './ProjectOAuthIntegrationsBanner.utils' + +const BANNER_DESCRIPTION = + 'Dashboard changes can affect connected tools in this organization.' + +export const ProjectOAuthIntegrationsBanner = () => { + const router = useRouter() + const { data: selectedOrganization } = useSelectedOrganizationQuery() + + const organizationSlug = selectedOrganization?.slug + const showProjectBanner = isProjectRoute({ pathname: router.pathname, asPath: router.asPath }) + + const { data: authorizedApps = [], isError } = useAuthorizedAppsQuery( + { slug: organizationSlug }, + { enabled: showProjectBanner && !!organizationSlug } + ) + + if (!showProjectBanner || !organizationSlug || isError) return null + + if (authorizedApps.length === 0) return null + + const appNames = getAuthorizedAppNames(authorizedApps) + + return ( + +
+ {getConnectedAppsTitle(appNames)} + {BANNER_DESCRIPTION} +
+ +
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts new file mode 100644 index 00000000000..ce4bcde8a1b --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import type { AuthorizedApp } from 'data/oauth/authorized-apps-query' +import { + getAuthorizedAppNames, + getConnectedAppsTitle, + isProjectRoute, +} from './ProjectOAuthIntegrationsBanner.utils' + +const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ + id: 'auth-app-id', + app_id: 'oauth-app-id', + icon: null, + name: 'Lovable', + website: 'https://example.com', + created_by: 'user-1', + authorized_at: new Date().toISOString(), + ...overrides, +}) + +describe('ProjectOAuthIntegrationsBanner utils', () => { + describe('isProjectRoute', () => { + it('returns true for project route template pathnames', () => { + expect(isProjectRoute({ pathname: '/project/[ref]/database/tables' })).toBe(true) + }) + + it('returns true for project URLs from asPath', () => { + expect(isProjectRoute({ pathname: '/unknown', asPath: '/project/default/functions' })).toBe( + true + ) + }) + + it('returns false for non-project routes', () => { + expect(isProjectRoute({ pathname: '/org/[slug]/apps', asPath: '/org/default/apps' })).toBe( + false + ) + }) + }) + + describe('getAuthorizedAppNames', () => { + it('returns unique display-safe app names', () => { + const appNames = getAuthorizedAppNames([ + createAuthorizedApp({ name: ' Lovable ' }), + createAuthorizedApp({ name: 'lovable' }), + createAuthorizedApp({ name: 'Bolt.new' }), + createAuthorizedApp({ name: ' ' }), + ]) + + expect(appNames).toEqual(['Lovable', 'Bolt.new']) + }) + }) + + describe('getConnectedAppsTitle', () => { + it('renders title for a single app', () => { + expect(getConnectedAppsTitle(['Lovable'])).toBe('Connected to Lovable') + }) + + it('renders a condensed title for multiple apps', () => { + expect(getConnectedAppsTitle(['Lovable', 'Bolt', 'Replit'])).toBe( + 'Connected to Lovable, Bolt, and 1 other' + ) + }) + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts new file mode 100644 index 00000000000..74708c91a94 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts @@ -0,0 +1,43 @@ +import type { AuthorizedAppsData } from 'data/oauth/authorized-apps-query' + +const PROJECT_ROUTE_PREFIX = '/project/' +const PROJECT_ROUTE_TEMPLATE = '/project/[ref]' +const MAX_APPS_IN_TITLE = 2 + +const sanitizeAppName = (name: string) => name.trim().replace(/\s+/g, ' ') + +export const isProjectRoute = ({ + pathname, + asPath, +}: { + pathname?: string + asPath?: string +}) => Boolean(pathname?.startsWith(PROJECT_ROUTE_TEMPLATE) || asPath?.startsWith(PROJECT_ROUTE_PREFIX)) + +export const getAuthorizedAppNames = (authorizedApps: AuthorizedAppsData = []) => { + const uniqueNames = new Map() + + authorizedApps.forEach((app) => { + const safeName = sanitizeAppName(app.name) + if (!safeName) return + + const normalizedName = safeName.toLowerCase() + if (!uniqueNames.has(normalizedName)) { + uniqueNames.set(normalizedName, safeName) + } + }) + + return Array.from(uniqueNames.values()) +} + +export const getConnectedAppsTitle = (appNames: string[]) => { + if (appNames.length === 0) return 'Connected to external apps' + if (appNames.length === 1) return `Connected to ${appNames[0]}` + if (appNames.length === 2) return `Connected to ${appNames[0]} and ${appNames[1]}` + + const [firstName, secondName] = appNames + const additionalApps = appNames.length - MAX_APPS_IN_TITLE + const suffix = additionalApps === 1 ? 'other' : 'others' + + return `Connected to ${firstName}, ${secondName}, and ${additionalApps} ${suffix}` +} From 99474de13d1cfec2ba930f0d1c771778d3857470 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:30:35 +1100 Subject: [PATCH 28/36] below header --- .../interfaces/App/AppBannerWrapper.tsx | 2 - .../DEPR-324-OAUTH-BANNER-MOCKING.md | 107 ++++++++++++++++++ .../ProjectOAuthIntegrationsBanner.test.tsx | 101 ++++++++++++++++- .../ProjectOAuthIntegrationsBanner.tsx | 63 ++++++++--- ...ojectOAuthIntegrationsBanner.utils.test.ts | 55 ++++++++- .../ProjectOAuthIntegrationsBanner.utils.ts | 102 ++++++++++++++++- .../layouts/ProjectLayout/index.tsx | 2 + packages/common/constants/local-storage.ts | 1 + 8 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 8570638f92d..215efbfa17d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,7 +1,6 @@ import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' -import { ProjectOAuthIntegrationsBanner } from 'components/layouts/AppLayout/ProjectOAuthIntegrationsBanner' import { StatusPageBanner } from 'components/layouts/AppLayout/StatusPageBanner' import { PropsWithChildren } from 'react' @@ -17,7 +16,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { {showNoticeBanner && } - {clockSkewBanner && }
{children} diff --git a/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md b/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md new file mode 100644 index 00000000000..390b7ad80d2 --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md @@ -0,0 +1,107 @@ +# DEPR-324 OAuth Banner Mocking + Data Granularity + +This marker file documents how to test the `ProjectOAuthIntegrationsBanner` before PR. + +## Placement + +- Mounted inside project content (under the project header context) via `ProjectLayout`, not in global `AppBannerWrapper`. +- This keeps the signal scoped to project surfaces. + +## 1) Mock controls (non-prod only) + +Mocking is enabled when `NEXT_PUBLIC_ENVIRONMENT !== "prod"`. + +- URL query param: `oauthBannerMock` +- Local storage key: `supabase-oauth-integrations-banner-mock` + +### A. Fast one-off mock via URL + +Open any project route with: + +```text +/project//editor?oauthBannerMock=Lovable,Bolt,Figma +``` + +Optional icon per app: + +```text +/project//editor?oauthBannerMock=Figma|https://cdn.example.com/figma.png,Lovable +``` + +### B. Persistent mock via localStorage + +In browser devtools console: + +```js +localStorage.setItem( + 'supabase-oauth-integrations-banner-mock', + JSON.stringify('Lovable,Bolt,Figma') +) +location.reload() +``` + +### C. Force real data (ignore local mock) + +```text +?oauthBannerMock=off +``` + +`off`, `none`, `false`, and `0` disable mocking. + +## 2) How to clear mock state + +```js +localStorage.removeItem('supabase-oauth-integrations-banner-mock') +location.reload() +``` + +## 3) How to extract real authorized app payload (before PR) + +### A. Network tab + +- Open org OAuth apps page: `/org//apps` +- Find request: + - `GET /platform/organizations//oauth/apps?type=authorized` +- Inspect response JSON. + +### B. Console fetch (same browser session) + +```js +const slug = '' +fetch(`/platform/organizations/${slug}/oauth/apps?type=authorized`, { + credentials: 'include', +}) + .then((res) => res.json()) + .then((payload) => { + console.table( + payload.map((app) => ({ + id: app.id, + app_id: app.app_id, + name: app.name, + icon: app.icon, + website: app.website, + created_by: app.created_by, + authorized_at: app.authorized_at, + })) + ) + return payload + }) +``` + +## 4) Data granularity we currently have + +From `authorized-apps-query`, each authorized app includes: + +- `id` +- `app_id` +- `name` +- `icon` (nullable URL) +- `website` +- `created_by` +- `authorized_at` + +Important constraints: + +- We do **not** have a normalized provider enum (for example, no explicit `provider: "figma"`). +- We can still show a logo if `icon` is present. +- If `icon` is null, fallback options are name-based initials or an internal name-to-logo mapping. diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx index a7e0081bc4c..1b4d30213c9 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx @@ -8,6 +8,7 @@ import { ProjectOAuthIntegrationsBanner } from './ProjectOAuthIntegrationsBanner const mockUseSelectedOrganizationQuery = vi.fn() const mockUseAuthorizedAppsQuery = vi.fn() +const mockUseLocalStorageQuery = vi.fn() vi.mock('hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => mockUseSelectedOrganizationQuery(), @@ -17,6 +18,10 @@ vi.mock('data/oauth/authorized-apps-query', () => ({ useAuthorizedAppsQuery: (...args: unknown[]) => mockUseAuthorizedAppsQuery(...args), })) +vi.mock('hooks/misc/useLocalStorage', () => ({ + useLocalStorageQuery: (...args: unknown[]) => mockUseLocalStorageQuery(...args), +})) + const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ id: 'authorized-app-1', app_id: 'oauth-app-1', @@ -36,6 +41,11 @@ describe('ProjectOAuthIntegrationsBanner', () => { mockUseSelectedOrganizationQuery.mockReturnValue({ data: { slug: 'acme' }, }) + mockUseLocalStorageQuery.mockReturnValue([ + '', + vi.fn(), + { isSuccess: true, isLoading: false, isError: false, error: null }, + ]) mockUseAuthorizedAppsQuery.mockReturnValue({ data: [createAuthorizedApp({ name: 'Lovable' })], @@ -46,9 +56,10 @@ describe('ProjectOAuthIntegrationsBanner', () => { it('renders on project routes when authorized apps are available', () => { render() - expect(screen.getByText('Connected to Lovable')).toBeInTheDocument() expect( - screen.getByText('Dashboard changes can affect connected tools in this organization.') + screen.getByText( + 'This project is connected to Lovable. Dashboard changes can affect connected tools in this organization.' + ) ).toBeInTheDocument() const manageLink = screen.getByRole('link', { name: 'Manage' }) @@ -71,7 +82,11 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() - expect(screen.getByText('Connected to Lovable, Bolt, and 1 other')).toBeInTheDocument() + expect( + screen.getByText( + 'This project is connected to Lovable, Bolt, and 1 other app. Dashboard changes can affect connected tools in this organization.' + ) + ).toBeInTheDocument() }) it('does not render on non-project routes', () => { @@ -117,4 +132,84 @@ describe('ProjectOAuthIntegrationsBanner', () => { expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() }) + + it('uses query-param mock data on project routes', () => { + routerMock.setCurrentUrl('/project/default/editor?oauthBannerMock=Lovable,Bolt,Figma') + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [], + isError: false, + }) + + render() + + expect( + screen.getByText( + 'This project is connected to Lovable, Bolt, and 1 other app. Dashboard changes can affect connected tools in this organization.' + ) + ).toBeInTheDocument() + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: false }) + ) + }) + + it('uses local-storage mock data when query param is absent', () => { + mockUseLocalStorageQuery.mockReturnValue([ + 'Lovable,Bolt', + vi.fn(), + { isSuccess: true, isLoading: false, isError: false, error: null }, + ]) + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [], + isError: false, + }) + + render() + + expect( + screen.getByText( + 'This project is connected to Lovable and Bolt. Dashboard changes can affect connected tools in this organization.' + ) + ).toBeInTheDocument() + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: false }) + ) + }) + + it('supports forcing real data with query-param off even when local mock exists', () => { + routerMock.setCurrentUrl('/project/default/editor?oauthBannerMock=off') + mockUseLocalStorageQuery.mockReturnValue([ + 'Lovable,Bolt', + vi.fn(), + { isSuccess: true, isLoading: false, isError: false, error: null }, + ]) + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [createAuthorizedApp({ name: 'Replit' })], + isError: false, + }) + + render() + + expect( + screen.getByText( + 'This project is connected to Replit. Dashboard changes can affect connected tools in this organization.' + ) + ).toBeInTheDocument() + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: true }) + ) + }) + + it('falls back to plug icon when no app icon is provided', () => { + mockUseAuthorizedAppsQuery.mockReturnValue({ + data: [createAuthorizedApp({ name: 'Lovable', icon: null })], + isError: false, + }) + + const { container } = render() + + expect(container.querySelector('div[style*="background-image: none"] svg')).toBeInTheDocument() + }) }) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx index d39921e0a03..1dc5a1c2f0a 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx @@ -1,45 +1,72 @@ -import { Settings2 } from 'lucide-react' +import { LOCAL_STORAGE_KEYS } from 'common' +import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Plug, Settings2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import { Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' -import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { - getAuthorizedAppNames, - getConnectedAppsTitle, + getAuthorizedAppDisplayData, + getConnectedAppsSentence, + getMockAuthorizedApps, isProjectRoute, } from './ProjectOAuthIntegrationsBanner.utils' -const BANNER_DESCRIPTION = - 'Dashboard changes can affect connected tools in this organization.' +const OAUTH_BANNER_MOCK_QUERY_PARAM = 'oauthBannerMock' export const ProjectOAuthIntegrationsBanner = () => { const router = useRouter() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const [mockAppsFromLocalStorage] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.OAUTH_INTEGRATIONS_BANNER_MOCK, + '' + ) const organizationSlug = selectedOrganization?.slug const showProjectBanner = isProjectRoute({ pathname: router.pathname, asPath: router.asPath }) - - const { data: authorizedApps = [], isError } = useAuthorizedAppsQuery( - { slug: organizationSlug }, - { enabled: showProjectBanner && !!organizationSlug } + const isMockingSupported = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + const mockAppsFromQuery = router.query[OAUTH_BANNER_MOCK_QUERY_PARAM] + const mockValue = + isMockingSupported && typeof mockAppsFromQuery !== 'undefined' + ? mockAppsFromQuery + : mockAppsFromLocalStorage + const { apps: mockedAuthorizedApps, isMocked } = getMockAuthorizedApps( + isMockingSupported ? mockValue : '' ) - if (!showProjectBanner || !organizationSlug || isError) return null + const { data: authorizedAppsData = [], isError } = useAuthorizedAppsQuery( + { slug: organizationSlug }, + { enabled: showProjectBanner && !!organizationSlug && !isMocked } + ) + + if (!showProjectBanner || !organizationSlug) return null + if (!isMocked && isError) return null + + const authorizedApps = isMocked ? mockedAuthorizedApps : authorizedAppsData if (authorizedApps.length === 0) return null - const appNames = getAuthorizedAppNames(authorizedApps) + const displayApps = getAuthorizedAppDisplayData(authorizedApps) + const appNames = displayApps.map((app) => app.name) + const appIcon = displayApps.find((app) => app.icon)?.icon ?? null return ( -
- {getConnectedAppsTitle(appNames)} - {BANNER_DESCRIPTION} +
+
+ {!appIcon && } +
+ + {getConnectedAppsSentence(appNames)} +
diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts index dff614bd062..9629354958e 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts @@ -82,7 +82,7 @@ describe('ProjectOAuthIntegrationsBanner utils', () => { describe('getConnectedAppsSentence', () => { it('renders single-line sentence copy', () => { expect(getConnectedAppsSentence(['Lovable'])).toBe( - 'This project is connected to Lovable. Dashboard changes can affect connected tools in this organization.' + 'This project is integrated with Lovable and dashboard changes may impact this project' ) }) }) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts index 5ea0c7c7ce3..cf27c8ad80c 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts @@ -13,13 +13,8 @@ const getStringValue = (value?: string | string[] | null) => { return Array.isArray(value) ? value.join(',') : value } -export const isProjectRoute = ({ - pathname, - asPath, -}: { - pathname?: string - asPath?: string -}) => Boolean(pathname?.startsWith(PROJECT_ROUTE_TEMPLATE) || asPath?.startsWith(PROJECT_ROUTE_PREFIX)) +export const isProjectRoute = ({ pathname, asPath }: { pathname?: string; asPath?: string }) => + Boolean(pathname?.startsWith(PROJECT_ROUTE_TEMPLATE) || asPath?.startsWith(PROJECT_ROUTE_PREFIX)) export const getAuthorizedAppNames = (authorizedApps: AuthorizedAppsData = []) => { return getAuthorizedAppDisplayData(authorizedApps).map((app) => app.name) @@ -49,20 +44,29 @@ export const getAuthorizedAppDisplayData = (authorizedApps: AuthorizedAppsData = } export const getConnectedAppsTitle = (appNames: string[]) => { - if (appNames.length === 0) return 'Connected to external apps' - if (appNames.length === 1) return `Connected to ${appNames[0]}` - if (appNames.length === 2) return `Connected to ${appNames[0]} and ${appNames[1]}` + return `Connected to ${getConnectedAppsLabel(appNames)}` +} + +export const getConnectedAppsLabel = (appNames: string[]) => { + if (appNames.length === 0) return 'external apps' + if (appNames.length === 1) return appNames[0] + if (appNames.length === 2) return `${appNames[0]} and ${appNames[1]}` const [firstName, secondName] = appNames const additionalApps = appNames.length - MAX_APPS_IN_TITLE const suffix = additionalApps === 1 ? 'other app' : 'other apps' - return `Connected to ${firstName}, ${secondName}, and ${additionalApps} ${suffix}` + return `${firstName}, ${secondName}, and ${additionalApps} ${suffix}` } export const getConnectedAppsSentence = (appNames: string[]) => { - const connectedAppsTitle = getConnectedAppsTitle(appNames).replace('Connected to ', '') - return `This project is connected to ${connectedAppsTitle}. Dashboard changes can affect connected tools in this organization.` + const connectedAppsLabel = getConnectedAppsLabel(appNames) + return `This project is connected to ${connectedAppsLabel}` +} + +export const getConnectedAppsDescription = (appNames: string[]) => { + const connectedAppsLabel = getConnectedAppsLabel(appNames) + return `Changes made here may affect how your project works in ${connectedAppsLabel}.` } export const shouldDisableMockAuthorizedApps = (value?: string | string[] | null) => { From 7afb8a44c36f17dd6034ee453fb966c35e301e1c Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:59:16 +1100 Subject: [PATCH 30/36] restore --- apps/studio/components/interfaces/App/AppBannerWrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 0cae207eaa1..215efbfa17d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -14,9 +14,9 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
- {true && } + {showNoticeBanner && } - {true && } + {clockSkewBanner && }
{children}
From 9b1509b0284d1d10acce2f4335d5fef55ab3bca4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:17:39 +1100 Subject: [PATCH 31/36] cleanup --- .../ProjectOAuthIntegrationsBanner.test.tsx | 96 ++----------------- .../ProjectOAuthIntegrationsBanner.tsx | 31 +----- ...ojectOAuthIntegrationsBanner.utils.test.ts | 60 +++--------- .../ProjectOAuthIntegrationsBanner.utils.ts | 92 +----------------- packages/common/constants/local-storage.ts | 1 - 5 files changed, 32 insertions(+), 248 deletions(-) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx index b67698f31a9..7534edbe5ac 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx @@ -8,7 +8,6 @@ import { ProjectOAuthIntegrationsBanner } from './ProjectOAuthIntegrationsBanner const mockUseSelectedOrganizationQuery = vi.fn() const mockUseAuthorizedAppsQuery = vi.fn() -const mockUseLocalStorageQuery = vi.fn() vi.mock('hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => mockUseSelectedOrganizationQuery(), @@ -18,10 +17,6 @@ vi.mock('data/oauth/authorized-apps-query', () => ({ useAuthorizedAppsQuery: (...args: unknown[]) => mockUseAuthorizedAppsQuery(...args), })) -vi.mock('hooks/misc/useLocalStorage', () => ({ - useLocalStorageQuery: (...args: unknown[]) => mockUseLocalStorageQuery(...args), -})) - const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ id: 'authorized-app-1', app_id: 'oauth-app-1', @@ -41,11 +36,6 @@ describe('ProjectOAuthIntegrationsBanner', () => { mockUseSelectedOrganizationQuery.mockReturnValue({ data: { slug: 'acme' }, }) - mockUseLocalStorageQuery.mockReturnValue([ - '', - vi.fn(), - { isSuccess: true, isLoading: false, isError: false, error: null }, - ]) mockUseAuthorizedAppsQuery.mockReturnValue({ data: [createAuthorizedApp({ name: 'Lovable' })], @@ -56,10 +46,9 @@ describe('ProjectOAuthIntegrationsBanner', () => { it('renders on project routes when authorized apps are available', () => { render() + expect(screen.getByText('This project is connected to Lovable')).toBeInTheDocument() expect( - screen.getByText( - 'This project is integrated with Lovable and dashboard changes may impact this project' - ) + screen.getByText('Changes made here may affect how your project works in Lovable.') ).toBeInTheDocument() const manageLink = screen.getByRole('link', { name: 'Manage' }) @@ -82,9 +71,11 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() + expect(screen.getByText('This project is connected to Lovable, Bolt, and 1 other app')) + .toBeInTheDocument() expect( screen.getByText( - 'This project is integrated with Lovable, Bolt, and 1 other app and dashboard changes may impact this project' + 'Changes made here may affect how your project works in Lovable, Bolt, and 1 other app.' ) ).toBeInTheDocument() }) @@ -94,7 +85,7 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() - expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( { slug: 'acme' }, expect.objectContaining({ enabled: false }) @@ -109,7 +100,7 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() - expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() }) it('does not render when app lookup fails', () => { @@ -120,7 +111,7 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() - expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() + expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() }) it('does not render when organization context is unavailable', () => { @@ -130,76 +121,7 @@ describe('ProjectOAuthIntegrationsBanner', () => { render() - expect(screen.queryByText(/Connected to/)).not.toBeInTheDocument() - }) - - it('uses query-param mock data on project routes', () => { - routerMock.setCurrentUrl('/project/default/editor?oauthBannerMock=Lovable,Bolt,Figma') - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [], - isError: false, - }) - - render() - - expect( - screen.getByText( - 'This project is integrated with Lovable, Bolt, and 1 other app and dashboard changes may impact this project' - ) - ).toBeInTheDocument() - expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( - { slug: 'acme' }, - expect.objectContaining({ enabled: false }) - ) - }) - - it('uses local-storage mock data when query param is absent', () => { - mockUseLocalStorageQuery.mockReturnValue([ - 'Lovable,Bolt', - vi.fn(), - { isSuccess: true, isLoading: false, isError: false, error: null }, - ]) - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [], - isError: false, - }) - - render() - - expect( - screen.getByText( - 'This project is integrated with Lovable and Bolt and dashboard changes may impact this project' - ) - ).toBeInTheDocument() - expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( - { slug: 'acme' }, - expect.objectContaining({ enabled: false }) - ) - }) - - it('supports forcing real data with query-param off even when local mock exists', () => { - routerMock.setCurrentUrl('/project/default/editor?oauthBannerMock=off') - mockUseLocalStorageQuery.mockReturnValue([ - 'Lovable,Bolt', - vi.fn(), - { isSuccess: true, isLoading: false, isError: false, error: null }, - ]) - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [createAuthorizedApp({ name: 'Replit' })], - isError: false, - }) - - render() - - expect( - screen.getByText( - 'This project is integrated with Replit and dashboard changes may impact this project' - ) - ).toBeInTheDocument() - expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( - { slug: 'acme' }, - expect.objectContaining({ enabled: true }) - ) + expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() }) it('falls back to plug icon when no app icon is provided', () => { diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx index 95537ce3ddd..b9e92dee3fc 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx @@ -1,6 +1,4 @@ -import { LOCAL_STORAGE_KEYS } from 'common' import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Plug, Settings2 } from 'lucide-react' import Link from 'next/link' @@ -10,42 +8,23 @@ import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, cn import { getAuthorizedAppDisplayData, getConnectedAppsDescription, - getConnectedAppsSentence, - getMockAuthorizedApps, + getConnectedAppsTitle, isProjectRoute, } from './ProjectOAuthIntegrationsBanner.utils' -const OAUTH_BANNER_MOCK_QUERY_PARAM = 'oauthBannerMock' - export const ProjectOAuthIntegrationsBanner = () => { const router = useRouter() const { data: selectedOrganization } = useSelectedOrganizationQuery() - const [mockAppsFromLocalStorage] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.OAUTH_INTEGRATIONS_BANNER_MOCK, - '' - ) const organizationSlug = selectedOrganization?.slug const showProjectBanner = isProjectRoute({ pathname: router.pathname, asPath: router.asPath }) - const isMockingSupported = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' - const mockAppsFromQuery = router.query[OAUTH_BANNER_MOCK_QUERY_PARAM] - const mockValue = - isMockingSupported && typeof mockAppsFromQuery !== 'undefined' - ? mockAppsFromQuery - : mockAppsFromLocalStorage - const { apps: mockedAuthorizedApps, isMocked } = getMockAuthorizedApps( - isMockingSupported ? mockValue : '' - ) - const { data: authorizedAppsData = [], isError } = useAuthorizedAppsQuery( + const { data: authorizedApps = [], isError } = useAuthorizedAppsQuery( { slug: organizationSlug }, - { enabled: showProjectBanner && !!organizationSlug && !isMocked } + { enabled: showProjectBanner && !!organizationSlug } ) - if (!showProjectBanner || !organizationSlug) return null - if (!isMocked && isError) return null - - const authorizedApps = isMocked ? mockedAuthorizedApps : authorizedAppsData + if (!showProjectBanner || !organizationSlug || isError) return null if (authorizedApps.length === 0) return null @@ -71,7 +50,7 @@ export const ProjectOAuthIntegrationsBanner = () => { {!appIcon && }
- {getConnectedAppsSentence(appNames)} + {getConnectedAppsTitle(appNames)} {getConnectedAppsDescription(appNames)} diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts index 9629354958e..05690f83c95 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts @@ -3,12 +3,9 @@ import { describe, expect, it } from 'vitest' import type { AuthorizedApp } from 'data/oauth/authorized-apps-query' import { getAuthorizedAppDisplayData, - getAuthorizedAppNames, - getConnectedAppsSentence, + getConnectedAppsDescription, getConnectedAppsTitle, - getMockAuthorizedApps, isProjectRoute, - shouldDisableMockAuthorizedApps, } from './ProjectOAuthIntegrationsBanner.utils' const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ @@ -41,20 +38,21 @@ describe('ProjectOAuthIntegrationsBanner utils', () => { }) }) - describe('getAuthorizedAppNames', () => { + describe('getAuthorizedAppDisplayData', () => { it('returns unique display-safe app names', () => { - const appNames = getAuthorizedAppNames([ + const displayApps = getAuthorizedAppDisplayData([ createAuthorizedApp({ name: ' Lovable ' }), createAuthorizedApp({ name: 'lovable' }), createAuthorizedApp({ name: 'Bolt.new' }), createAuthorizedApp({ name: ' ' }), ]) - expect(appNames).toEqual(['Lovable', 'Bolt.new']) + expect(displayApps).toEqual([ + { name: 'Lovable', icon: null }, + { name: 'Bolt.new', icon: null }, + ]) }) - }) - describe('getAuthorizedAppDisplayData', () => { it('backfills icon from duplicate entries', () => { const displayApps = getAuthorizedAppDisplayData([ createAuthorizedApp({ name: 'Figma', icon: null }), @@ -67,51 +65,21 @@ describe('ProjectOAuthIntegrationsBanner utils', () => { }) }) - describe('getConnectedAppsTitle', () => { + describe('copy helpers', () => { it('renders title for a single app', () => { - expect(getConnectedAppsTitle(['Lovable'])).toBe('Connected to Lovable') + expect(getConnectedAppsTitle(['Lovable'])).toBe('This project is connected to Lovable') }) - it('renders a condensed title for multiple apps', () => { + it('renders condensed title for multiple apps', () => { expect(getConnectedAppsTitle(['Lovable', 'Bolt', 'Replit'])).toBe( - 'Connected to Lovable, Bolt, and 1 other app' + 'This project is connected to Lovable, Bolt, and 1 other app' ) }) - }) - describe('getConnectedAppsSentence', () => { - it('renders single-line sentence copy', () => { - expect(getConnectedAppsSentence(['Lovable'])).toBe( - 'This project is integrated with Lovable and dashboard changes may impact this project' + it('renders a matching description', () => { + expect(getConnectedAppsDescription(['Lovable'])).toBe( + 'Changes made here may affect how your project works in Lovable.' ) }) }) - - describe('mock parsing', () => { - it('parses comma-separated app names into mock apps', () => { - const { apps, isMocked } = getMockAuthorizedApps('Lovable,Bolt,Figma') - - expect(isMocked).toBe(true) - expect(apps.map((app) => app.name)).toEqual(['Lovable', 'Bolt', 'Figma']) - expect(apps.every((app) => app.created_by === 'mock-oauth')).toBe(true) - }) - - it('supports optional icon in mock entries', () => { - const { apps, isMocked } = getMockAuthorizedApps('Figma|https://cdn.example.com/figma.png') - - expect(isMocked).toBe(true) - expect(apps[0]).toMatchObject({ - name: 'Figma', - icon: 'https://cdn.example.com/figma.png', - }) - }) - - it('treats off/none flags as mock disabled', () => { - expect(shouldDisableMockAuthorizedApps('off')).toBe(true) - expect(shouldDisableMockAuthorizedApps('none')).toBe(true) - - const parsed = getMockAuthorizedApps('off') - expect(parsed).toEqual({ apps: [], isMocked: false }) - }) - }) }) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts index cf27c8ad80c..d5781bfc977 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts @@ -1,25 +1,14 @@ -import type { AuthorizedApp, AuthorizedAppsData } from 'data/oauth/authorized-apps-query' +import type { AuthorizedAppsData } from 'data/oauth/authorized-apps-query' const PROJECT_ROUTE_PREFIX = '/project/' const PROJECT_ROUTE_TEMPLATE = '/project/[ref]' const MAX_APPS_IN_TITLE = 2 -const MOCK_DISABLED_VALUES = new Set(['0', 'false', 'none', 'off']) -const MOCK_NAME_ICON_DELIMITER = '|' const sanitizeAppName = (name: string) => name.trim().replace(/\s+/g, ' ') -const getStringValue = (value?: string | string[] | null) => { - if (!value) return '' - return Array.isArray(value) ? value.join(',') : value -} - export const isProjectRoute = ({ pathname, asPath }: { pathname?: string; asPath?: string }) => Boolean(pathname?.startsWith(PROJECT_ROUTE_TEMPLATE) || asPath?.startsWith(PROJECT_ROUTE_PREFIX)) -export const getAuthorizedAppNames = (authorizedApps: AuthorizedAppsData = []) => { - return getAuthorizedAppDisplayData(authorizedApps).map((app) => app.name) -} - export const getAuthorizedAppDisplayData = (authorizedApps: AuthorizedAppsData = []) => { const uniqueApps = new Map() @@ -43,11 +32,7 @@ export const getAuthorizedAppDisplayData = (authorizedApps: AuthorizedAppsData = return Array.from(uniqueApps.values()) } -export const getConnectedAppsTitle = (appNames: string[]) => { - return `Connected to ${getConnectedAppsLabel(appNames)}` -} - -export const getConnectedAppsLabel = (appNames: string[]) => { +const getConnectedAppsLabel = (appNames: string[]) => { if (appNames.length === 0) return 'external apps' if (appNames.length === 1) return appNames[0] if (appNames.length === 2) return `${appNames[0]} and ${appNames[1]}` @@ -59,79 +44,10 @@ export const getConnectedAppsLabel = (appNames: string[]) => { return `${firstName}, ${secondName}, and ${additionalApps} ${suffix}` } -export const getConnectedAppsSentence = (appNames: string[]) => { - const connectedAppsLabel = getConnectedAppsLabel(appNames) - return `This project is connected to ${connectedAppsLabel}` -} +export const getConnectedAppsTitle = (appNames: string[]) => + `This project is connected to ${getConnectedAppsLabel(appNames)}` export const getConnectedAppsDescription = (appNames: string[]) => { const connectedAppsLabel = getConnectedAppsLabel(appNames) return `Changes made here may affect how your project works in ${connectedAppsLabel}.` } - -export const shouldDisableMockAuthorizedApps = (value?: string | string[] | null) => { - const normalizedValue = getStringValue(value).trim().toLowerCase() - return MOCK_DISABLED_VALUES.has(normalizedValue) -} - -const createMockAuthorizedApp = ({ - appName, - icon, - index, -}: { - appName: string - icon: string | null - index: number -}): AuthorizedApp => { - const safeName = sanitizeAppName(appName) - const slug = safeName.toLowerCase().replace(/[^a-z0-9]+/g, '-') - - return { - id: `mock-authorized-app-${index}-${slug || 'unknown'}`, - app_id: `mock-${slug || `app-${index}`}`, - icon, - name: safeName || `Mock App ${index + 1}`, - website: icon ?? 'https://example.com', - created_by: 'mock-oauth', - authorized_at: new Date().toISOString(), - } -} - -export const getMockAuthorizedApps = ( - value?: string | string[] | null -): { - apps: AuthorizedAppsData - isMocked: boolean -} => { - if (shouldDisableMockAuthorizedApps(value)) { - return { apps: [], isMocked: false } - } - - const mockValue = getStringValue(value).trim() - if (!mockValue) return { apps: [], isMocked: false } - - const seenNames = new Set() - const apps = mockValue - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - .map((entry, index) => { - const [namePart, ...iconParts] = entry.split(MOCK_NAME_ICON_DELIMITER) - const appName = sanitizeAppName(namePart) - if (!appName) return null - - const normalizedName = appName.toLowerCase() - if (seenNames.has(normalizedName)) return null - seenNames.add(normalizedName) - - const icon = sanitizeAppName(iconParts.join(MOCK_NAME_ICON_DELIMITER)) - return createMockAuthorizedApp({ - appName, - icon: icon.length > 0 ? icon : null, - index, - }) - }) - .filter((app): app is AuthorizedApp => app !== null) - - return { apps, isMocked: apps.length > 0 } -} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 73d240b4383..361262d2f3c 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -89,7 +89,6 @@ export const LOCAL_STORAGE_KEYS = { `user-impersonation-selector-previous-searches-${ref}`, HOTKEY_COMMAND_MENU: 'supabase-dashboard-hotkey-command-menu', - OAUTH_INTEGRATIONS_BANNER_MOCK: 'supabase-oauth-integrations-banner-mock', LAST_OPENED_SIDE_BAR: (ref: string) => `last-opened-sidebar-${ref}`, From 309cc4e0c1b00d83942953a91e9bb601a886077d Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:21:11 +1100 Subject: [PATCH 32/36] delete marker file --- .../DEPR-324-OAUTH-BANNER-MOCKING.md | 144 ------------------ 1 file changed, 144 deletions(-) delete mode 100644 apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md diff --git a/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md b/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md deleted file mode 100644 index ba7552c701c..00000000000 --- a/apps/studio/components/layouts/AppLayout/DEPR-324-OAUTH-BANNER-MOCKING.md +++ /dev/null @@ -1,144 +0,0 @@ -# DEPR-324 OAuth Banner Mocking + Data Granularity - -This marker file documents how to test the `ProjectOAuthIntegrationsBanner` before PR. - -## Placement - -- Mounted inside project content (under the project header context) via `ProjectLayout`, not in global `AppBannerWrapper`. -- This keeps the signal scoped to project surfaces. -- Current UX is single-line inline admonition with app icon (or `Plug` fallback), without a `Manage` button. - -## 1) Mock controls (non-prod only) - -Mocking is enabled when `NEXT_PUBLIC_ENVIRONMENT !== "prod"`. - -- URL query param: `oauthBannerMock` -- Local storage key: `supabase-oauth-integrations-banner-mock` - -### A. Fast one-off mock via URL - -Open any project route with: - -```text -/project//editor?oauthBannerMock=Lovable,Bolt,Figma -``` - -Optional icon per app: - -```text -/project//editor?oauthBannerMock=Figma|https://cdn.example.com/figma.png,Lovable -``` - -### B. Persistent mock via localStorage - -In browser devtools console: - -```js -localStorage.setItem( - 'supabase-oauth-integrations-banner-mock', - JSON.stringify('Lovable,Bolt,Figma') -) -location.reload() -``` - -### C. Force real data (ignore local mock) - -```text -?oauthBannerMock=off -``` - -`off`, `none`, `false`, and `0` disable mocking. - -## 2) How to clear mock state - -```js -localStorage.removeItem('supabase-oauth-integrations-banner-mock') -location.reload() -``` - -## 3) How to extract real authorized app payload (before PR) - -### A. Network tab - -- Open org OAuth apps page: `/org//apps` -- Find request: - - `GET /platform/organizations//oauth/apps?type=authorized` -- Inspect response JSON. - -### B. Console fetch (same browser session) - -```js -const slug = '' -fetch(`/platform/organizations/${slug}/oauth/apps?type=authorized`, { - credentials: 'include', -}) - .then((res) => res.json()) - .then((payload) => { - console.table( - payload.map((app) => ({ - id: app.id, - app_id: app.app_id, - name: app.name, - icon: app.icon, - website: app.website, - created_by: app.created_by, - authorized_at: app.authorized_at, - })) - ) - return payload - }) -``` - -## 4) Data granularity we currently have - -From `authorized-apps-query`, each authorized app includes: - -- `id` -- `app_id` -- `name` -- `icon` (nullable URL) -- `website` -- `created_by` -- `authorized_at` - -Important constraints: - -- We do **not** have a normalized provider enum (for example, no explicit `provider: "figma"`). -- We can still show a logo if `icon` is present. -- If `icon` is null, fallback options are name-based initials or an internal name-to-logo mapping. - -## 5) Real local OAuth flow (no mocks) - -### A. Create an OAuth app in local Studio - -- Open `/org//apps` -- Publish an OAuth app with: - - `redirect_uri`: `http://localhost:3001/callback` - - desired scopes (for testing, minimal read scopes are enough) -- Save `client_id` and `client_secret` - -### B. Trigger authorization - -Open in browser (while logged into local Studio): - -```text -http://localhost:8082/api/v1/oauth/authorize?client_id=&redirect_uri=http://localhost:3001/callback&response_type=code&organization_slug=&state=dev-local -``` - -Approve the app in the consent screen. - -### C. Exchange code for tokens - -After callback, exchange code with: - -```bash -curl -X POST 'http://localhost:8082/api/v1/oauth/token' \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'grant_type=authorization_code' \ - --data-urlencode 'client_id=' \ - --data-urlencode 'client_secret=' \ - --data-urlencode 'code=' \ - --data-urlencode 'redirect_uri=http://localhost:3001/callback' -``` - -Once approved, the app should appear in authorized apps for that org and the project banner should render without mocks. From 1bc69df399ec5055dc6098a3831cb9f5cfdcfcee Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:38:14 +1100 Subject: [PATCH 33/36] clean up tests --- .../ProjectOAuthIntegrationsBanner.test.tsx | 75 ++++------------ ...ojectOAuthIntegrationsBanner.utils.test.ts | 87 +++++++------------ 2 files changed, 51 insertions(+), 111 deletions(-) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx index 7534edbe5ac..9780dc57391 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx @@ -59,27 +59,6 @@ describe('ProjectOAuthIntegrationsBanner', () => { ) }) - it('renders a condensed title when multiple apps are authorized', () => { - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [ - createAuthorizedApp({ id: '1', app_id: '1', name: 'Lovable' }), - createAuthorizedApp({ id: '2', app_id: '2', name: 'Bolt' }), - createAuthorizedApp({ id: '3', app_id: '3', name: 'Replit' }), - ], - isError: false, - }) - - render() - - expect(screen.getByText('This project is connected to Lovable, Bolt, and 1 other app')) - .toBeInTheDocument() - expect( - screen.getByText( - 'Changes made here may affect how your project works in Lovable, Bolt, and 1 other app.' - ) - ).toBeInTheDocument() - }) - it('does not render on non-project routes', () => { routerMock.setCurrentUrl('/org/acme/apps') @@ -92,46 +71,30 @@ describe('ProjectOAuthIntegrationsBanner', () => { ) }) - it('does not render when there are no authorized apps', () => { - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [], - isError: false, - }) - - render() - - expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() - }) - - it('does not render when app lookup fails', () => { - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [createAuthorizedApp({ name: 'Lovable' })], - isError: true, - }) - - render() - - expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() - }) - - it('does not render when organization context is unavailable', () => { + it.each([ + { + label: 'no authorized apps', + organization: { slug: 'acme' }, + query: { data: [], isError: false }, + }, + { + label: 'authorized apps query fails', + organization: { slug: 'acme' }, + query: { data: [createAuthorizedApp({ name: 'Lovable' })], isError: true }, + }, + { + label: 'organization context is unavailable', + organization: undefined, + query: { data: [createAuthorizedApp({ name: 'Lovable' })], isError: false }, + }, + ])('does not render when banner is ineligible: $label', ({ organization, query }) => { mockUseSelectedOrganizationQuery.mockReturnValue({ - data: undefined, + data: organization, }) + mockUseAuthorizedAppsQuery.mockReturnValue(query) render() expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() }) - - it('falls back to plug icon when no app icon is provided', () => { - mockUseAuthorizedAppsQuery.mockReturnValue({ - data: [createAuthorizedApp({ name: 'Lovable', icon: null })], - isError: false, - }) - - const { container } = render() - - expect(container.querySelector('div[style*="background-image: none"] svg')).toBeInTheDocument() - }) }) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts index 05690f83c95..d1b7e09e804 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.test.ts @@ -20,66 +20,43 @@ const createAuthorizedApp = (overrides: Partial): AuthorizedApp = }) describe('ProjectOAuthIntegrationsBanner utils', () => { - describe('isProjectRoute', () => { - it('returns true for project route template pathnames', () => { - expect(isProjectRoute({ pathname: '/project/[ref]/database/tables' })).toBe(true) - }) - - it('returns true for project URLs from asPath', () => { - expect(isProjectRoute({ pathname: '/unknown', asPath: '/project/default/functions' })).toBe( - true - ) - }) - - it('returns false for non-project routes', () => { - expect(isProjectRoute({ pathname: '/org/[slug]/apps', asPath: '/org/default/apps' })).toBe( - false - ) - }) + it('detects project routes from pathname or asPath', () => { + expect(isProjectRoute({ pathname: '/project/[ref]/database/tables' })).toBe(true) + expect(isProjectRoute({ pathname: '/unknown', asPath: '/project/default/functions' })).toBe( + true + ) + expect(isProjectRoute({ pathname: '/org/[slug]/apps', asPath: '/org/default/apps' })).toBe( + false + ) }) - describe('getAuthorizedAppDisplayData', () => { - it('returns unique display-safe app names', () => { - const displayApps = getAuthorizedAppDisplayData([ - createAuthorizedApp({ name: ' Lovable ' }), - createAuthorizedApp({ name: 'lovable' }), - createAuthorizedApp({ name: 'Bolt.new' }), - createAuthorizedApp({ name: ' ' }), - ]) + it('returns unique display-safe apps and backfills icons from duplicate entries', () => { + const displayApps = getAuthorizedAppDisplayData([ + createAuthorizedApp({ name: ' Lovable ' }), + createAuthorizedApp({ name: 'lovable' }), + createAuthorizedApp({ name: 'Bolt.new' }), + createAuthorizedApp({ name: ' ' }), + createAuthorizedApp({ name: 'Figma', icon: null }), + createAuthorizedApp({ name: 'figma', icon: 'https://cdn.example.com/figma.png' }), + ]) - expect(displayApps).toEqual([ - { name: 'Lovable', icon: null }, - { name: 'Bolt.new', icon: null }, - ]) - }) - - it('backfills icon from duplicate entries', () => { - const displayApps = getAuthorizedAppDisplayData([ - createAuthorizedApp({ name: 'Figma', icon: null }), - createAuthorizedApp({ name: 'figma', icon: 'https://cdn.example.com/figma.png' }), - ]) - - expect(displayApps).toEqual([ - { name: 'Figma', icon: 'https://cdn.example.com/figma.png' }, - ]) - }) + expect(displayApps).toEqual([ + { name: 'Lovable', icon: null }, + { name: 'Bolt.new', icon: null }, + { name: 'Figma', icon: 'https://cdn.example.com/figma.png' }, + ]) }) - describe('copy helpers', () => { - it('renders title for a single app', () => { - expect(getConnectedAppsTitle(['Lovable'])).toBe('This project is connected to Lovable') - }) + it('renders connected app titles for single and multi-app cases', () => { + expect(getConnectedAppsTitle(['Lovable'])).toBe('This project is connected to Lovable') + expect(getConnectedAppsTitle(['Lovable', 'Bolt', 'Replit'])).toBe( + 'This project is connected to Lovable, Bolt, and 1 other app' + ) + }) - it('renders condensed title for multiple apps', () => { - expect(getConnectedAppsTitle(['Lovable', 'Bolt', 'Replit'])).toBe( - 'This project is connected to Lovable, Bolt, and 1 other app' - ) - }) - - it('renders a matching description', () => { - expect(getConnectedAppsDescription(['Lovable'])).toBe( - 'Changes made here may affect how your project works in Lovable.' - ) - }) + it('renders a connected app description', () => { + expect(getConnectedAppsDescription(['Lovable'])).toBe( + 'Changes made here may affect how your project works in Lovable.' + ) }) }) From edb0bce518034c271fcdf00b36798cb5724b09ff Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:52:15 +1100 Subject: [PATCH 34/36] test fix --- .../layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts index d5781bfc977..83a9aa576d2 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.utils.ts @@ -24,8 +24,8 @@ export const getAuthorizedAppDisplayData = (authorizedApps: AuthorizedAppsData = // Backfill an icon if a duplicate app entry has one and the first did not. const existingApp = uniqueApps.get(normalizedName) - if (!existingApp?.icon && app.icon) { - uniqueApps.set(normalizedName, { ...existingApp, icon: app.icon }) + if (existingApp && !existingApp.icon && app.icon) { + uniqueApps.set(normalizedName, { name: existingApp.name, icon: app.icon }) } }) From 686efc6390272361e2e9bc671081cb5b149625b4 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:42:42 +1100 Subject: [PATCH 35/36] fix test --- apps/studio/components/layouts/ProjectLayout/index.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 7bec870580d..cfed471a62b 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -100,6 +100,9 @@ vi.mock('@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs', () => ({ vi.mock('@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner', () => ({ ResourceExhaustionWarningBanner: () => null, })) +vi.mock('@/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner', () => ({ + ProjectOAuthIntegrationsBanner: () => null, +})) vi.mock('@/hooks/custom-content/useCustomContent', () => ({ useCustomContent: () => ({ appTitle: 'Supabase' }), From f52b06d37ebb272adc394ba0f0b828a32725db9a Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:19:31 +1100 Subject: [PATCH 36/36] use feature preview --- .../ConnectedAppsBannerPreview.tsx | 25 +++++++++++++++++++ .../FeaturePreview/FeaturePreviewContext.tsx | 9 +++++++ .../FeaturePreview/FeaturePreviewModal.tsx | 3 +++ .../App/FeaturePreview/useFeaturePreviews.ts | 10 ++++++++ .../ProjectOAuthIntegrationsBanner.test.tsx | 19 ++++++++++++++ .../ProjectOAuthIntegrationsBanner.tsx | 14 +++++++++-- packages/common/constants/local-storage.ts | 3 +++ 7 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/ConnectedAppsBannerPreview.tsx diff --git a/apps/studio/components/interfaces/App/FeaturePreview/ConnectedAppsBannerPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/ConnectedAppsBannerPreview.tsx new file mode 100644 index 00000000000..c03be55b413 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/ConnectedAppsBannerPreview.tsx @@ -0,0 +1,25 @@ +import { useParams } from 'common' + +import { InlineLink } from '@/components/ui/InlineLink' + +export const ConnectedAppsBannerPreview = () => { + const { slug = '_', ref = '_' } = useParams() + + return ( +
+

+ Show a project-level awareness banner when your organization has connected OAuth apps. +

+
    +
  • + Project scope:{' '} + Visible across project pages +
  • +
  • + Manage connections:{' '} + Organization OAuth apps +
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 2b6eb4d64c9..63311bb5038 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -119,6 +119,15 @@ export const useIsPlatformWebhooksEnabled = () => { return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS] } +export const useIsProjectOauthIntegrationsBannerEnabled = () => { + const { flags } = useFeaturePreviewContext() + const projectOauthIntegrationsBannerEnabled = useFlag('projectOauthIntegrationsBanner') + return ( + projectOauthIntegrationsBannerEnabled && + flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER] + ) +} + export const useIsTableFilterBarEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index ca9f986f09f..53cf328e609 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -23,6 +23,7 @@ import { AdvisorRulesPreview } from './AdvisorRulesPreview' import { APISidePanelPreview } from './APISidePanelPreview' import { Branching2Preview } from './Branching2Preview' import { CLSPreview } from './CLSPreview' +import { ConnectedAppsBannerPreview } from './ConnectedAppsBannerPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' @@ -44,6 +45,8 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER]: + , } export const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index df3f9535d6c..6e275409cc0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -18,6 +18,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar') const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') const platformWebhooksEnabled = useFlag('platformWebhooks') + const projectOauthIntegrationsBannerEnabled = useFlag('projectOauthIntegrationsBanner') return [ { @@ -66,6 +67,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: false, enabled: platformWebhooksEnabled, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER, + name: 'Connected apps banner', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: projectOauthIntegrationsBannerEnabled, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx index 9780dc57391..07e5e85eb5b 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.test.tsx @@ -8,6 +8,7 @@ import { ProjectOAuthIntegrationsBanner } from './ProjectOAuthIntegrationsBanner const mockUseSelectedOrganizationQuery = vi.fn() const mockUseAuthorizedAppsQuery = vi.fn() +const mockUseIsProjectOauthIntegrationsBannerEnabled = vi.fn() vi.mock('hooks/misc/useSelectedOrganization', () => ({ useSelectedOrganizationQuery: () => mockUseSelectedOrganizationQuery(), @@ -17,6 +18,11 @@ vi.mock('data/oauth/authorized-apps-query', () => ({ useAuthorizedAppsQuery: (...args: unknown[]) => mockUseAuthorizedAppsQuery(...args), })) +vi.mock('components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ + useIsProjectOauthIntegrationsBannerEnabled: () => + mockUseIsProjectOauthIntegrationsBannerEnabled(), +})) + const createAuthorizedApp = (overrides: Partial): AuthorizedApp => ({ id: 'authorized-app-1', app_id: 'oauth-app-1', @@ -36,6 +42,7 @@ describe('ProjectOAuthIntegrationsBanner', () => { mockUseSelectedOrganizationQuery.mockReturnValue({ data: { slug: 'acme' }, }) + mockUseIsProjectOauthIntegrationsBannerEnabled.mockReturnValue(true) mockUseAuthorizedAppsQuery.mockReturnValue({ data: [createAuthorizedApp({ name: 'Lovable' })], @@ -71,6 +78,18 @@ describe('ProjectOAuthIntegrationsBanner', () => { ) }) + it('does not render when feature preview is disabled', () => { + mockUseIsProjectOauthIntegrationsBannerEnabled.mockReturnValue(false) + + render() + + expect(screen.queryByText(/This project is connected to/)).not.toBeInTheDocument() + expect(mockUseAuthorizedAppsQuery).toHaveBeenCalledWith( + { slug: 'acme' }, + expect.objectContaining({ enabled: false }) + ) + }) + it.each([ { label: 'no authorized apps', diff --git a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx index b9e92dee3fc..d03bf17d4ca 100644 --- a/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/ProjectOAuthIntegrationsBanner.tsx @@ -1,3 +1,4 @@ +import { useIsProjectOauthIntegrationsBannerEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Plug, Settings2 } from 'lucide-react' @@ -15,16 +16,25 @@ import { export const ProjectOAuthIntegrationsBanner = () => { const router = useRouter() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const isProjectOauthIntegrationsBannerEnabled = useIsProjectOauthIntegrationsBannerEnabled() const organizationSlug = selectedOrganization?.slug const showProjectBanner = isProjectRoute({ pathname: router.pathname, asPath: router.asPath }) + const canShowProjectBanner = + isProjectOauthIntegrationsBannerEnabled && showProjectBanner && !!organizationSlug const { data: authorizedApps = [], isError } = useAuthorizedAppsQuery( { slug: organizationSlug }, - { enabled: showProjectBanner && !!organizationSlug } + { enabled: canShowProjectBanner } ) - if (!showProjectBanner || !organizationSlug || isError) return null + if ( + !isProjectOauthIntegrationsBannerEnabled || + !showProjectBanner || + !organizationSlug || + isError + ) + return null if (authorizedApps.length === 0) return null diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 361262d2f3c..716705af5d1 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -25,6 +25,8 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_TABLE_FILTER_BAR: 'supabase-ui-table-filter-bar', UI_PREVIEW_PG_DELTA_DIFF: 'supabase-ui-pg-delta-diff', UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks', + UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER: + 'supabase-ui-preview-project-oauth-integrations-banner', NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge', TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge', @@ -144,6 +146,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS, LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR, LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, + LOCAL_STORAGE_KEYS.UI_PREVIEW_PROJECT_OAUTH_INTEGRATIONS_BANNER, LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD, LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST, LOCAL_STORAGE_KEYS.BLOG_VIEW,