From d8f7cc0d57fae38af40a003d574a93cd0681182f Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:57:49 -0400 Subject: [PATCH] feat(support form): attach dashboard logs (#39539) * o11y: mirror and sanitize breadcrumbs Mirror Sentry breadcrumbs as the basis for our own support logging. Also adds more sanitization to breadcrumbs. * feat(support form): toggle for attaching dashboard logs Add a toggle to the support form when the category is "Dashboard bug", to attach recent dashboard logs. Users can preview the attached logs and opt out. * feat(support links): dedicated support link component Add a new component for support links, which: - Uses the serializer for support link params to ensure serialization/deserialization pairs correctly - Snapshots breadcrumbs so the attached log on the support form will be cut off at the support link click (otherwise we will get support form actions cluttering up the log) * tests(support form): extend timeout on flaky test * Minor clean up * fix(support form): allow url to specifically indicate no specified project * minor nits * Fix tests * Fix tests --------- Co-authored-by: Joshen Lim --- .../APIKeys/ApiKeysIllustrations.tsx | 15 +- .../Preferences/DeleteAccountButton.tsx | 5 +- .../PaymentMethods/CurrentPaymentMethod.tsx | 10 +- .../Payment/PaymentMethods/PaymentMethods.tsx | 15 +- .../Database/Migrations/Migrations.tsx | 13 +- .../fields/ComputeSizeField.tsx | 13 +- .../BillingSettings/CreditTopUp.tsx | 20 +- .../Subscription/Subscription.tsx | 22 +- .../Organization/Documents/Documents.tsx | 10 +- .../interfaces/Settings/Addons/Addons.tsx | 14 +- .../Settings/Addons/PITRSidePanel.tsx | 3 +- .../Database/DiskSizeConfigurationModal.tsx | 11 +- .../Settings/Database/SSLConfiguration.tsx | 14 +- .../CustomDomainConfig/CustomDomainConfig.tsx | 13 +- .../DeployNewReplicaPanel.tsx | 15 +- .../interfaces/SignIn/SessionTimeoutModal.tsx | 12 +- .../interfaces/SignIn/SignInMfaForm.tsx | 13 +- .../Storage/StorageBucketsError.tsx | 13 +- .../Support/CategoryAndSeverityInfo.tsx | 3 +- .../Support/DashboardLogsToggle.tsx | 74 ++++ .../components/interfaces/Support/Success.tsx | 10 +- .../Support/SupportAccessToggle.tsx | 2 +- .../interfaces/Support/SupportForm.schema.ts | 1 + .../interfaces/Support/SupportForm.utils.tsx | 15 +- .../interfaces/Support/SupportFormPage.tsx | 2 +- .../interfaces/Support/SupportFormV2.tsx | 43 ++- .../interfaces/Support/SupportLink.tsx | 21 ++ .../__tests__/SupportFormPage.test.tsx | 355 +++++++++++++++++- .../interfaces/Support/dashboard-logs.ts | 77 ++++ .../interfaces/Support/useSupportForm.ts | 6 +- .../layouts/ProjectLayout/BuildingState.tsx | 3 +- .../FeedbackDropdown/FeedbackDropdown.tsx | 6 +- .../FeedbackDropdown/FeedbackWidget.tsx | 24 +- .../LayoutHeader/HelpPopover.tsx | 7 +- .../ProjectLayout/PauseFailedState.tsx | 14 +- .../ProjectLayout/RestoreFailedState.tsx | 14 +- .../layouts/ProjectLayout/RestoringState.tsx | 13 +- .../UpgradingState/UpgradingState.tsx | 16 +- apps/studio/components/ui/AlertError.tsx | 21 +- apps/studio/components/ui/Error.tsx | 3 +- .../ui/GlobalErrorBoundaryState.tsx | 28 +- .../ui/ProjectUpgradeFailedBanner.tsx | 16 +- .../components/ui/ToggleSpendCapButton.tsx | 19 +- .../components/ui/UpgradePlanButton.tsx | 13 +- .../generate-attachment-urls-mutation.ts | 4 +- apps/studio/instrumentation-client.ts | 23 +- apps/studio/lib/breadcrumbs.ts | 21 ++ apps/studio/lib/ringBuffer.test.ts | 139 +++++++ apps/studio/lib/ringBuffer.ts | 87 +++++ apps/studio/lib/sanitize.test.ts | 162 ++++++++ apps/studio/lib/sanitize.ts | 218 +++++++++++ apps/studio/pages/500.tsx | 3 +- .../pages/api/generate-attachment-url.ts | 5 +- .../src/form/Layout/FormLayout.tsx | 4 +- 54 files changed, 1501 insertions(+), 202 deletions(-) create mode 100644 apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx create mode 100644 apps/studio/components/interfaces/Support/SupportLink.tsx create mode 100644 apps/studio/components/interfaces/Support/dashboard-logs.ts create mode 100644 apps/studio/lib/breadcrumbs.ts create mode 100644 apps/studio/lib/ringBuffer.test.ts create mode 100644 apps/studio/lib/ringBuffer.ts create mode 100644 apps/studio/lib/sanitize.test.ts create mode 100644 apps/studio/lib/sanitize.ts diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index ff5bbd74c87..77dc6dd6112 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,5 +1,6 @@ import { ExternalLink, Github } from 'lucide-react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' import { APIKeysData } from 'data/api-keys/api-keys-query' @@ -15,6 +16,7 @@ import { TableHeader, TableRow, } from 'ui' +import { SupportLink } from '../Support/SupportLink' import { ApiKeyPill } from './ApiKeyPill' import { CreateNewAPIKeysButton } from './CreateNewAPIKeysButton' import { useApiKeysVisibility } from './hooks/useApiKeysVisibility' @@ -191,12 +193,17 @@ export const ApiKeysFeedbackBanner = () => {

Having trouble with the new API keys?{' '} - Contact support - +

diff --git a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx index 91306f2d191..90e9c00edb2 100644 --- a/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/DeleteAccountButton.tsx @@ -5,6 +5,8 @@ import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { LOCAL_STORAGE_KEYS } from 'common' +import { NO_PROJECT_MARKER } from 'components/interfaces/Support/SupportForm.utils' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProfile } from 'lib/profile' @@ -26,7 +28,6 @@ import { Input_Shadcn_, Separator, } from 'ui' -import { LOCAL_STORAGE_KEYS } from 'common' const setDeletionRequestFlag = () => { const expiryDate = new Date() @@ -92,7 +93,7 @@ export const DeleteAccountButton = () => { severity: 'Low', allowSupportAccess: false, verified: true, - projectRef: 'no-project', + projectRef: NO_PROJECT_MARKER, } submitSupportTicket(payload) diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx index 1dd9201fcf3..5b5deccf3c0 100644 --- a/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentMethods/CurrentPaymentMethod.tsx @@ -3,6 +3,7 @@ import { CreditCardIcon } from 'lucide-react' import Link from 'next/link' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useOrganizationPaymentMethodsQuery } from 'data/organizations/organization-payment-methods-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -66,11 +67,14 @@ const CurrentPaymentMethod = () => { , ]} > diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx index ffe4a03f960..dd8cdfaf9ed 100644 --- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx +++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx @@ -1,7 +1,8 @@ import dayjs from 'dayjs' -import Link from 'next/link' import { useState } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { DatabaseMigration, useMigrationsQuery } from 'data/database/migrations-query' @@ -64,11 +65,15 @@ const Migrations = () => { } > )} diff --git a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx index 6cbe106ade3..6343e588b77 100644 --- a/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx +++ b/apps/studio/components/interfaces/DiskManagement/fields/ComputeSizeField.tsx @@ -2,7 +2,9 @@ import { CpuIcon, Lock, Microchip } from 'lucide-react' import { useMemo } from 'react' import { UseFormReturn } from 'react-hook-form' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DocsButton } from 'components/ui/DocsButton' import { InlineLink } from 'components/ui/InlineLink' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' @@ -11,7 +13,6 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { DOCS_URL, InstanceSpecs } from 'lib/constants' -import Link from 'next/link' import { cn, FormField_Shadcn_, @@ -302,8 +303,12 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) { 'relative text-sm text-left flex flex-col gap-0 px-0 py-3 [&_label]:w-full group] w-full h-[110px]' )} label={ -
@@ -334,7 +339,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
- + } /> diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index 71772fb560d..cf0db499a2b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -6,7 +6,6 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/ import { useQueryClient } from '@tanstack/react-query' import { AlertCircle, Info } from 'lucide-react' import { useTheme } from 'next-themes' -import Link from 'next/link' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -14,6 +13,8 @@ import { z } from 'zod' import { getStripeElementsAppearanceOptions } from 'components/interfaces/Billing/Payment/Payment.utils' import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation' +import { NO_PROJECT_MARKER } from 'components/interfaces/Support/SupportForm.utils' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useOrganizationCreditTopUpMutation } from 'data/organizations/organization-credit-top-up-mutation' import { subscriptionKeys } from 'data/subscriptions/keys' @@ -241,13 +242,18 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => { up credits do not expire.

