mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
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)
|
||||
|
||||
+7
-3
@@ -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>
|
||||
|
||||
+12
-10
@@ -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>
|
||||
|
||||
+9
-4
@@ -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>
|
||||
|
||||
+10
-5
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
+3
-3
@@ -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">
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
@@ -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_>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
Reference in new issue
Block a user