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 <joshenlimek@gmail.com>
This commit is contained in:
CharisandJoshen Lim authored and GitHub committed 2025-10-22 08:57:49 -04:00
1 parent 1418fc9af6
commit d8f7cc0d57
54 files changed
+1501 -202

No files matched your search

@@ -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 = () => {
<div className="px-5 py-2 bg-surface-200/30">
<p className="text-sm text-foreground-lighter">
Having trouble with the new API keys?{' '}
<a
href="/dashboard/support/new?category=Problem&subject=Help%20with%20API%20keys&message=I'm%20experiencing%20problems%20with%20the%20new%20API%20keys%20feature.%20Please%20describe%20your%20specific%20issue%20here."
className="inline-flex items-center gap-1 text-foreground"
<SupportLink
className="underline"
queryParams={{
category: SupportCategories.PROBLEM,
subject: 'Help with API keys',
message:
"I'm experiencing problems with the new API keys feature. Please describe your specific issue here.",
}}
>
Contact support
</a>
</SupportLink>
</p>
</div>
</FeatureBanner>
@@ -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)
@@ -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 = () => {
<Button type="outline" asChild>
{subscription?.payment_method_type === 'invoice' ? (
<Link
href={`/support/new?slug=${slug}&projectRef=no-project&message=${encodeURIComponent('I would like to change my payment method')}&category=${SupportCategories.BILLING}`}
<SupportLink
queryParams={{
category: SupportCategories.BILLING,
message: 'I would like to change my payment method',
}}
>
Contact support
</Link>
</SupportLink>
) : (
<Link href={`/org/${slug}/billing#payment-methods`}>Manage payment methods</Link>
)}
@@ -1,11 +1,11 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
import { CreditCardIcon, Plus } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import AddNewPaymentMethodModal from 'components/interfaces/Billing/Payment/AddNewPaymentMethodModal'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import {
ScaffoldSection,
ScaffoldSectionContent,
@@ -99,14 +99,15 @@ const PaymentMethods = () => {
title="Payment is currently by invoice"
actions={[
<Button key="payment-method-support" asChild type="default">
<Link
href={`/support/new?category=billing&subject=Request%20to%20change%20payment%20method`}
target="_blank"
rel="noreferrer"
<SupportLink
className="ml-3"
queryParams={{
category: SupportCategories.BILLING,
subject: 'Request to change payment method',
}}
>
Contact support
</Link>
</SupportLink>
</Button>,
]}
>
@@ -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 = () => {
}
>
<Button key="contact-support" asChild type="default">
<Link
href={`/support/new?projectRef=${project?.ref}&category=dashboard_bug&subject=Unable%20to%20view%20database%20migrations`}
<SupportLink
queryParams={{
projectRef: project?.ref,
category: SupportCategories.DASHBOARD_BUG,
subject: 'Unable to view database migrations',
}}
>
Contact support
</Link>
</SupportLink>
</Button>
</Admonition>
)}
@@ -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={
<Link
href={`/support/new?projectRef=${ref}&category=sales&subject=Enquiry%20about%20larger%20instance%20sizes`}
<SupportLink
queryParams={{
projectRef: ref,
category: SupportCategories.SALES_ENQUIRY,
subject: 'Enquiry about larger instance sizes',
}}
>
<div className="w-full flex flex-col gap-3 justify-between">
<div className="relative px-3 flex justify-between">
@@ -334,7 +339,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
</div>
</div>
</div>
</Link>
</SupportLink>
}
/>
</>
@@ -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.
</p>
<p className="prose text-sm">
For larger discounted credit packages, please{' '}
<Link
href={`/support/new?slug=${slug}&subject=${encodeURIComponent('I would like to inquire about larger credit packages')}&category=${SupportCategories.SALES_ENQUIRY}`}
target="_blank"
For larger discounted credit packages, please reach out to us via{' '}
<SupportLink
queryParams={{
orgSlug: slug,
projectRef: NO_PROJECT_MARKER,
subject: 'I would like to inquire about larger credit packages',
category: SupportCategories.SALES_ENQUIRY,
}}
>
reach out.
</Link>
support
</SupportLink>
.
</p>
</DialogDescription>
</DialogHeader>
@@ -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={[
<div key="contact-support">
<Button asChild type="default">
<Link
href={`/support/new?category=sales&subject=Change%20plan%20away%20from%20${planName}`}
>
Contact support
</Link>
</Button>
</div>,
<Button asChild key="contact-support" type="default">
<SupportLink
queryParams={{
category: SupportCategories.SALES_ENQUIRY,
subject: `Change plan away from ${planName}`,
}}
>
Contact support
</SupportLink>
</Button>,
]}
>
Please contact us if you'd like to change your plan.
@@ -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 = () => {
<ScaffoldContainer>
<ScaffoldSection>
<p className="sticky space-y-6 top-12 text-sm text-foreground-light m-0 whitespace-nowrap">
<Link href="/support/new" className="hover:underline">
Submit a support request
</Link>{' '}
if you require additional documents for financial or tax reasons, such as a W-9 form.
<SupportLink className={InlineLinkClassName}>Submit a support request</SupportLink> if
you require additional documents for financial or tax reasons, such as a W-9 form.
</p>
</ScaffoldSection>
</ScaffoldContainer>
@@ -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 = () => {
</AlertDescription_Shadcn_>
<div className="mt-4">
<Button type="default" asChild>
<Link href="/support/new">Contact support</Link>
<SupportLink>Contact support</SupportLink>
</Button>
</div>
</Alert_Shadcn_>
@@ -487,11 +489,15 @@ export const Addons = () => {
Reach out to us via support if you're interested
</p>
<Button asChild type="default">
<Link
href={`/support/new?projectRef=${projectRef}&category=sales&subject=Project%20too%20old%20old%20for%20PITR`}
<SupportLink
queryParams={{
projectRef,
category: SupportCategories.SALES_ENQUIRY,
subject: 'Project too old old for PITR',
}}
>
<a>Contact support</a>
</Link>
</SupportLink>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
@@ -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 = () => {
</AlertDescription_Shadcn_>
<div className="mt-4">
<Button type="default" asChild>
<Link href="/support/new">Contact support</Link>
<SupportLink>Contact support</SupportLink>
</Button>
</div>
</Alert_Shadcn_>
@@ -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.
</p>
<Button asChild type="default" className="mt-3">
<Link
href={`/support/new?projectRef=${projectRef}&category=${SupportCategories.PERFORMANCE_ISSUES}&subject=Increase%20disk%20size%20beyond%20200GB`}
<SupportLink
queryParams={{
projectRef,
category: SupportCategories.PERFORMANCE_ISSUES,
subject: 'Increase disk size beyond 200GB',
}}
>
Contact support
</Link>
</SupportLink>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
@@ -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{' '}
<Link
href="/support/new"
target="_blank"
rel="noreferrer"
className="underline"
>
support
</Link>{' '}
if this error persists
<SupportLink className={InlineLinkClassName}>support</SupportLink> if this
error persists
</Alert>
)}
</div>
@@ -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 = () => {
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm text-foreground-light">
Failed to retrieve custom domain configuration. Please try again later or{' '}
<Link href={`/support/new?projectRef=${ref}&category=sales`} className="underline">
<SupportLink
queryParams={{ projectRef: ref, category: SupportCategories.SALES_ENQUIRY }}
className={InlineLinkClassName}
>
contact support
</Link>
</SupportLink>
.
</p>
</div>
@@ -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 = ({
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-2">
<Button type="default">
<Link
href={`/support/new?category=Sales&ref=${projectRef}&subject=Enquiry%20on%20read%20replicas&message=Project%20DB%20version:%20${project?.dbVersion}`}
target="_blank"
rel="noreferrer"
<SupportLink
queryParams={{
projectRef,
category: SupportCategories.SALES_ENQUIRY,
subject: 'Enquiry on read replicas',
message: `Project DB version: ${project?.dbVersion}`,
}}
>
Contact support
</Link>
</SupportLink>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
@@ -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 = ({
</ul>
<p>
If none of these steps work, please{' '}
<InlineLink href={`/support/new?subject=Session%20timed%20out`}>
<SupportLink
className={InlineLinkClassName}
queryParams={{ subject: 'Session timed out' }}
>
Contact support
</InlineLink>
</SupportLink>
.
</p>
<p>
@@ -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) => {
</Link>
</li>
<li>
<Link
target="_blank"
rel="noreferrer"
href="/support/new?subject=Unable+to+sign+in+via+MFA&category=Login_issues"
<SupportLink
className="text-sm transition text-foreground-light hover:text-foreground"
queryParams={{
subject: 'Unable to sign in via MFA',
category: SupportCategories.LOGIN_ISSUES,
}}
>
Reach out to us via support
</Link>
</SupportLink>
</li>
</ul>
</div>
@@ -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={[
<Button key="contact-support" asChild type="default" className="ml-4">
<Link
href={`/support/new?projectRef=${ref}&category=dashboard_bug&subject=Unable%20to%20fetch%20storage%20buckets`}
<SupportLink
queryParams={{
projectRef: ref,
category: SupportCategories.DASHBOARD_BUG,
subject: 'Unable to fetch storage buckets',
}}
>
Contact support
</Link>
</SupportLink>
</Button>,
]}
>
@@ -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<SupportFormValues>
@@ -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'
@@ -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<SupportFormValues>
}
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 (
<FormField_Shadcn_
name="attachDashboardLogs"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="attachDashboardLogs"
className="px-6"
layout="flex"
label={
<div className="flex items-center gap-x-2">
<span className="text-foreground">Include dashboard activity log</span>
</div>
}
description={
<div className="flex flex-col gap-y-2">
<span className="text-foreground-light">
Share sanitized logs of recent dashboard actions to help reproduce the issue.
</span>
<Collapsible_Shadcn_ open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<CollapsibleTrigger_Shadcn_ className="group flex items-center gap-x-1 text-sm text-foreground-lighter hover:text-foreground transition">
<ChevronRight
size={14}
className="transition-transform group-data-[state=open]:rotate-90"
/>
<span>Preview log</span>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="mt-2">
<pre className="bg-background-surface-200 border border-strong rounded-lg p-3 max-h-60 overflow-y-auto overflow-x-auto text-xs text-foreground-light whitespace-pre-wrap">
{sanitizedLogJson}
</pre>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
</div>
}
>
<Switch
size="large"
id="attachDashboardLogs"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormItemLayout>
)}
/>
)
}
@@ -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
<p className="text-sm text-foreground-light">
We will reach out to you at <span className="text-foreground">{respondToEmail}</span>.
</p>
{selectedProject !== 'no-project' && (
{selectedProject !== NO_PROJECT_MARKER && (
<p className="text-sm text-foreground-light">
Your ticket has been logged for the project{' '}
<span className="text-foreground">{projectName}</span>, reference ID:{' '}
@@ -35,6 +35,7 @@ export function SupportAccessToggle({ form }: SupportAccessToggleProps) {
render={({ field }) => {
return (
<FormItemLayout
hideMessage
name="allowSupportAccess"
className="px-6"
layout="flex"
@@ -56,7 +57,6 @@ export function SupportAccessToggle({ form }: SupportAccessToggleProps) {
}
>
<ChevronRight
strokeWidth={2}
size={14}
className="transition-all group-data-[state=open]:rotate-90 text-foreground-muted -ml-1"
/>
@@ -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(),
})
@@ -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<SupportFormUrlKeys>) {
const serializedParams = serializeSupportFormInitialParams(initialParams)
return `/support/new${serializedParams ?? ''}`
}
@@ -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'
@@ -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<SupportFormValues> = async (values) => {
const onSubmit: SubmitHandler<SupportFormValues> = 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
<DialogSectionSeparator />
<div className="px-6 flex flex-col gap-y-8">
{SUPPORT_ACCESS_CATEGORIES.includes(category) && (
<>
<SupportAccessToggle form={form} />
<Separator />
</>
)}
{DASHBOARD_LOG_CATEGORIES.includes(category) && (
<>
<DashboardLogsToggle form={form} />
<DialogSectionSeparator />
</>
)}
{SUPPORT_ACCESS_CATEGORIES.includes(category) && (
<>
<SupportAccessToggle form={form} />
<DialogSectionSeparator />
</>
)}
<div className="px-6 pt-2">
<SubmitButton
isSubmitting={state.type === 'submitting'}
userEmail={respondToEmail}
@@ -0,0 +1,21 @@
import Link from 'next/link'
import type { ComponentProps, PropsWithChildren } from 'react'
import { takeBreadcrumbSnapshot } from 'lib/breadcrumbs'
import { createSupportFormUrl, type SupportFormUrlKeys } from './SupportForm.utils'
export const SupportLink = ({
children,
queryParams,
...props
}: PropsWithChildren<
{ queryParams?: Partial<SupportFormUrlKeys> } & Omit<ComponentProps<typeof Link>, 'href'>
>) => {
const href = createSupportFormUrl(queryParams ?? {})
return (
<Link {...props} href={href} onClick={takeBreadcrumbSnapshot}>
{children}
</Link>
)
}
@@ -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<typeof vi.fn>
let getBreadcrumbSnapshotMock: ReturnType<typeof vi.fn>
let generateAttachmentUrlSpy: ReturnType<typeof vi.fn>
// 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<HTMLFormElement>('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')
@@ -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<GenerateAttachmentURLsData>
}): Promise<string[]> => {
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 []
}
}
@@ -24,6 +24,7 @@ const supportFormDefaultValues: DefaultValues<SupportFormValues> = {
message: '',
affectedServices: '',
allowSupportAccess: true,
attachDashboardLogs: true,
dashboardSentryIssueId: '',
}
@@ -83,10 +84,7 @@ export function useSupportForm(dispatch: Dispatch<SupportFormActions>): 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,
@@ -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.
</p>
<Button asChild type="default">
<Link href="/support/new">Contact support team</Link>
<SupportLink>Contact support team</SupportLink>
</Button>
</>
}
@@ -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 }) => {
<div className="font-medium text-sm">What would you like to share?</div>
<div className="grid grid-cols-2 gap-3">
<Button type="default" className="h-32" onClick={() => setIsOpen(false)} asChild>
<Link href="/support/new">
<SupportLink>
<span className="grid gap-1 text-center">
<TriangleAlert size="28" className="mx-auto text-destructive-600" />
<span className="text-base">Issue</span>
<span className="text-xm text-foreground-lighter">with my project</span>
</span>
</Link>
</SupportLink>
</Button>
<Button type="default" className="h-32" onClick={() => setStage('widget')}>
<span className="grid gap-1 text-center">
@@ -2,13 +2,13 @@ import { useDebounce } from '@uidotdev/usehooks'
import { AnimatePresence, motion } from 'framer-motion'
import { toPng } from 'html-to-image'
import { Camera, CircleCheck, Image as ImageIcon, Upload, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ChangeEvent, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { InlineLink } from 'components/ui/InlineLink'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { InlineLinkClassName } from 'components/ui/InlineLink'
import { useFeedbackCategoryQuery } from 'data/feedback/feedback-category'
import { useSendFeedbackMutation } from 'data/feedback/feedback-send'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
@@ -19,6 +19,7 @@ import { timeout } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -185,12 +186,15 @@ export const FeedbackWidget = ({ onClose }: FeedbackWidgetProps) => {
>
<p className="text-xs text-foreground-light !leading-tight">
Please{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/support/new/?projectRef=${slug}&message=${encodeURIComponent(feedback)}`}
<SupportLink
className={cn(
InlineLinkClassName,
'text-foreground-light hover:text-foreground'
)}
queryParams={{ projectRef: slug, message: feedback }}
>
open a support ticket
</InlineLink>{' '}
</SupportLink>{' '}
to get help with this issue, as we do not reply to all product feedback.
</p>
</Admonition>
@@ -206,11 +210,11 @@ export const FeedbackWidget = ({ onClose }: FeedbackWidgetProps) => {
<p className="text-xs text-foreground">Have a technical issue?</p>
<p className="text-xs text-foreground-light">
Contact{' '}
<Link href="/support/new">
<SupportLink>
<span className="cursor-pointer text-brand transition-colors hover:text-brand-600">
support
</span>
</Link>{' '}
</SupportLink>{' '}
or{' '}
<a href={`${DOCS_URL}`} target="_blank" rel="noreferrer">
<span className="cursor-pointer text-brand transition-colors hover:text-brand-600">
@@ -319,11 +323,11 @@ const ThanksMessage = ({ onClose }: { onClose: () => void }) => {
<PopoverSeparator_Shadcn_ />
<div className="flex items-center justify-between px-4">
<p className="text-xs text-foreground-light">
<Link href="/support/new">
<SupportLink>
<span className="cursor-pointer text-brand transition-colors hover:text-brand-600">
Create a Support Ticket
</span>
</Link>
</SupportLink>
</p>
<Button type="default" onClick={onClose}>
@@ -1,9 +1,9 @@
import { Activity, BookOpen, HelpCircle, Mail, MessageCircle, Wrench } from 'lucide-react'
import Image from 'next/legacy/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import SVG from 'react-inlinesvg'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -29,8 +29,7 @@ export const HelpPopover = () => {
const { mutate: sendEvent } = useSendEventMutation()
const projectRef = project?.parent_project_ref ?? router.query.ref
const supportUrl = `/support/new${projectRef ? `?projectRef=${projectRef}` : ''}`
const projectRef = project?.parent_project_ref ?? (router.query.ref as string | undefined)
return (
<Popover_Shadcn_>
@@ -118,7 +117,7 @@ export const HelpPopover = () => {
</a>
</ButtonGroupItem>
<ButtonGroupItem size="tiny" icon={<Mail strokeWidth={1.5} size={14} />}>
<Link href={supportUrl}>Contact Support</Link>
<SupportLink queryParams={{ projectRef }}>Contact Support</SupportLink>
</ButtonGroupItem>
</ButtonGroup>
</div>
@@ -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 = () => {
<div className="border-t border-overlay flex items-center justify-end gap-x-2 py-4 px-8">
<Button asChild type="default">
<Link
href={`/support/new?category=Database_unresponsive&ref=${project?.ref}&subject=Pausing%20failed%20for%20project`}
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref,
subject: 'Pausing failed for project',
}}
>
Contact support
</Link>
</SupportLink>
</Button>
<ButtonTooltip
type="default"
@@ -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 RestoreFailedState = () => {
<div className="border-t border-overlay flex items-center justify-end py-4 px-8 gap-x-2">
<Button asChild type="default">
<Link
href={`/support/new?category=Database_unresponsive&ref=${project?.ref}&subject=Restoration%20failed%20for%20project`}
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref,
subject: 'Restoration failed for project',
}}
>
Contact support
</Link>
</SupportLink>
</Button>
<ButtonTooltip
@@ -1,8 +1,9 @@
import { CheckCircle, Download, Loader } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useBackupDownloadMutation } from 'data/database/backup-download-mutation'
import { useDownloadableBackupQuery } from 'data/database/backup-query'
@@ -110,11 +111,15 @@ const RestoringState = () => {
</div>
<div className="border-t border-overlay flex items-center justify-end py-4 px-8 gap-x-2">
<Button asChild type="default">
<Link
href={`/support/new?category=Database_unresponsive&ref=${project?.ref}&subject=Ongoing%20restoration%20for%20project`}
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: project?.ref,
subject: 'Ongoing restoration for project',
}}
>
Contact support
</Link>
</SupportLink>
</Button>
<ButtonTooltip
type="default"
@@ -10,11 +10,12 @@ import {
Minimize2,
Settings,
} from 'lucide-react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { 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 { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -108,13 +109,16 @@ const UpgradingState = () => {
</div>
<div className="flex items-center mx-auto space-x-2">
<Button asChild type="default">
<Link
href={`/support/new?category=Database_unresponsive&ref=${ref}&subject=${subject}&message=${message}`}
target="_blank"
rel="noreferrer"
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: ref,
subject,
message,
}}
>
Contact support
</Link>
</SupportLink>
</Button>
<Button loading={loading} disabled={loading} onClick={refetchProjectDetails}>
Return to project
+12 -9
View File
@@ -1,4 +1,5 @@
import Link from 'next/link'
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { PropsWithChildren } from 'react'
import {
@@ -29,13 +30,6 @@ export const AlertError = ({
children,
additionalActions,
}: PropsWithChildren<AlertErrorProps>) => {
const subjectString = subject?.replace(/ /g, '%20')
let href = `/support/new?category=dashboard_bug`
if (projectRef) href += `&ref=${projectRef}`
if (subjectString) href += `&subject=${subjectString}`
if (error) href += `&error=${error.message}`
const formattedErrorMessage = error?.message?.includes('503')
? '503 Service Temporarily Unavailable'
: error?.message
@@ -56,7 +50,16 @@ export const AlertError = ({
<div className="flex gap-2">
{additionalActions}
<Button asChild type="warning" className="w-min">
<Link href={href}>Contact support</Link>
<SupportLink
queryParams={{
category: SupportCategories.DASHBOARD_BUG,
projectRef,
subject,
error: error?.message,
}}
>
Contact support
</SupportLink>
</Button>
</div>
</AlertDescription_Shadcn_>
+2 -1
View File
@@ -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) {
<Link href="/projects">Head back</Link>
</Button>
<Button asChild type="secondary">
<Link href="/support/new">Submit a support request</Link>
<SupportLink>Submit a support request</SupportLink>
</Button>
</div>
<p className="text-sm text-foreground-light">
@@ -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 && (
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopenner noreferrer"
href={`/support/new?category=dashboard_bug${sentryIssueId ? `&sid=${sentryIssueId}` : ''}&subject=Client%20side%20exception%20occurred%20on%20dashboard&message=${encodeURI(urlMessage)}`}
<SupportLink
queryParams={{
category: SupportCategories.DASHBOARD_BUG,
subject: 'Client side exception occurred on dashboard',
sid: sentryIssueId,
message: urlMessage,
}}
>
Contact support
</Link>
</SupportLink>
</Button>
)}
@@ -176,14 +181,17 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback
)}
{(isRemoveChildError || isInsertBeforeError) && (
<Link
target="_blank"
rel="noopenner noreferrer"
<SupportLink
className="text-center text-xs text-foreground-lighter hover:text-foreground-light transition"
href={`/support/new?category=dashboard_bug${sentryIssueId ? `&sid=${sentryIssueId}` : ''}&subject=Client%20side%20exception%20occurred%20on%20dashboard&message=${encodeURI(urlMessage)}`}
queryParams={{
category: SupportCategories.DASHBOARD_BUG,
subject: 'Client side exception occurred on dashboard',
sid: sentryIssueId,
message: urlMessage,
}}
>
Still stuck?
</Link>
</SupportLink>
)}
</div>
</div>
@@ -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={
<div className="flex items-center h-full space-x-4">
<Button asChild type="default">
<Link
href={`/support/new?category=Database_unresponsive&ref=${ref}&subject=${subject}&message=${message}`}
target="_blank"
rel="noreferrer"
<SupportLink
queryParams={{
category: SupportCategories.DATABASE_UNRESPONSIVE,
projectRef: ref,
subject,
message,
}}
>
Contact support
</Link>
</SupportLink>
</Button>
<Button
type="text"
@@ -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'
@@ -23,15 +24,21 @@ export const ToggleSpendCapButton = ({
const subject = `Enquiry to ${action} spend cap for organization`
const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}`
const href = billingAll
? `/org/${slug}/billing?panel=costControl`
: `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
const href = billingAll ? `/org/${slug}/billing?panel=costControl` : ''
const linkChildren = children || `${action} spend cap`
const link = billingAll ? (
<Link href={href} className="capitalize">
{linkChildren}
</Link>
) : (
<SupportLink queryParams={{ orgSlug: slug, category: 'Plan_upgrade', subject, message }}>
{linkChildren}
</SupportLink>
)
return (
<Button type={type} asChild>
<Link href={href} className="capitalize">
{children || `${action} spend cap`}
</Link>
{link}
</Button>
)
}
@@ -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 ? (
<Link href={href}>{linkChildren}</Link>
) : (
<SupportLink queryParams={{ orgSlug: slug, category: 'Plan_upgrade', subject, message }}>
{linkChildren}
</SupportLink>
)
return (
<Button asChild type={type} disabled={disabled}>
<Link href={href}>{children || `Upgrade to ${plan}`}</Link>
{link}
</Button>
)
}
@@ -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<ReturnType<typeof generateAttachmentURLs>>
export type GenerateAttachmentURLsData = Awaited<ReturnType<typeof generateAttachmentURLs>>
export const useGenerateAttachmentURLsMutation = ({
onSuccess,
+22 -1
View File
@@ -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: [
+21
View File
@@ -0,0 +1,21 @@
import type * as Sentry from '@sentry/nextjs'
import { RingBuffer } from './ringBuffer'
export const MIRRORED_BREADCRUMBS = new RingBuffer<Sentry.Breadcrumb>(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
}
+139
View File
@@ -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<number>(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<number>(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<number>(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<string>(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<number>(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<number>(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<number>(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<number>(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<number>(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<number>(3)
expect(emptyBuffer.toArray()).toEqual([])
expect(emptyBuffer.toArray(1)).toEqual([])
})
it('returns entries in order after overwriting oldest values', () => {
const buffer = new RingBuffer<number>(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])
})
})
+87
View File
@@ -0,0 +1,87 @@
export class RingBuffer<T> {
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<T | undefined>(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<T>(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
}
}
+162
View File
@@ -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<Record<string, unknown>>
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: '<removed>',
sensitiveKeys: ['customSensitive'],
}) as Array<any>
expect(result[0].customSensitive).toBe('<removed>')
expect(result[0].token).toBe('<removed>')
expect(result[0].nested).toEqual({ customSensitive: '<removed>' })
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: '<truncated>',
}) as Array<any>
expect(result.level1.level2).toBe('<truncated>')
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<any>
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<any, any>()
map.set('password', 'hunter2')
map.set('public', date)
const set = new Set<any>([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<any>
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<any>
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: '<blocked>',
}
) as Array<any>
expect(result[0]).toBe('<blocked>')
expect(result[1]).toBe('visible')
})
})
+218
View File
@@ -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<string, unknown> {
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<string | symbol | number, unknown> = {}
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))
}
+2 -1
View File
@@ -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 = () => {
<Button onClick={onClickLogout}>Head back</Button>
)}
<Button type="secondary" asChild>
<Link href="/support/new">Submit a support request</Link>
<SupportLink>Submit a support request</SupportLink>
</Button>
</div>
</div>
@@ -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) {
@@ -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: '',