- For larger discounted credit packages, please{' '} - - reach out. - + support + + .

diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index b6c3173f9e4..2e9a052a10e 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,7 +1,8 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import Link from 'next/link' import { useFlag, useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldSection, ScaffoldSectionContent, @@ -109,15 +110,16 @@ const Subscription = () => { variant="info" title={`Unable to update plan from ${planName}`} actions={[ -
- -
, + , ]} > Please contact us if you'd like to change your plan. diff --git a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx index 1b35df888dc..7190771e207 100644 --- a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx @@ -1,6 +1,6 @@ -import Link from 'next/link' - +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ScaffoldContainer, ScaffoldDivider, ScaffoldSection } from 'components/layouts/Scaffold' +import { InlineLinkClassName } from 'components/ui/InlineLink' import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { Fragment } from 'react' import { CustomDocument } from './CustomDocument' @@ -59,10 +59,8 @@ const Documents = () => {

- - Submit a support request - {' '} - if you require additional documents for financial or tax reasons, such as a W-9 form. + Submit a support request if + you require additional documents for financial or tax reasons, such as a W-9 form.

diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 0b200b9a810..79b65204074 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -5,6 +5,7 @@ import Image from 'next/image' import Link from 'next/link' import { useMemo } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { getAddons, @@ -12,6 +13,7 @@ import { } from 'components/interfaces/Billing/Subscription/Subscription.utils' import { NoticeBar } from 'components/interfaces/DiskManagement/ui/NoticeBar' import ProjectUpdateDisabledTooltip from 'components/interfaces/Organization/BillingSettings/ProjectUpdateDisabledTooltip' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsProjectActive } from 'components/layouts/ProjectLayout/ProjectContext' import { ScaffoldContainer, @@ -446,7 +448,7 @@ export const Addons = () => {
@@ -487,11 +489,15 @@ export const Addons = () => { Reach out to us via support if you're interested

diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 172f0c9966c..8e655eeb145 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { UpgradePlanButton } from 'components/ui/UpgradePlanButton' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' @@ -258,7 +259,7 @@ const PITRSidePanel = () => {
diff --git a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx index 58e6fd8d413..46e1bd6e09a 100644 --- a/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DiskSizeConfigurationModal.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { number, object } from 'yup' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -118,11 +119,15 @@ const DiskSizeConfigurationModal = ({ need more than this, contact us via support for help.

diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 570e054c8cf..b7d786ce547 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -1,16 +1,17 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { template } from 'lodash' import { Download, Loader2 } from 'lucide-react' -import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { InlineLinkClassName } from 'components/ui/InlineLink' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSSLEnforcementQuery } from 'data/ssl-enforcement/ssl-enforcement-query' import { useSSLEnforcementUpdateMutation } from 'data/ssl-enforcement/ssl-enforcement-update-mutation' @@ -109,15 +110,8 @@ const SSLConfiguration = () => { title="SSL enforcement was not updated successfully" > Please try updating again, or contact{' '} - - support - {' '} - if this error persists + support if this + error persists )} diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index f3ee01982a2..06bd6f23fcd 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -1,13 +1,15 @@ import { AlertCircle } from 'lucide-react' -import Link from 'next/link' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { FormHeader } from 'components/ui/Forms/FormHeader' +import { InlineLinkClassName } from 'components/ui/InlineLink' import Panel from 'components/ui/Panel' import UpgradeToPro from 'components/ui/UpgradeToPro' import { - type CustomDomainsData, useCustomDomainsQuery, + type CustomDomainsData, } from 'data/custom-domains/custom-domains-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -91,9 +93,12 @@ export const CustomDomainConfig = () => {

Failed to retrieve custom domain configuration. Please try again later or{' '} - + contact support - + .

diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index e6f55fac5b2..3d975a76961 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { calculateIOPSPrice, @@ -12,6 +13,7 @@ import { DISK_PRICING, DiskType, } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DocsButton } from 'components/ui/DocsButton' import { useDiskAttributesQuery } from 'data/config/disk-attributes-query' import { useEnablePhysicalBackupsMutation } from 'data/database/enable-physical-backups-mutation' @@ -287,13 +289,16 @@ const DeployNewReplicaPanel = ({ diff --git a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx index c4e05cf8bd5..ddca9ff4c61 100644 --- a/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx +++ b/apps/studio/components/interfaces/SignIn/SessionTimeoutModal.tsx @@ -1,9 +1,10 @@ import * as Sentry from '@sentry/nextjs' import { useEffect } from 'react' -import { InlineLink } from 'components/ui/InlineLink' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink' import { toast } from 'sonner' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { SupportLink } from '../Support/SupportLink' interface SessionTimeoutModalProps { visible: boolean @@ -60,9 +61,12 @@ export const SessionTimeoutModal = ({

If none of these steps work, please{' '} - + Contact support - + .

diff --git a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx index 31e22f38b25..199cb417e00 100644 --- a/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx +++ b/apps/studio/components/interfaces/SignIn/SignInMfaForm.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import z from 'zod' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useAuthError } from 'common' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' @@ -17,6 +18,7 @@ import { useSignOut } from 'lib/auth' import { getReturnToPath } from 'lib/gotrue' import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { SupportLink } from '../Support/SupportLink' const schema = z.object({ code: z.string().min(1, 'MFA Code is required'), @@ -200,14 +202,15 @@ export const SignInMfaForm = ({ context = 'sign-in' }: SignInMfaFormProps) => {

  • - Reach out to us via support - +
  • diff --git a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx index 6105523fcca..a13720a5ff2 100644 --- a/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx +++ b/apps/studio/components/interfaces/Storage/StorageBucketsError.tsx @@ -1,7 +1,8 @@ +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import Link from 'next/link' import type { ResponseError } from 'types' import { Alert, Button } from 'ui' +import { SupportLink } from '../Support/SupportLink' export interface StorageBucketsErrorProps { error: ResponseError @@ -19,11 +20,15 @@ const StorageBucketsError = ({ error }: StorageBucketsErrorProps) => { title="Failed to fetch buckets" actions={[ , ]} > diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx index ae10f3997b0..d8bc51dd936 100644 --- a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -22,6 +22,7 @@ import { SEVERITY_OPTIONS, } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' +import { NO_PROJECT_MARKER } from './SupportForm.utils' interface CategoryAndSeverityInfoProps { form: UseFormReturn @@ -148,7 +149,7 @@ function SeveritySelector({ form }: SeveritySelectorProps) { } const IssueSuggestion = ({ category, projectRef }: { category: string; projectRef?: string }) => { - const baseUrl = `/project/${projectRef === 'no-project' ? '_' : projectRef}` + const baseUrl = `/project/${projectRef === NO_PROJECT_MARKER ? '_' : projectRef}` const className = 'col-span-2 mb-0' diff --git a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx new file mode 100644 index 00000000000..02a97920497 --- /dev/null +++ b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx @@ -0,0 +1,74 @@ +import { ChevronRight } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { UseFormReturn } from 'react-hook-form' + +import { + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + FormField_Shadcn_, + Switch, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import type { SupportFormValues } from './SupportForm.schema' +import { DASHBOARD_LOG_CATEGORIES, getSanitizedBreadcrumbs } from './dashboard-logs' + +interface DashboardLogsToggleProps { + form: UseFormReturn +} + +export function DashboardLogsToggle({ form }: DashboardLogsToggleProps) { + const sanitizedLogJson = useMemo(() => JSON.stringify(getSanitizedBreadcrumbs(), null, 2), []) + + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + + if (!DASHBOARD_LOG_CATEGORIES.includes(form.getValues('category'))) return + + return ( + ( + + Include dashboard activity log + + } + description={ +
    + + Share sanitized logs of recent dashboard actions to help reproduce the issue. + + + + + Preview log + + +
    +                    {sanitizedLogJson}
    +                  
    +
    +
    +
    + } + > + +
    + )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/Success.tsx b/apps/studio/components/interfaces/Support/Success.tsx index 4d428c6a376..1ad3a17cbe1 100644 --- a/apps/studio/components/interfaces/Support/Success.tsx +++ b/apps/studio/components/interfaces/Support/Success.tsx @@ -6,19 +6,23 @@ import { useProjectDetailQuery } from 'data/projects/project-detail-query' import { useProfile } from 'lib/profile' import { Button, Input, Separator } from 'ui' import { CATEGORY_OPTIONS } from './Support.constants' +import { NO_PROJECT_MARKER } from './SupportForm.utils' interface SuccessProps { sentCategory?: string selectedProject?: string } -export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: SuccessProps) => { +export const Success = ({ + sentCategory = '', + selectedProject = NO_PROJECT_MARKER, +}: SuccessProps) => { const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' const { data: project } = useProjectDetailQuery( { ref: selectedProject }, - { enabled: selectedProject !== 'no-project' } + { enabled: selectedProject !== NO_PROJECT_MARKER } ) const projectName = project ? project.name : 'No specific project' @@ -40,7 +44,7 @@ export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: S

    We will reach out to you at {respondToEmail}.

    - {selectedProject !== 'no-project' && ( + {selectedProject !== NO_PROJECT_MARKER && (

    Your ticket has been logged for the project{' '} {projectName}, reference ID:{' '} diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index bdb5b8df1ee..a5b49bc2e01 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -35,6 +35,7 @@ export function SupportAccessToggle({ form }: SupportAccessToggleProps) { render={({ field }) => { return ( diff --git a/apps/studio/components/interfaces/Support/SupportForm.schema.ts b/apps/studio/components/interfaces/Support/SupportForm.schema.ts index 79c9af8cfeb..29b160027f8 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.schema.ts +++ b/apps/studio/components/interfaces/Support/SupportForm.schema.ts @@ -20,6 +20,7 @@ const createFormSchema = (showClientLibraries: boolean) => { message: z.string().min(1, "Please add a message about the issue that you're facing"), affectedServices: z.string(), allowSupportAccess: z.boolean(), + attachDashboardLogs: z.boolean(), dashboardSentryIssueId: z.string().optional(), }) diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 991fbcb3c03..853600d4d76 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -3,16 +3,16 @@ import { createLoader, createParser, createSerializer, - parseAsString, type inferParserType, + parseAsString, type UseQueryStatesKeysMap, } from 'nuqs' // End of third-party imports import { - DocsSearchResultType as PageType, type DocsSearchResult as Page, type DocsSearchResultSection as PageSection, + DocsSearchResultType as PageType, } from 'common' import { getProjectDetail } from 'data/projects/project-detail-query' import dayjs from 'dayjs' @@ -28,11 +28,13 @@ export const formatMessage = ({ attachments = [], error, commit, + dashboardLogUrl, }: { message: string attachments?: string[] error: string | null | undefined commit: { commitSha: string; commitTime: string } | undefined + dashboardLogUrl?: string }) => { const errorString = error != null ? `\n\nError: ${error}` : '' const attachmentsString = @@ -41,7 +43,8 @@ export const formatMessage = ({ commit != undefined ? `\n\n---\nSupabase Studio version: SHA ${commit.commitSha} deployed at ${commit.commitTime === 'unknown' ? 'unknown time' : dayjs(commit.commitTime).format('YYYY-MM-DD HH:mm:ss Z')}` : '' - return `${message}${errorString}${attachmentsString}${commitString}` + const logString = dashboardLogUrl ? `\nDashboard logs: ${dashboardLogUrl}` : '' + return `${message}${errorString}${attachmentsString}${commitString}${logString}` } export function getPageIcon(page: Page) { @@ -119,8 +122,8 @@ const parseAsCategoryOption = createParser({ }) const supportFormUrlState = { - projectRef: parseAsString.withDefault(NO_PROJECT_MARKER), - orgSlug: parseAsString.withDefault(NO_ORG_MARKER), + projectRef: parseAsString.withDefault(''), + orgSlug: parseAsString.withDefault(''), category: parseAsCategoryOption, subject: parseAsString.withDefault(''), message: parseAsString.withDefault(''), @@ -134,7 +137,7 @@ export const loadSupportFormInitialParams = createLoader(supportFormUrlState) const serializeSupportFormInitialParams = createSerializer(supportFormUrlState) -export function createSupportFormUrl(initialParams: SupportFormUrlKeys) { +export function createSupportFormUrl(initialParams: Partial) { const serializedParams = serializeSupportFormInitialParams(initialParams) return `/support/new${serializedParams ?? ''}` } diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index e4d48d0b9a4..064a4c6fe22 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -23,8 +23,8 @@ import type { SupportFormValues } from './SupportForm.schema' import { createInitialSupportFormState, type SupportFormActions, - supportFormReducer, type SupportFormState, + supportFormReducer, } from './SupportForm.state' import { SupportFormV2 } from './SupportFormV2' import { useSupportForm } from './useSupportForm' diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 04b62436b14..398857db16b 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -8,10 +8,11 @@ import { CLIENT_LIBRARIES } from 'common/constants' import { getProjectAuthConfig } from 'data/auth/auth-config-query' import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send' import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation' import { useDeploymentCommitQuery } from 'data/utils/deployment-commit-query' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' -import { DialogSectionSeparator, Form_Shadcn_, Separator } from 'ui' +import { DialogSectionSeparator, Form_Shadcn_ } from 'ui' import { AffectedServicesSelector, CATEGORIES_WITHOUT_AFFECTED_SERVICES, @@ -19,6 +20,7 @@ import { import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload' import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' import { ClientLibraryInfo } from './ClientLibraryInfo' +import { DashboardLogsToggle } from './DashboardLogsToggle' import { MessageField } from './MessageField' import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' @@ -33,6 +35,7 @@ import { NO_ORG_MARKER, NO_PROJECT_MARKER, } from './SupportForm.utils' +import { DASHBOARD_LOG_CATEGORIES, uploadDashboardLog } from './dashboard-logs' const useIsSimplifiedForm = (slug: string) => { const simplifiedSupportForm = useFlag('simplifiedSupportForm') @@ -64,6 +67,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) const attachmentUpload = useAttachmentUpload() + const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation() const { data: commit } = useDeploymentCommitQuery({ staleTime: 1000 * 60 * 10, // 10 minutes @@ -86,9 +90,19 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo }, }) - const onSubmit: SubmitHandler = async (values) => { + const onSubmit: SubmitHandler = async (formValues) => { dispatch({ type: 'SUBMIT' }) - const attachments = await attachmentUpload.createAttachments() + + const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues + const attachDashboardLogs = + formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category) + + const [attachments, dashboardLogUrl] = await Promise.all([ + attachmentUpload.createAttachments(), + attachDashboardLogs + ? uploadDashboardLog({ userId: profile?.gotrue_id, uploadDashboardLogFn }) + : undefined, + ]) const selectedLibrary = values.library ? CLIENT_LIBRARIES.find((library) => library.language === values.library) @@ -111,6 +125,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo attachments, error: initialError, commit, + dashboardLogUrl: dashboardLogUrl?.[0], }), verified: true, tags: ['dashboard-support-form'], @@ -195,13 +210,21 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo -

    - {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( - <> - - - - )} + {DASHBOARD_LOG_CATEGORIES.includes(category) && ( + <> + + + + )} + + {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + <> + + + + )} + +
    } & Omit, 'href'> +>) => { + const href = createSupportFormUrl(queryParams ?? {}) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 5f6f45a3f09..03ec0e4e773 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -88,11 +88,22 @@ vi.mock('react-inlinesvg', () => ({ default: () => null, })) -// Mock the support storage client module - will be configured per test vi.mock('../support-storage-client', () => ({ createSupportStorageClient: vi.fn(), })) +vi.mock(import('lib/breadcrumbs'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getOwnershipOfBreadcrumbSnapshot: vi.fn(), + } +}) + +let createSupportStorageClientMock: ReturnType +let getBreadcrumbSnapshotMock: ReturnType +let generateAttachmentUrlSpy: ReturnType + // Mock sonner toast vi.mock('sonner', () => ({ toast: { @@ -221,6 +232,13 @@ const selectLibraryOption = async (screen: Screen, optionLabel: string) => { await userEvent.click(option) } +const getDashboardLogsToggle = (screen: Screen, type: 'find' | 'query' = 'find') => { + const labelMatcher = /dashboard .* log/i + return type === 'find' + ? screen.findByRole('switch', { name: labelMatcher }) + : screen.queryByRole('switch', { name: labelMatcher }) +} + const getSupportForm = () => { const form = document.querySelector('form#support-form') expect(form).not.toBeNull() @@ -273,10 +291,69 @@ describe('SupportFormPage', () => { }) }) - beforeEach(() => { + beforeEach(async () => { mockUseDeploymentCommitQuery.mockReturnValue({ data: { commitSha: mockCommitSha, commitTime: mockCommitTime }, }) + const { createSupportStorageClient } = await import('../support-storage-client') + createSupportStorageClientMock = vi.mocked(createSupportStorageClient) + createSupportStorageClientMock.mockReset() + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload: vi.fn(async (path: string) => ({ + data: { path }, + error: null, + })), + createSignedUrls: vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })), + })), + }, + } as any) + + generateAttachmentUrlSpy = vi.fn() + mswServer.use( + http.post('*/rest/v1/rpc/docs_search_fts', async () => { + return HttpResponse.json([]) + }), + http.post('*/rest/v1/rpc/docs_search_fts_nimbus', async () => { + return HttpResponse.json([]) + }), + http.post('*/functions/v1/search-embeddings', async () => { + return HttpResponse.json([]) + }), + http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => { + const body = (await request.json()) as { + bucket?: string + filenames?: string[] + } + generateAttachmentUrlSpy(body) + const filenames = body.filenames ?? [] + return HttpResponse.json( + filenames.map((filename) => `https://storage.example.com/signed/${filename}`) + ) + }) + ) + + const breadcrumbsModule = await import('lib/breadcrumbs') + getBreadcrumbSnapshotMock = vi.mocked(breadcrumbsModule.getOwnershipOfBreadcrumbSnapshot) + getBreadcrumbSnapshotMock.mockReset() + getBreadcrumbSnapshotMock.mockReturnValue([ + { + timestamp: 1_710_000_000, + category: 'ui.action', + message: 'Clicked button', + level: 'info', + data: { route: '/project/_/dashboard' }, + }, + ]) + Object.defineProperty(window.navigator, 'userAgent', { value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', @@ -427,6 +504,20 @@ describe('SupportFormPage', () => { }) }) + test('loading a URL with explicit no project ref falls back to first organization and no project', async () => { + Object.defineProperty(window, 'location', { + value: createMockLocation(`?projectRef=${NO_PROJECT_MARKER}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + }) + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { mswServer.use( http.get(`${API_URL}/platform/projects/:ref`, () => @@ -597,10 +688,13 @@ describe('SupportFormPage', () => { renderSupportFormPage() - await waitFor(() => { - expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') - expect(getProjectSelector(screen)).toHaveTextContent('Project 1') - }) + await waitFor( + () => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }, + { timeout: 5_000 } + ) await selectCategoryOption(screen, 'Dashboard bug') await waitFor(() => { @@ -1043,6 +1137,241 @@ describe('SupportFormPage', () => { } }, 10_000) + test('shows dashboard logs toggle only for Dashboard bug issues', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + + await selectCategoryOption(screen, 'APIs and client libraries') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + await waitFor(() => { + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + const dashboardLogToggleAgain = await getDashboardLogsToggle(screen) + expect(dashboardLogToggleAgain).toBeChecked() + }) + + test('skips dashboard log upload when toggle is disabled', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async () => ({ + data: { path: 'dashboard-logs/mock.log.json' }, + error: null, + })) + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + await userEvent.click(dashboardLogToggle!) + expect(dashboardLogToggle).not.toBeChecked() + + await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing') + await userEvent.type(getMessageField(screen), 'Charts throw error on load') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).not.toHaveBeenCalled() + expect(createSignedUrls).not.toHaveBeenCalled() + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Charts throw error on load') + expect(payload.message).not.toContain('Dashboard logs:') + }) + + test('skips dashboard log upload when toggle hidden', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async () => ({ + data: { path: 'dashboard-logs/mock.log.json' }, + error: null, + })) + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument() + + await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing') + await userEvent.type(getMessageField(screen), 'Charts throw error on load') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).not.toHaveBeenCalled() + expect(createSignedUrls).not.toHaveBeenCalled() + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Charts throw error on load') + expect(payload.message).not.toContain('Dashboard logs:') + }) + + test('uploads dashboard logs when enabled and appends link to message', async () => { + const submitSpy = vi.fn() + const upload = vi.fn(async (path: string) => ({ data: { path }, error: null })) + const createSignedUrls = vi.fn(async (paths: string[], _expiry: number) => ({ + data: paths.map((path) => ({ + signedUrl: `https://storage.example.com/signed/${path}`, + path, + error: null, + })), + error: null, + })) + + createSupportStorageClientMock.mockReturnValue({ + storage: { + from: vi.fn(() => ({ + upload, + createSignedUrls, + })), + }, + } as any) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + const dashboardLogToggle = await screen.findByRole('switch', { + name: /include dashboard activity log/i, + }) + expect(dashboardLogToggle).toBeChecked() + + await userEvent.type(getSummaryField(screen), 'Dashboard navigation broken') + await userEvent.type( + getMessageField(screen), + 'Navigation menu does not respond after latest deploy' + ) + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + expect(upload).toHaveBeenCalledTimes(1) + await waitFor(() => { + expect(generateAttachmentUrlSpy).toHaveBeenCalledTimes(1) + }) + expect(generateAttachmentUrlSpy.mock.calls[0]?.[0]).toMatchObject({ + bucket: 'dashboard-logs', + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.message).toContain('Navigation menu does not respond after latest deploy') + expect(payload.message).toMatch( + /Dashboard logs: https:\/\/storage\.example\.com\/signed\/.+\.json/ + ) + }) + test('shows toast on submission error and allows form re-editing and resubmission', async () => { const submitSpy = vi.fn() const toastErrorSpy = vi.fn() @@ -1111,9 +1440,10 @@ describe('SupportFormPage', () => { const payload = submitSpy.mock.calls[0]?.[0] expect(payload.subject).toBe('Cannot access settings') - expect(payload.message).toBe( + expect(payload.message).toMatch( 'Settings page shows 500 error - updated description' + supportVersionInfo ) + expect(payload.message).toMatch(/Dashboard logs: https:\/\/storage\.example\.com\/.+\.json/) await waitFor(() => { expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!') @@ -1149,7 +1479,6 @@ describe('SupportFormPage', () => { 'https://storage.example.com/signed/file2.jpg?token=def456', ] - const { createSupportStorageClient } = await import('../support-storage-client') const mockStorageClient = { storage: { from: vi.fn(() => ({ @@ -1160,7 +1489,8 @@ describe('SupportFormPage', () => { })), }, } - vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any) + + createSupportStorageClientMock.mockReturnValue(mockStorageClient as any) mswServer.use( http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => { @@ -1271,7 +1601,7 @@ describe('SupportFormPage', () => { unmount?.() url.createObjectURL = originalCreateObjectURL url.revokeObjectURL = originalRevokeObjectURL - vi.mocked(createSupportStorageClient).mockReset() + createSupportStorageClientMock.mockReset() } }, 10_000) @@ -1313,6 +1643,11 @@ describe('SupportFormPage', () => { expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') }) + const dashboardLogToggle = await getDashboardLogsToggle(screen) + expect(dashboardLogToggle).toBeChecked() + await userEvent.click(dashboardLogToggle!) + expect(dashboardLogToggle).not.toBeChecked() + await userEvent.type(getSummaryField(screen), 'Cannot access my account') await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account') diff --git a/apps/studio/components/interfaces/Support/dashboard-logs.ts b/apps/studio/components/interfaces/Support/dashboard-logs.ts new file mode 100644 index 00000000000..93da930b3a8 --- /dev/null +++ b/apps/studio/components/interfaces/Support/dashboard-logs.ts @@ -0,0 +1,77 @@ +import * as Sentry from '@sentry/nextjs' + +import { SupportCategories } from '@supabase/shared-types/out/constants' +import type { + GenerateAttachmentURLsData, + GenerateAttachmentURLsVariables, +} from 'data/support/generate-attachment-urls-mutation' +import { getMirroredBreadcrumbs, getOwnershipOfBreadcrumbSnapshot } from 'lib/breadcrumbs' +import { uuidv4 } from 'lib/helpers' +import { sanitizeArrayOfObjects } from 'lib/sanitize' +import { createSupportStorageClient } from './support-storage-client' +import type { ExtendedSupportCategories } from './Support.constants' + +export type DashboardBreadcrumb = Sentry.Breadcrumb + +export const DASHBOARD_LOG_BUCKET = 'dashboard-logs' + +export const DASHBOARD_LOG_CATEGORIES: ExtendedSupportCategories[] = [ + SupportCategories.DASHBOARD_BUG, +] + +export const getSanitizedBreadcrumbs = (): unknown[] => { + const breadcrumbs = getOwnershipOfBreadcrumbSnapshot() ?? getMirroredBreadcrumbs() + return sanitizeArrayOfObjects(breadcrumbs) +} + +export const uploadDashboardLog = async ({ + userId, + uploadDashboardLogFn, +}: { + userId: string | undefined + uploadDashboardLogFn: ( + vars: GenerateAttachmentURLsVariables + ) => Promise +}): Promise => { + if (!userId) { + console.error( + '[SupportForm > uploadDashboardLog] Cannot upload dashboard log: user ID is undefined' + ) + return [] + } + + const sanitized = getSanitizedBreadcrumbs() + if (sanitized.length === 0) return [] + + try { + const supportStorageClient = createSupportStorageClient() + const objectKey = `${userId}/${uuidv4()}.json` + const body = new Blob([JSON.stringify(sanitized, null, 2)], { + type: 'application/json', + }) + + const { error: uploadError } = await supportStorageClient.storage + .from(DASHBOARD_LOG_BUCKET) + .upload(objectKey, body, { + cacheControl: '3600', + contentType: 'application/json', + upsert: false, + }) + + if (uploadError) { + console.error( + '[SupportForm > uploadDashboardLog] Failed to upload dashboard log to support storage bucket', + uploadError + ) + return [] + } + + return uploadDashboardLogFn({ + bucket: DASHBOARD_LOG_BUCKET, + filenames: [objectKey], + }) + } catch (error) { + console.error('[SupportForm] Unexpected error uploading dashboard log', error) + return [] + } +} diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts index b3aa32e06c3..34575b4c873 100644 --- a/apps/studio/components/interfaces/Support/useSupportForm.ts +++ b/apps/studio/components/interfaces/Support/useSupportForm.ts @@ -24,6 +24,7 @@ const supportFormDefaultValues: DefaultValues = { message: '', affectedServices: '', allowSupportAccess: true, + attachDashboardLogs: true, dashboardSentryIssueId: '', } @@ -83,10 +84,7 @@ export function useSupportForm(dispatch: Dispatch): UseSuppo urlParamsRef.current.orgSlug && urlParamsRef.current.orgSlug !== NO_ORG_MARKER ? urlParamsRef.current.orgSlug : null - const projectRefFromUrl = - urlParamsRef.current.projectRef && urlParamsRef.current.projectRef !== NO_PROJECT_MARKER - ? urlParamsRef.current.projectRef - : null + const projectRefFromUrl = urlParamsRef.current.projectRef ?? null selectInitialOrgAndProject({ projectRef: projectRefFromUrl, diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx index b12a696178f..0b6a7975d45 100644 --- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -5,6 +5,7 @@ import { useParams } from 'common' import ClientLibrary from 'components/interfaces/Home/ClientLibrary' import { ExampleProject } from 'components/interfaces/Home/ExampleProject' import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings' import { useInvalidateProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query' import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query' @@ -112,7 +113,7 @@ const BuildingState = () => { support ticket.

    } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx index 54e30a670a7..c73d79a0562 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx @@ -1,7 +1,7 @@ import { Lightbulb, TriangleAlert } from 'lucide-react' -import Link from 'next/link' import { useState } from 'react' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui' import { FeedbackWidget } from './FeedbackWidget' @@ -42,13 +42,13 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
    What would you like to share?
    diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index ca0a7f839c2..cdd1f10dcfe 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -1,10 +1,10 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' +import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import { Download, MoreVertical, Trash } from 'lucide-react' -import Link from 'next/link' import { useState } from 'react' import { useParams } from 'common' import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import { InlineLink } from 'components/ui/InlineLink' @@ -69,11 +69,15 @@ export const PauseFailedState = () => {
    {
    {
    {
    diff --git a/apps/studio/components/ui/Error.tsx b/apps/studio/components/ui/Error.tsx index 52045c15951..c61a43c4f2b 100644 --- a/apps/studio/components/ui/Error.tsx +++ b/apps/studio/components/ui/Error.tsx @@ -1,3 +1,4 @@ +import { SupportLink } from 'components/interfaces/Support/SupportLink' import Link from 'next/link' import { useEffect } from 'react' import { Button } from 'ui' @@ -21,7 +22,7 @@ export default function EmptyPageState({ error }: any) { Head back

    diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx index c8539fd37ee..6747f5717f4 100644 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx @@ -3,6 +3,8 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Button, cn } from 'ui' import { Admonition } from 'ui-patterns' @@ -153,13 +155,16 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback > {!isRemoveChildError && !isInsertBeforeError && ( )} @@ -176,14 +181,17 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback )} {(isRemoveChildError || isInsertBeforeError) && ( - Still stuck? - + )}

    diff --git a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx index f6b7a6688b7..67018c65260 100644 --- a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx +++ b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx @@ -1,10 +1,11 @@ import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' import dayjs from 'dayjs' import { X } from 'lucide-react' -import Link from 'next/link' import { useEffect, useState } from 'react' +import { SupportCategories } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query' import { IS_PLATFORM } from 'lib/constants' import { Alert, Button } from 'ui' @@ -60,13 +61,16 @@ export const ProjectUpgradeFailedBanner = () => { actions={
    ) } diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 8c9db51c95c..976bdb575b5 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { PropsWithChildren } from 'react' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Button } from 'ui' @@ -35,11 +36,19 @@ export const UpgradePlanButton = ({ const href = billingAll ? propsHref ?? `/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}` - : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}` + : '' + const linkChildren = children || `Upgrade to ${plan}` + const link = billingAll ? ( + {linkChildren} + ) : ( + + {linkChildren} + + ) return ( ) } diff --git a/apps/studio/data/support/generate-attachment-urls-mutation.ts b/apps/studio/data/support/generate-attachment-urls-mutation.ts index cf6cfc4ec99..edd36078e7a 100644 --- a/apps/studio/data/support/generate-attachment-urls-mutation.ts +++ b/apps/studio/data/support/generate-attachment-urls-mutation.ts @@ -12,7 +12,7 @@ export type GenerateAttachmentURLsResponse = { export type GenerateAttachmentURLsVariables = { filenames: string[] - bucket?: 'support-attachments' | 'feedback-attachments' + bucket?: 'support-attachments' | 'feedback-attachments' | 'dashboard-logs' } export async function generateAttachmentURLs({ @@ -45,7 +45,7 @@ export async function generateAttachmentURLs({ } } -type GenerateAttachmentURLsData = Awaited> +export type GenerateAttachmentURLsData = Awaited> export const useGenerateAttachmentURLsMutation = ({ onSuccess, diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index bafe2feee59..5c5cb94c5c7 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -3,9 +3,12 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' +import { match } from 'path-to-regexp' + import { hasConsented } from 'common' import { IS_PLATFORM } from 'common/constants/environment' -import { match } from 'path-to-regexp' +import { MIRRORED_BREADCRUMBS } from 'lib/breadcrumbs' +import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from 'lib/sanitize' // This is a workaround to ignore hCaptcha related errors. function isHCaptchaRelatedError(event: Sentry.Event): boolean { @@ -48,6 +51,21 @@ Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, + beforeBreadcrumb(breadcrumb, _hint) { + const cleanedBreadcrumb = { ...breadcrumb } + + if (cleanedBreadcrumb.category === 'navigation') { + if (typeof cleanedBreadcrumb.data?.from === 'string') { + cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from) + } + if (typeof cleanedBreadcrumb.data?.to === 'string') { + cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to) + } + } + + MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb) + return cleanedBreadcrumb + }, beforeSend(event, hint) { const consent = hasConsented() @@ -90,6 +108,9 @@ Sentry.init({ return null } + if (event.breadcrumbs) { + event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[] + } return event }, ignoreErrors: [ diff --git a/apps/studio/lib/breadcrumbs.ts b/apps/studio/lib/breadcrumbs.ts new file mode 100644 index 00000000000..2cf662eac90 --- /dev/null +++ b/apps/studio/lib/breadcrumbs.ts @@ -0,0 +1,21 @@ +import type * as Sentry from '@sentry/nextjs' + +import { RingBuffer } from './ringBuffer' + +export const MIRRORED_BREADCRUMBS = new RingBuffer(50) + +export const getMirroredBreadcrumbs = (): Sentry.Breadcrumb[] => { + return MIRRORED_BREADCRUMBS.toArray() +} + +let BREADCRUMB_SNAPSHOT: Sentry.Breadcrumb[] | null = null + +export const takeBreadcrumbSnapshot = (): void => { + BREADCRUMB_SNAPSHOT = getMirroredBreadcrumbs() +} + +export const getOwnershipOfBreadcrumbSnapshot = (): Sentry.Breadcrumb[] | null => { + const snapshot = BREADCRUMB_SNAPSHOT + BREADCRUMB_SNAPSHOT = null + return snapshot +} diff --git a/apps/studio/lib/ringBuffer.test.ts b/apps/studio/lib/ringBuffer.test.ts new file mode 100644 index 00000000000..5aaac0d6042 --- /dev/null +++ b/apps/studio/lib/ringBuffer.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' + +import { RingBuffer } from './ringBuffer' + +describe('RingBuffer', () => { + it('follows FIFO order when popping from the front', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.popFront()).toBe(1) + expect(buffer.popFront()).toBe(2) + expect(buffer.popFront()).toBe(3) + expect(buffer.popFront()).toBeUndefined() + }) + + it('supports popping from the back', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.popBack()).toBe(3) + expect(buffer.popBack()).toBe(2) + expect(buffer.popBack()).toBe(1) + expect(buffer.popBack()).toBeUndefined() + }) + + it('drops the oldest element when full', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + + expect(buffer.length).toBe(3) + expect(buffer.popFront()).toBe(2) + expect(buffer.popFront()).toBe(3) + expect(buffer.popFront()).toBe(4) + }) + + it('handles mixed operations correctly', () => { + const buffer = new RingBuffer(2) + + buffer.pushBack('a') + buffer.pushBack('b') + + expect(buffer.popFront()).toBe('a') + + buffer.pushBack('c') + buffer.pushBack('d') + + expect(buffer.length).toBe(2) + expect(buffer.popBack()).toBe('d') + expect(buffer.popFront()).toBe('c') + expect(buffer.popFront()).toBeUndefined() + }) + + it('returns undefined when popping from an empty buffer', () => { + const buffer = new RingBuffer(1) + + expect(buffer.popFront()).toBeUndefined() + expect(buffer.popBack()).toBeUndefined() + }) + + it('requires a positive integer capacity', () => { + expect(() => new RingBuffer(0)).toThrow('positive integer') + expect(() => new RingBuffer(-1)).toThrow('positive integer') + expect(() => new RingBuffer(1.5 as unknown as number)).toThrow('positive integer') + }) + + it('returns the full contents in order via toArray', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + + expect(buffer.toArray()).toEqual([1, 2, 3]) + }) + + it('supports slice-style bounds for toArray', () => { + const buffer = new RingBuffer(5) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + + expect(buffer.toArray(1, 3)).toEqual([2, 3]) + expect(buffer.toArray(2)).toEqual([3, 4]) + }) + + it('handles negative and overflowing bounds in toArray', () => { + const buffer = new RingBuffer(4) + + buffer.pushBack(10) + buffer.pushBack(20) + buffer.pushBack(30) + buffer.pushBack(40) + + expect(buffer.toArray(-2)).toEqual([30, 40]) + expect(buffer.toArray(0, -1)).toEqual([10, 20, 30]) + expect(buffer.toArray(-5, 10)).toEqual([10, 20, 30, 40]) + }) + + it('returns an empty array when the slice is empty', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + + expect(buffer.toArray(5)).toEqual([]) + expect(buffer.toArray(2, 2)).toEqual([]) + expect(buffer.toArray(2, 1)).toEqual([]) + + const emptyBuffer = new RingBuffer(3) + expect(emptyBuffer.toArray()).toEqual([]) + expect(emptyBuffer.toArray(1)).toEqual([]) + }) + + it('returns entries in order after overwriting oldest values', () => { + const buffer = new RingBuffer(3) + + buffer.pushBack(1) + buffer.pushBack(2) + buffer.pushBack(3) + buffer.pushBack(4) + buffer.pushBack(5) + + expect(buffer.toArray()).toEqual([3, 4, 5]) + expect(buffer.toArray(1)).toEqual([4, 5]) + expect(buffer.toArray(-1)).toEqual([5]) + }) +}) diff --git a/apps/studio/lib/ringBuffer.ts b/apps/studio/lib/ringBuffer.ts new file mode 100644 index 00000000000..0f1b21e2ea7 --- /dev/null +++ b/apps/studio/lib/ringBuffer.ts @@ -0,0 +1,87 @@ +export class RingBuffer { + private readonly capacity: number + private readonly buffer: (T | undefined)[] + private head = 0 + private tail = 0 + private size = 0 + + constructor(capacity: number) { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new Error('RingBuffer capacity must be a positive integer') + } + + this.capacity = capacity + this.buffer = new Array(capacity).fill(undefined) + } + + get length(): number { + return this.size + } + + pushBack(value: T): void { + this.buffer[this.tail] = value + + if (this.size === this.capacity) { + this.head = (this.head + 1) % this.capacity + } else { + this.size += 1 + } + + this.tail = (this.tail + 1) % this.capacity + } + + popFront(): T | undefined { + if (this.size === 0) { + return undefined + } + + const value = this.buffer[this.head] + this.buffer[this.head] = undefined + this.head = (this.head + 1) % this.capacity + this.size -= 1 + + return value + } + + popBack(): T | undefined { + if (this.size === 0) { + return undefined + } + + const index = (this.tail - 1 + this.capacity) % this.capacity + const value = this.buffer[index] + this.buffer[index] = undefined + this.tail = index + this.size -= 1 + + return value + } + + toArray(start?: number, end?: number): T[] { + const len = this.size + + let startIndex = start === undefined ? 0 : Math.trunc(start) + if (startIndex < 0) { + startIndex = Math.max(len + startIndex, 0) + } else { + startIndex = Math.min(startIndex, len) + } + + let endIndex = end === undefined ? len : Math.trunc(end) + if (endIndex < 0) { + endIndex = Math.max(len + endIndex, 0) + } else { + endIndex = Math.min(endIndex, len) + } + + const sliceLength = Math.max(endIndex - startIndex, 0) + const result = new Array(sliceLength) + + for (let offset = 0; offset < sliceLength; offset += 1) { + const physicalIndex = (this.head + startIndex + offset) % this.capacity + result[offset] = this.buffer[physicalIndex] as T + } + + return result + } +} diff --git a/apps/studio/lib/sanitize.test.ts b/apps/studio/lib/sanitize.test.ts new file mode 100644 index 00000000000..f0707ef4671 --- /dev/null +++ b/apps/studio/lib/sanitize.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeArrayOfObjects } from './sanitize' + +describe('sanitizeArrayOfObjects', () => { + it('redacts sensitive keys case-insensitively', () => { + const input = [{ Password: 'hunter2', username: 'alice' }] + + const result = sanitizeArrayOfObjects(input) as Array> + + expect(result).toEqual([{ Password: '[REDACTED]', username: 'alice' }]) + }) + + it('honors custom redaction and extra sensitive keys', () => { + const input = [ + { + customSensitive: 'value', + token: 'should hide', + nested: { customSensitive: 'also hide' }, + }, + ] + + const result = sanitizeArrayOfObjects(input, { + redaction: '', + sensitiveKeys: ['customSensitive'], + }) as Array + + expect(result[0].customSensitive).toBe('') + expect(result[0].token).toBe('') + expect(result[0].nested).toEqual({ customSensitive: '' }) + expect(input[0].nested.customSensitive).toBe('also hide') + }) + + it('redacts known secret patterns in strings', () => { + const samples = [ + { value: '192.168.0.1' }, + { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' }, + { value: 'AKIAIOSFODNN7EXAMPLE' }, + { value: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }, + { value: 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789' }, + { + value: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + }, + { value: 'A'.repeat(32) }, + ] + + const result = sanitizeArrayOfObjects(samples) as Array<{ value: string }> + + for (const item of result) { + expect(item.value).toBe('[REDACTED]') + } + }) + + it('limits recursion depth and uses truncation notice', () => { + const input = [ + { + level1: { + level2: { + level3: { + password: 'secret', + }, + }, + }, + }, + ] + + const [result] = sanitizeArrayOfObjects(input, { + maxDepth: 2, + truncationNotice: '', + }) as Array + + expect(result.level1.level2).toBe('') + expect(result.level1).not.toBe(input[0].level1) + expect(input[0].level1.level2.level3.password).toBe('secret') + }) + + it('handles circular references without crashing', () => { + const obj: any = { name: 'loop' } + obj.self = obj + + const [result] = sanitizeArrayOfObjects([obj]) as Array + + expect(result.self).toBe('[Circular]') + expect(result.name).toBe('loop') + }) + + it('sanitizes complex types consistently', () => { + const date = new Date('2024-01-01T00:00:00.000Z') + const regex = /abc/gi + const fn = () => {} + const arrayBuffer = new ArrayBuffer(8) + const typedArray = new Uint8Array([1, 2, 3]) + const map = new Map() + map.set('password', 'hunter2') + map.set('public', date) + const set = new Set([1, date]) + const url = new URL('https://example.com/path') + const error = new Error('Token is Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789') + const custom = new (class Custom { + toString() { + return 'custom-instance' + } + })() + + const [result] = sanitizeArrayOfObjects([ + { + date, + regex, + fn, + arrayBuffer, + typedArray, + map, + set, + url, + error, + custom, + }, + ]) as Array + + expect(result.date).toBe('2024-01-01T00:00:00.000Z') + expect(result.regex).toBe('/abc/gi') + expect(result.fn).toBe('[Function]') + expect(result.arrayBuffer).toBe('[ArrayBuffer byteLength=8]') + expect(result.typedArray).toBe('[TypedArray byteLength=3]') + expect(result.map).toEqual([ + ['[REDACTED]', '[REDACTED]'], + ['public', '2024-01-01T00:00:00.000Z'], + ]) + expect(result.set).toEqual([1, '2024-01-01T00:00:00.000Z']) + expect(result.url).toBe('https://example.com/path') + expect(result.error).toEqual({ + name: 'Error', + message: 'Token is [REDACTED]', + stack: '[REDACTED: max depth reached]', + }) + expect(result.custom).toBe('custom-instance') + }) + + it('sanitizes primitive array entries', () => { + const [redacted, number] = sanitizeArrayOfObjects([ + 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789', + 42, + ]) as Array + + expect(redacted).toBe('[REDACTED]') + expect(number).toBe(42) + }) + + it('applies maxDepth=0 to top-level entries', () => { + const result = sanitizeArrayOfObjects( + [{ password: 'secret', nested: { value: 'test' } }, 'visible'], + { + maxDepth: 0, + truncationNotice: '', + } + ) as Array + + expect(result[0]).toBe('') + expect(result[1]).toBe('visible') + }) +}) diff --git a/apps/studio/lib/sanitize.ts b/apps/studio/lib/sanitize.ts new file mode 100644 index 00000000000..c6ae53b5414 --- /dev/null +++ b/apps/studio/lib/sanitize.ts @@ -0,0 +1,218 @@ +export function sanitizeUrlHashParams(url: string): string { + return url.split('#')[0] +} + +/** + * Best-effort sanitizer for arrays of objects. + * - Redacts likely secrets by key name (password, token, apiKey, etc.) + * - Redacts likely secrets by value pattern (IPv4/IPv6, AWS keys, Bearer/JWT, generic long tokens) + * - Recurses into nested arrays/objects up to `maxDepth`; beyond that replaces with a notice + * - Handles circular references + * + * @param {any[]} inputArr - Array of items to sanitize (non-objects are copied as-is). + * @param {Object} [opts] + * @param {number} [opts.maxDepth=3] - Maximum depth to traverse (0 == only top level). + * @param {string} [opts.redaction="[REDACTED]"] - Replacement text for sensitive values. + * @param {string} [opts.truncationNotice="[REDACTED: max depth reached]"] - Used when depth limit is hit. + * @param {string[]} [opts.sensitiveKeys] - Extra key names to treat as sensitive (case-insensitive). + * @returns {any[]} a deeply-sanitized clone of the input array + */ +export function sanitizeArrayOfObjects( + inputArr: unknown[], + opts: { + maxDepth?: number + redaction?: string + truncationNotice?: string + sensitiveKeys?: string[] + } = {} +): unknown[] { + const { + maxDepth = 3, + redaction = '[REDACTED]', + truncationNotice = '[REDACTED: max depth reached]', + sensitiveKeys = [], + } = opts + + // Common sensitive key names (case-insensitive). Extendable via opts.sensitiveKeys. + const sensitiveKeySet = new Set( + [ + 'password', + 'passwd', + 'pwd', + 'pass', + 'secret', + 'token', + 'id_token', + 'access_token', + 'refresh_token', + 'apikey', + 'api_key', + 'api-key', + 'apiKey', + 'key', + 'privatekey', + 'private_key', + 'client_secret', + 'clientSecret', + 'auth', + 'authorization', + 'ssh_key', + 'sshKey', + 'bearer', + 'session', + 'cookie', + 'csrf', + 'xsrf', + 'ip', + 'ip_address', + 'ipAddress', + 'aws_access_key_id', + 'aws_secret_access_key', + 'gcp_service_account_key', + ...sensitiveKeys, + ].map((k) => k.toLowerCase()) + ) + + // Value patterns that often indicate secrets or PII + const patterns = [ + // IPv4 + { re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, reason: 'ip' }, + // IPv6 (simplified but effective) + { re: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b/g, reason: 'ip6' }, + // AWS Access Key ID (starts with AKIA/ASIA, 16 remaining upper alnum) + { re: /\b(AKI|ASI)A[0-9A-Z]{16}\b/g, reason: 'aws_access_key_id' }, + // AWS Secret Access Key (40 base64-ish chars) + { re: /\b[0-9A-Za-z/+]{40}\b/g, reason: 'aws_secret_access_key_like' }, + // Bearer tokens + { re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g, reason: 'bearer' }, + // JWT (three base64url segments separated by dots) + { re: /\b[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\b/g, reason: 'jwt_like' }, + // Generic long API-ish token (conservative: 24–64 safe chars) + { re: /\b[A-Za-z0-9_\-]{24,64}\b/g, reason: 'long_token' }, + ] + + const seen = new WeakMap() + + function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null + } + + function redactString(str: string) { + let out = str + for (const { re } of patterns) out = out.replace(re, redaction) + return out + } + + function shouldRedactByKey(key: string | symbol | number) { + return sensitiveKeySet.has(String(key).toLowerCase()) + } + + function sanitizeValue(value: unknown, depth: number): unknown { + if ( + value == null || + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' + ) { + return value + } + + if (typeof value === 'string') { + return redactString(value) + } + + if (typeof value === 'function') { + return '[Function]' + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (value instanceof RegExp) { + return value.toString() + } + + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return `[TypedArray byteLength=${value.byteLength}]` + } + if (value instanceof ArrayBuffer) { + return `[ArrayBuffer byteLength=${value.byteLength}]` + } + + if (depth >= maxDepth) { + return truncationNotice + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]' + } + + if (Array.isArray(value)) { + const outArr: unknown[] = [] + seen.set(value, outArr) + for (let i = 0; i < value.length; i++) { + outArr[i] = sanitizeValue(value[i], depth + 1) + } + return outArr + } + + if (isPlainObject(value)) { + const outObj: Record = {} + seen.set(value, outObj) + for (const [k, v] of Object.entries(value)) { + if (shouldRedactByKey(k)) { + outObj[k] = redaction + } else { + outObj[k] = sanitizeValue(v, depth + 1) + } + } + return outObj + } + + if (value instanceof Map) { + const out: unknown[] = [] + seen.set(value, out) + for (const [k, v] of value.entries()) { + const redactedKey = shouldRedactByKey(k) ? redaction : sanitizeValue(k, depth + 1) + const redactedVal = shouldRedactByKey(k) ? redaction : sanitizeValue(v, depth + 1) + out.push([redactedKey, redactedVal]) + } + return out + } + + if (value instanceof Set) { + const out: unknown[] = [] + seen.set(value, out) + for (const v of value.values()) { + out.push(sanitizeValue(v, depth + 1)) + } + return out + } + + if (value instanceof URL) return value.toString() + if (value instanceof Error) { + const o = { + name: value.name, + message: redactString(value.message), + stack: truncationNotice, + } + seen.set(value, o) + return o + } + + try { + return redactString(String(value)) + } catch { + return redactString(Object.prototype.toString.call(value)) + } + } + + return redactString(String(value)) + } + + return inputArr.map((item) => sanitizeValue(item, 0)) +} diff --git a/apps/studio/pages/500.tsx b/apps/studio/pages/500.tsx index 72ff3bbb328..e37f834dd87 100644 --- a/apps/studio/pages/500.tsx +++ b/apps/studio/pages/500.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { LOCAL_STORAGE_KEYS } from 'common' +import { SupportLink } from 'components/interfaces/Support/SupportLink' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSignOut } from 'lib/auth' import { Button } from 'ui' @@ -69,7 +70,7 @@ const Error500: NextPage = () => { )}
    diff --git a/apps/studio/pages/api/generate-attachment-url.ts b/apps/studio/pages/api/generate-attachment-url.ts index 87c3b892117..1bb8fcbff42 100644 --- a/apps/studio/pages/api/generate-attachment-url.ts +++ b/apps/studio/pages/api/generate-attachment-url.ts @@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js' import type { NextApiRequest, NextApiResponse } from 'next' import z from 'zod' +import { DASHBOARD_LOG_BUCKET } from 'components/interfaces/Support/dashboard-logs' import apiWrapper from 'lib/api/apiWrapper' import { getUserClaims } from 'lib/gotrue' @@ -9,7 +10,9 @@ export const maxDuration = 120 const GenerateAttachmentUrlSchema = z.object({ filenames: z.array(z.string()), - bucket: z.enum(['support-attachments', 'feedback-attachments']).default('support-attachments'), + bucket: z + .enum(['support-attachments', 'feedback-attachments', DASHBOARD_LOG_BUCKET]) + .default('support-attachments'), }) async function handlePost(req: NextApiRequest, res: NextApiResponse) { diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 1aca37d67f9..54d41edfe57 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -81,8 +81,8 @@ const LabelContainerVariants = cva('transition-all duration-500 ease-in-out', { layout: { horizontal: 'flex flex-col gap-2 col-span-4', vertical: 'flex flex-row gap-2 justify-between', - flex: 'flex flex-col gap-0', - 'flex-row-reverse': 'flex flex-col', + flex: 'flex flex-col gap-0 min-w-0', + 'flex-row-reverse': 'flex flex-col min-w-0', }, labelLayout: { horizontal: '',