(
+
+ Include dashboard activity log
+
+ }
+ description={
+
+
+ Share sanitized logs of recent dashboard actions to help reproduce the issue.
+
+
+
+
+ Preview log
+
+
+
+ {sanitizedLogJson}
+
+
+
+
+ }
+ >
+
+
+ )}
+ />
+ )
+}
diff --git a/apps/studio/components/interfaces/Support/Success.tsx b/apps/studio/components/interfaces/Support/Success.tsx
index 4d428c6a376..1ad3a17cbe1 100644
--- a/apps/studio/components/interfaces/Support/Success.tsx
+++ b/apps/studio/components/interfaces/Support/Success.tsx
@@ -6,19 +6,23 @@ import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useProfile } from 'lib/profile'
import { Button, Input, Separator } from 'ui'
import { CATEGORY_OPTIONS } from './Support.constants'
+import { NO_PROJECT_MARKER } from './SupportForm.utils'
interface SuccessProps {
sentCategory?: string
selectedProject?: string
}
-export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: SuccessProps) => {
+export const Success = ({
+ sentCategory = '',
+ selectedProject = NO_PROJECT_MARKER,
+}: SuccessProps) => {
const { profile } = useProfile()
const respondToEmail = profile?.primary_email ?? 'your email'
const { data: project } = useProjectDetailQuery(
{ ref: selectedProject },
- { enabled: selectedProject !== 'no-project' }
+ { enabled: selectedProject !== NO_PROJECT_MARKER }
)
const projectName = project ? project.name : 'No specific project'
@@ -40,7 +44,7 @@ export const Success = ({ sentCategory = '', selectedProject = 'no-project' }: S
We will reach out to you at {respondToEmail}.
- {selectedProject !== 'no-project' && (
+ {selectedProject !== NO_PROJECT_MARKER && (
Your ticket has been logged for the project{' '}
{projectName}, reference ID:{' '}
diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx
index bdb5b8df1ee..a5b49bc2e01 100644
--- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx
+++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx
@@ -35,6 +35,7 @@ export function SupportAccessToggle({ form }: SupportAccessToggleProps) {
render={({ field }) => {
return (
diff --git a/apps/studio/components/interfaces/Support/SupportForm.schema.ts b/apps/studio/components/interfaces/Support/SupportForm.schema.ts
index 79c9af8cfeb..29b160027f8 100644
--- a/apps/studio/components/interfaces/Support/SupportForm.schema.ts
+++ b/apps/studio/components/interfaces/Support/SupportForm.schema.ts
@@ -20,6 +20,7 @@ const createFormSchema = (showClientLibraries: boolean) => {
message: z.string().min(1, "Please add a message about the issue that you're facing"),
affectedServices: z.string(),
allowSupportAccess: z.boolean(),
+ attachDashboardLogs: z.boolean(),
dashboardSentryIssueId: z.string().optional(),
})
diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx
index 991fbcb3c03..853600d4d76 100644
--- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx
+++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx
@@ -3,16 +3,16 @@ import {
createLoader,
createParser,
createSerializer,
- parseAsString,
type inferParserType,
+ parseAsString,
type UseQueryStatesKeysMap,
} from 'nuqs'
// End of third-party imports
import {
- DocsSearchResultType as PageType,
type DocsSearchResult as Page,
type DocsSearchResultSection as PageSection,
+ DocsSearchResultType as PageType,
} from 'common'
import { getProjectDetail } from 'data/projects/project-detail-query'
import dayjs from 'dayjs'
@@ -28,11 +28,13 @@ export const formatMessage = ({
attachments = [],
error,
commit,
+ dashboardLogUrl,
}: {
message: string
attachments?: string[]
error: string | null | undefined
commit: { commitSha: string; commitTime: string } | undefined
+ dashboardLogUrl?: string
}) => {
const errorString = error != null ? `\n\nError: ${error}` : ''
const attachmentsString =
@@ -41,7 +43,8 @@ export const formatMessage = ({
commit != undefined
? `\n\n---\nSupabase Studio version: SHA ${commit.commitSha} deployed at ${commit.commitTime === 'unknown' ? 'unknown time' : dayjs(commit.commitTime).format('YYYY-MM-DD HH:mm:ss Z')}`
: ''
- return `${message}${errorString}${attachmentsString}${commitString}`
+ const logString = dashboardLogUrl ? `\nDashboard logs: ${dashboardLogUrl}` : ''
+ return `${message}${errorString}${attachmentsString}${commitString}${logString}`
}
export function getPageIcon(page: Page) {
@@ -119,8 +122,8 @@ const parseAsCategoryOption = createParser({
})
const supportFormUrlState = {
- projectRef: parseAsString.withDefault(NO_PROJECT_MARKER),
- orgSlug: parseAsString.withDefault(NO_ORG_MARKER),
+ projectRef: parseAsString.withDefault(''),
+ orgSlug: parseAsString.withDefault(''),
category: parseAsCategoryOption,
subject: parseAsString.withDefault(''),
message: parseAsString.withDefault(''),
@@ -134,7 +137,7 @@ export const loadSupportFormInitialParams = createLoader(supportFormUrlState)
const serializeSupportFormInitialParams = createSerializer(supportFormUrlState)
-export function createSupportFormUrl(initialParams: SupportFormUrlKeys) {
+export function createSupportFormUrl(initialParams: Partial) {
const serializedParams = serializeSupportFormInitialParams(initialParams)
return `/support/new${serializedParams ?? ''}`
}
diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx
index e4d48d0b9a4..064a4c6fe22 100644
--- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx
+++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx
@@ -23,8 +23,8 @@ import type { SupportFormValues } from './SupportForm.schema'
import {
createInitialSupportFormState,
type SupportFormActions,
- supportFormReducer,
type SupportFormState,
+ supportFormReducer,
} from './SupportForm.state'
import { SupportFormV2 } from './SupportFormV2'
import { useSupportForm } from './useSupportForm'
diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx
index 04b62436b14..398857db16b 100644
--- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx
+++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx
@@ -8,10 +8,11 @@ import { CLIENT_LIBRARIES } from 'common/constants'
import { getProjectAuthConfig } from 'data/auth/auth-config-query'
import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation'
import { useDeploymentCommitQuery } from 'data/utils/deployment-commit-query'
import { detectBrowser } from 'lib/helpers'
import { useProfile } from 'lib/profile'
-import { DialogSectionSeparator, Form_Shadcn_, Separator } from 'ui'
+import { DialogSectionSeparator, Form_Shadcn_ } from 'ui'
import {
AffectedServicesSelector,
CATEGORIES_WITHOUT_AFFECTED_SERVICES,
@@ -19,6 +20,7 @@ import {
import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload'
import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo'
import { ClientLibraryInfo } from './ClientLibraryInfo'
+import { DashboardLogsToggle } from './DashboardLogsToggle'
import { MessageField } from './MessageField'
import { OrganizationSelector } from './OrganizationSelector'
import { ProjectAndPlanInfo } from './ProjectAndPlanInfo'
@@ -33,6 +35,7 @@ import {
NO_ORG_MARKER,
NO_PROJECT_MARKER,
} from './SupportForm.utils'
+import { DASHBOARD_LOG_CATEGORIES, uploadDashboardLog } from './dashboard-logs'
const useIsSimplifiedForm = (slug: string) => {
const simplifiedSupportForm = useFlag('simplifiedSupportForm')
@@ -64,6 +67,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug)
const attachmentUpload = useAttachmentUpload()
+ const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation()
const { data: commit } = useDeploymentCommitQuery({
staleTime: 1000 * 60 * 10, // 10 minutes
@@ -86,9 +90,19 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
},
})
- const onSubmit: SubmitHandler = async (values) => {
+ const onSubmit: SubmitHandler = async (formValues) => {
dispatch({ type: 'SUBMIT' })
- const attachments = await attachmentUpload.createAttachments()
+
+ const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues
+ const attachDashboardLogs =
+ formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category)
+
+ const [attachments, dashboardLogUrl] = await Promise.all([
+ attachmentUpload.createAttachments(),
+ attachDashboardLogs
+ ? uploadDashboardLog({ userId: profile?.gotrue_id, uploadDashboardLogFn })
+ : undefined,
+ ])
const selectedLibrary = values.library
? CLIENT_LIBRARIES.find((library) => library.language === values.library)
@@ -111,6 +125,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
attachments,
error: initialError,
commit,
+ dashboardLogUrl: dashboardLogUrl?.[0],
}),
verified: true,
tags: ['dashboard-support-form'],
@@ -195,13 +210,21 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo
-
- {SUPPORT_ACCESS_CATEGORIES.includes(category) && (
- <>
-
-
- >
- )}
+ {DASHBOARD_LOG_CATEGORIES.includes(category) && (
+ <>
+
+
+ >
+ )}
+
+ {SUPPORT_ACCESS_CATEGORIES.includes(category) && (
+ <>
+
+
+ >
+ )}
+
+
} & Omit, 'href'>
+>) => {
+ const href = createSupportFormUrl(queryParams ?? {})
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx
index 5f6f45a3f09..03ec0e4e773 100644
--- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx
+++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx
@@ -88,11 +88,22 @@ vi.mock('react-inlinesvg', () => ({
default: () => null,
}))
-// Mock the support storage client module - will be configured per test
vi.mock('../support-storage-client', () => ({
createSupportStorageClient: vi.fn(),
}))
+vi.mock(import('lib/breadcrumbs'), async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getOwnershipOfBreadcrumbSnapshot: vi.fn(),
+ }
+})
+
+let createSupportStorageClientMock: ReturnType
+let getBreadcrumbSnapshotMock: ReturnType
+let generateAttachmentUrlSpy: ReturnType
+
// Mock sonner toast
vi.mock('sonner', () => ({
toast: {
@@ -221,6 +232,13 @@ const selectLibraryOption = async (screen: Screen, optionLabel: string) => {
await userEvent.click(option)
}
+const getDashboardLogsToggle = (screen: Screen, type: 'find' | 'query' = 'find') => {
+ const labelMatcher = /dashboard .* log/i
+ return type === 'find'
+ ? screen.findByRole('switch', { name: labelMatcher })
+ : screen.queryByRole('switch', { name: labelMatcher })
+}
+
const getSupportForm = () => {
const form = document.querySelector('form#support-form')
expect(form).not.toBeNull()
@@ -273,10 +291,69 @@ describe('SupportFormPage', () => {
})
})
- beforeEach(() => {
+ beforeEach(async () => {
mockUseDeploymentCommitQuery.mockReturnValue({
data: { commitSha: mockCommitSha, commitTime: mockCommitTime },
})
+ const { createSupportStorageClient } = await import('../support-storage-client')
+ createSupportStorageClientMock = vi.mocked(createSupportStorageClient)
+ createSupportStorageClientMock.mockReset()
+ createSupportStorageClientMock.mockReturnValue({
+ storage: {
+ from: vi.fn(() => ({
+ upload: vi.fn(async (path: string) => ({
+ data: { path },
+ error: null,
+ })),
+ createSignedUrls: vi.fn(async (paths: string[]) => ({
+ data: paths.map((path) => ({
+ signedUrl: `https://storage.example.com/${path}`,
+ path,
+ error: null,
+ })),
+ error: null,
+ })),
+ })),
+ },
+ } as any)
+
+ generateAttachmentUrlSpy = vi.fn()
+ mswServer.use(
+ http.post('*/rest/v1/rpc/docs_search_fts', async () => {
+ return HttpResponse.json([])
+ }),
+ http.post('*/rest/v1/rpc/docs_search_fts_nimbus', async () => {
+ return HttpResponse.json([])
+ }),
+ http.post('*/functions/v1/search-embeddings', async () => {
+ return HttpResponse.json([])
+ }),
+ http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => {
+ const body = (await request.json()) as {
+ bucket?: string
+ filenames?: string[]
+ }
+ generateAttachmentUrlSpy(body)
+ const filenames = body.filenames ?? []
+ return HttpResponse.json(
+ filenames.map((filename) => `https://storage.example.com/signed/${filename}`)
+ )
+ })
+ )
+
+ const breadcrumbsModule = await import('lib/breadcrumbs')
+ getBreadcrumbSnapshotMock = vi.mocked(breadcrumbsModule.getOwnershipOfBreadcrumbSnapshot)
+ getBreadcrumbSnapshotMock.mockReset()
+ getBreadcrumbSnapshotMock.mockReturnValue([
+ {
+ timestamp: 1_710_000_000,
+ category: 'ui.action',
+ message: 'Clicked button',
+ level: 'info',
+ data: { route: '/project/_/dashboard' },
+ },
+ ])
+
Object.defineProperty(window.navigator, 'userAgent', {
value:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@@ -427,6 +504,20 @@ describe('SupportFormPage', () => {
})
})
+ test('loading a URL with explicit no project ref falls back to first organization and no project', async () => {
+ Object.defineProperty(window, 'location', {
+ value: createMockLocation(`?projectRef=${NO_PROJECT_MARKER}`),
+ writable: true,
+ })
+
+ renderSupportFormPage()
+
+ await waitFor(() => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ expect(getProjectSelector(screen)).toHaveTextContent('No specific project')
+ })
+ })
+
test('loading a URL with an invalid project slug falls back to first organization and project', async () => {
mswServer.use(
http.get(`${API_URL}/platform/projects/:ref`, () =>
@@ -597,10 +688,13 @@ describe('SupportFormPage', () => {
renderSupportFormPage()
- await waitFor(() => {
- expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
- expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
- })
+ await waitFor(
+ () => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ expect(getProjectSelector(screen)).toHaveTextContent('Project 1')
+ },
+ { timeout: 5_000 }
+ )
await selectCategoryOption(screen, 'Dashboard bug')
await waitFor(() => {
@@ -1043,6 +1137,241 @@ describe('SupportFormPage', () => {
}
}, 10_000)
+ test('shows dashboard logs toggle only for Dashboard bug issues', async () => {
+ renderSupportFormPage()
+
+ await waitFor(() => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ })
+
+ expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument()
+
+ await selectCategoryOption(screen, 'Dashboard bug')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug')
+ })
+
+ const dashboardLogToggle = await getDashboardLogsToggle(screen)
+ expect(dashboardLogToggle).toBeChecked()
+
+ await selectCategoryOption(screen, 'APIs and client libraries')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries')
+ })
+ await waitFor(() => {
+ expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument()
+ })
+
+ await selectCategoryOption(screen, 'Dashboard bug')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug')
+ })
+ const dashboardLogToggleAgain = await getDashboardLogsToggle(screen)
+ expect(dashboardLogToggleAgain).toBeChecked()
+ })
+
+ test('skips dashboard log upload when toggle is disabled', async () => {
+ const submitSpy = vi.fn()
+ const upload = vi.fn(async () => ({
+ data: { path: 'dashboard-logs/mock.log.json' },
+ error: null,
+ }))
+ const createSignedUrls = vi.fn(async (paths: string[]) => ({
+ data: paths.map((path) => ({
+ signedUrl: `https://storage.example.com/${path}`,
+ path,
+ error: null,
+ })),
+ error: null,
+ }))
+
+ createSupportStorageClientMock.mockReturnValue({
+ storage: {
+ from: vi.fn(() => ({
+ upload,
+ createSignedUrls,
+ })),
+ },
+ } as any)
+
+ addAPIMock({
+ method: 'post',
+ path: '/platform/feedback/send',
+ response: async ({ request }) => {
+ submitSpy(await request.json())
+ return HttpResponse.json({ ok: true })
+ },
+ })
+
+ renderSupportFormPage()
+
+ await waitFor(() => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ })
+
+ await selectCategoryOption(screen, 'Dashboard bug')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug')
+ })
+
+ const dashboardLogToggle = await getDashboardLogsToggle(screen)
+ expect(dashboardLogToggle).toBeChecked()
+ await userEvent.click(dashboardLogToggle!)
+ expect(dashboardLogToggle).not.toBeChecked()
+
+ await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing')
+ await userEvent.type(getMessageField(screen), 'Charts throw error on load')
+
+ await userEvent.click(getSubmitButton(screen))
+
+ await waitFor(() => {
+ expect(submitSpy).toHaveBeenCalledTimes(1)
+ })
+
+ expect(upload).not.toHaveBeenCalled()
+ expect(createSignedUrls).not.toHaveBeenCalled()
+
+ const payload = submitSpy.mock.calls[0]?.[0]
+ expect(payload.message).toContain('Charts throw error on load')
+ expect(payload.message).not.toContain('Dashboard logs:')
+ })
+
+ test('skips dashboard log upload when toggle hidden', async () => {
+ const submitSpy = vi.fn()
+ const upload = vi.fn(async () => ({
+ data: { path: 'dashboard-logs/mock.log.json' },
+ error: null,
+ }))
+ const createSignedUrls = vi.fn(async (paths: string[]) => ({
+ data: paths.map((path) => ({
+ signedUrl: `https://storage.example.com/${path}`,
+ path,
+ error: null,
+ })),
+ error: null,
+ }))
+
+ createSupportStorageClientMock.mockReturnValue({
+ storage: {
+ from: vi.fn(() => ({
+ upload,
+ createSignedUrls,
+ })),
+ },
+ } as any)
+
+ addAPIMock({
+ method: 'post',
+ path: '/platform/feedback/send',
+ response: async ({ request }) => {
+ submitSpy(await request.json())
+ return HttpResponse.json({ ok: true })
+ },
+ })
+
+ renderSupportFormPage()
+
+ await waitFor(() => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ })
+
+ await selectCategoryOption(screen, 'Database unresponsive')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive')
+ })
+
+ expect(getDashboardLogsToggle(screen, 'query')).not.toBeInTheDocument()
+
+ await userEvent.type(getSummaryField(screen), 'Dashboard charts crashing')
+ await userEvent.type(getMessageField(screen), 'Charts throw error on load')
+
+ await userEvent.click(getSubmitButton(screen))
+
+ await waitFor(() => {
+ expect(submitSpy).toHaveBeenCalledTimes(1)
+ })
+
+ expect(upload).not.toHaveBeenCalled()
+ expect(createSignedUrls).not.toHaveBeenCalled()
+
+ const payload = submitSpy.mock.calls[0]?.[0]
+ expect(payload.message).toContain('Charts throw error on load')
+ expect(payload.message).not.toContain('Dashboard logs:')
+ })
+
+ test('uploads dashboard logs when enabled and appends link to message', async () => {
+ const submitSpy = vi.fn()
+ const upload = vi.fn(async (path: string) => ({ data: { path }, error: null }))
+ const createSignedUrls = vi.fn(async (paths: string[], _expiry: number) => ({
+ data: paths.map((path) => ({
+ signedUrl: `https://storage.example.com/signed/${path}`,
+ path,
+ error: null,
+ })),
+ error: null,
+ }))
+
+ createSupportStorageClientMock.mockReturnValue({
+ storage: {
+ from: vi.fn(() => ({
+ upload,
+ createSignedUrls,
+ })),
+ },
+ } as any)
+
+ addAPIMock({
+ method: 'post',
+ path: '/platform/feedback/send',
+ response: async ({ request }) => {
+ submitSpy(await request.json())
+ return HttpResponse.json({ ok: true })
+ },
+ })
+
+ renderSupportFormPage()
+
+ await waitFor(() => {
+ expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1')
+ })
+
+ await selectCategoryOption(screen, 'Dashboard bug')
+ await waitFor(() => {
+ expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug')
+ })
+
+ const dashboardLogToggle = await screen.findByRole('switch', {
+ name: /include dashboard activity log/i,
+ })
+ expect(dashboardLogToggle).toBeChecked()
+
+ await userEvent.type(getSummaryField(screen), 'Dashboard navigation broken')
+ await userEvent.type(
+ getMessageField(screen),
+ 'Navigation menu does not respond after latest deploy'
+ )
+
+ await userEvent.click(getSubmitButton(screen))
+
+ await waitFor(() => {
+ expect(submitSpy).toHaveBeenCalledTimes(1)
+ })
+
+ expect(upload).toHaveBeenCalledTimes(1)
+ await waitFor(() => {
+ expect(generateAttachmentUrlSpy).toHaveBeenCalledTimes(1)
+ })
+ expect(generateAttachmentUrlSpy.mock.calls[0]?.[0]).toMatchObject({
+ bucket: 'dashboard-logs',
+ })
+
+ const payload = submitSpy.mock.calls[0]?.[0]
+ expect(payload.message).toContain('Navigation menu does not respond after latest deploy')
+ expect(payload.message).toMatch(
+ /Dashboard logs: https:\/\/storage\.example\.com\/signed\/.+\.json/
+ )
+ })
+
test('shows toast on submission error and allows form re-editing and resubmission', async () => {
const submitSpy = vi.fn()
const toastErrorSpy = vi.fn()
@@ -1111,9 +1440,10 @@ describe('SupportFormPage', () => {
const payload = submitSpy.mock.calls[0]?.[0]
expect(payload.subject).toBe('Cannot access settings')
- expect(payload.message).toBe(
+ expect(payload.message).toMatch(
'Settings page shows 500 error - updated description' + supportVersionInfo
)
+ expect(payload.message).toMatch(/Dashboard logs: https:\/\/storage\.example\.com\/.+\.json/)
await waitFor(() => {
expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!')
@@ -1149,7 +1479,6 @@ describe('SupportFormPage', () => {
'https://storage.example.com/signed/file2.jpg?token=def456',
]
- const { createSupportStorageClient } = await import('../support-storage-client')
const mockStorageClient = {
storage: {
from: vi.fn(() => ({
@@ -1160,7 +1489,8 @@ describe('SupportFormPage', () => {
})),
},
}
- vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any)
+
+ createSupportStorageClientMock.mockReturnValue(mockStorageClient as any)
mswServer.use(
http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => {
@@ -1271,7 +1601,7 @@ describe('SupportFormPage', () => {
unmount?.()
url.createObjectURL = originalCreateObjectURL
url.revokeObjectURL = originalRevokeObjectURL
- vi.mocked(createSupportStorageClient).mockReset()
+ createSupportStorageClientMock.mockReset()
}
}, 10_000)
@@ -1313,6 +1643,11 @@ describe('SupportFormPage', () => {
expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug')
})
+ const dashboardLogToggle = await getDashboardLogsToggle(screen)
+ expect(dashboardLogToggle).toBeChecked()
+ await userEvent.click(dashboardLogToggle!)
+ expect(dashboardLogToggle).not.toBeChecked()
+
await userEvent.type(getSummaryField(screen), 'Cannot access my account')
await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account')
diff --git a/apps/studio/components/interfaces/Support/dashboard-logs.ts b/apps/studio/components/interfaces/Support/dashboard-logs.ts
new file mode 100644
index 00000000000..93da930b3a8
--- /dev/null
+++ b/apps/studio/components/interfaces/Support/dashboard-logs.ts
@@ -0,0 +1,77 @@
+import * as Sentry from '@sentry/nextjs'
+
+import { SupportCategories } from '@supabase/shared-types/out/constants'
+import type {
+ GenerateAttachmentURLsData,
+ GenerateAttachmentURLsVariables,
+} from 'data/support/generate-attachment-urls-mutation'
+import { getMirroredBreadcrumbs, getOwnershipOfBreadcrumbSnapshot } from 'lib/breadcrumbs'
+import { uuidv4 } from 'lib/helpers'
+import { sanitizeArrayOfObjects } from 'lib/sanitize'
+import { createSupportStorageClient } from './support-storage-client'
+import type { ExtendedSupportCategories } from './Support.constants'
+
+export type DashboardBreadcrumb = Sentry.Breadcrumb
+
+export const DASHBOARD_LOG_BUCKET = 'dashboard-logs'
+
+export const DASHBOARD_LOG_CATEGORIES: ExtendedSupportCategories[] = [
+ SupportCategories.DASHBOARD_BUG,
+]
+
+export const getSanitizedBreadcrumbs = (): unknown[] => {
+ const breadcrumbs = getOwnershipOfBreadcrumbSnapshot() ?? getMirroredBreadcrumbs()
+ return sanitizeArrayOfObjects(breadcrumbs)
+}
+
+export const uploadDashboardLog = async ({
+ userId,
+ uploadDashboardLogFn,
+}: {
+ userId: string | undefined
+ uploadDashboardLogFn: (
+ vars: GenerateAttachmentURLsVariables
+ ) => Promise
+}): Promise => {
+ if (!userId) {
+ console.error(
+ '[SupportForm > uploadDashboardLog] Cannot upload dashboard log: user ID is undefined'
+ )
+ return []
+ }
+
+ const sanitized = getSanitizedBreadcrumbs()
+ if (sanitized.length === 0) return []
+
+ try {
+ const supportStorageClient = createSupportStorageClient()
+ const objectKey = `${userId}/${uuidv4()}.json`
+ const body = new Blob([JSON.stringify(sanitized, null, 2)], {
+ type: 'application/json',
+ })
+
+ const { error: uploadError } = await supportStorageClient.storage
+ .from(DASHBOARD_LOG_BUCKET)
+ .upload(objectKey, body, {
+ cacheControl: '3600',
+ contentType: 'application/json',
+ upsert: false,
+ })
+
+ if (uploadError) {
+ console.error(
+ '[SupportForm > uploadDashboardLog] Failed to upload dashboard log to support storage bucket',
+ uploadError
+ )
+ return []
+ }
+
+ return uploadDashboardLogFn({
+ bucket: DASHBOARD_LOG_BUCKET,
+ filenames: [objectKey],
+ })
+ } catch (error) {
+ console.error('[SupportForm] Unexpected error uploading dashboard log', error)
+ return []
+ }
+}
diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts
index b3aa32e06c3..34575b4c873 100644
--- a/apps/studio/components/interfaces/Support/useSupportForm.ts
+++ b/apps/studio/components/interfaces/Support/useSupportForm.ts
@@ -24,6 +24,7 @@ const supportFormDefaultValues: DefaultValues = {
message: '',
affectedServices: '',
allowSupportAccess: true,
+ attachDashboardLogs: true,
dashboardSentryIssueId: '',
}
@@ -83,10 +84,7 @@ export function useSupportForm(dispatch: Dispatch): UseSuppo
urlParamsRef.current.orgSlug && urlParamsRef.current.orgSlug !== NO_ORG_MARKER
? urlParamsRef.current.orgSlug
: null
- const projectRefFromUrl =
- urlParamsRef.current.projectRef && urlParamsRef.current.projectRef !== NO_PROJECT_MARKER
- ? urlParamsRef.current.projectRef
- : null
+ const projectRefFromUrl = urlParamsRef.current.projectRef ?? null
selectInitialOrgAndProject({
projectRef: projectRefFromUrl,
diff --git a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx
index b12a696178f..0b6a7975d45 100644
--- a/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/BuildingState.tsx
@@ -5,6 +5,7 @@ import { useParams } from 'common'
import ClientLibrary from 'components/interfaces/Home/ClientLibrary'
import { ExampleProject } from 'components/interfaces/Home/ExampleProject'
import { EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants'
+import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings'
import { useInvalidateProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useInvalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
@@ -112,7 +113,7 @@ const BuildingState = () => {
support ticket.
>
}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
index 54e30a670a7..c73d79a0562 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackDropdown.tsx
@@ -1,7 +1,7 @@
import { Lightbulb, TriangleAlert } from 'lucide-react'
-import Link from 'next/link'
import { useState } from 'react'
+import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_ } from 'ui'
import { FeedbackWidget } from './FeedbackWidget'
@@ -42,13 +42,13 @@ export const FeedbackDropdown = ({ className }: { className?: string }) => {
What would you like to share?
diff --git a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx
index f6b7a6688b7..67018c65260 100644
--- a/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx
+++ b/apps/studio/components/ui/ProjectUpgradeFailedBanner.tsx
@@ -1,10 +1,11 @@
import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events'
import dayjs from 'dayjs'
import { X } from 'lucide-react'
-import Link from 'next/link'
import { useEffect, useState } from 'react'
+import { SupportCategories } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
+import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query'
import { IS_PLATFORM } from 'lib/constants'
import { Alert, Button } from 'ui'
@@ -60,13 +61,16 @@ export const ProjectUpgradeFailedBanner = () => {
actions={
-
Contact support
-
+
+ {linkChildren}
+
+ ) : (
+
+ {linkChildren}
+
+ )
return (
-
- {children || `${action} spend cap`}
-
+ {link}
)
}
diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx
index 8c9db51c95c..976bdb575b5 100644
--- a/apps/studio/components/ui/UpgradePlanButton.tsx
+++ b/apps/studio/components/ui/UpgradePlanButton.tsx
@@ -1,6 +1,7 @@
import Link from 'next/link'
import { PropsWithChildren } from 'react'
+import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button } from 'ui'
@@ -35,11 +36,19 @@ export const UpgradePlanButton = ({
const href = billingAll
? propsHref ??
`/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}`
- : `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
+ : ''
+ const linkChildren = children || `Upgrade to ${plan}`
+ const link = billingAll ? (
+ {linkChildren}
+ ) : (
+
+ {linkChildren}
+
+ )
return (
- {children || `Upgrade to ${plan}`}
+ {link}
)
}
diff --git a/apps/studio/data/support/generate-attachment-urls-mutation.ts b/apps/studio/data/support/generate-attachment-urls-mutation.ts
index cf6cfc4ec99..edd36078e7a 100644
--- a/apps/studio/data/support/generate-attachment-urls-mutation.ts
+++ b/apps/studio/data/support/generate-attachment-urls-mutation.ts
@@ -12,7 +12,7 @@ export type GenerateAttachmentURLsResponse = {
export type GenerateAttachmentURLsVariables = {
filenames: string[]
- bucket?: 'support-attachments' | 'feedback-attachments'
+ bucket?: 'support-attachments' | 'feedback-attachments' | 'dashboard-logs'
}
export async function generateAttachmentURLs({
@@ -45,7 +45,7 @@ export async function generateAttachmentURLs({
}
}
-type GenerateAttachmentURLsData = Awaited>
+export type GenerateAttachmentURLsData = Awaited>
export const useGenerateAttachmentURLsMutation = ({
onSuccess,
diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts
index bafe2feee59..5c5cb94c5c7 100644
--- a/apps/studio/instrumentation-client.ts
+++ b/apps/studio/instrumentation-client.ts
@@ -3,9 +3,12 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs'
+import { match } from 'path-to-regexp'
+
import { hasConsented } from 'common'
import { IS_PLATFORM } from 'common/constants/environment'
-import { match } from 'path-to-regexp'
+import { MIRRORED_BREADCRUMBS } from 'lib/breadcrumbs'
+import { sanitizeArrayOfObjects, sanitizeUrlHashParams } from 'lib/sanitize'
// This is a workaround to ignore hCaptcha related errors.
function isHCaptchaRelatedError(event: Sentry.Event): boolean {
@@ -48,6 +51,21 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
+ beforeBreadcrumb(breadcrumb, _hint) {
+ const cleanedBreadcrumb = { ...breadcrumb }
+
+ if (cleanedBreadcrumb.category === 'navigation') {
+ if (typeof cleanedBreadcrumb.data?.from === 'string') {
+ cleanedBreadcrumb.data.from = sanitizeUrlHashParams(cleanedBreadcrumb.data.from)
+ }
+ if (typeof cleanedBreadcrumb.data?.to === 'string') {
+ cleanedBreadcrumb.data.to = sanitizeUrlHashParams(cleanedBreadcrumb.data.to)
+ }
+ }
+
+ MIRRORED_BREADCRUMBS.pushBack(cleanedBreadcrumb)
+ return cleanedBreadcrumb
+ },
beforeSend(event, hint) {
const consent = hasConsented()
@@ -90,6 +108,9 @@ Sentry.init({
return null
}
+ if (event.breadcrumbs) {
+ event.breadcrumbs = sanitizeArrayOfObjects(event.breadcrumbs) as Sentry.Breadcrumb[]
+ }
return event
},
ignoreErrors: [
diff --git a/apps/studio/lib/breadcrumbs.ts b/apps/studio/lib/breadcrumbs.ts
new file mode 100644
index 00000000000..2cf662eac90
--- /dev/null
+++ b/apps/studio/lib/breadcrumbs.ts
@@ -0,0 +1,21 @@
+import type * as Sentry from '@sentry/nextjs'
+
+import { RingBuffer } from './ringBuffer'
+
+export const MIRRORED_BREADCRUMBS = new RingBuffer(50)
+
+export const getMirroredBreadcrumbs = (): Sentry.Breadcrumb[] => {
+ return MIRRORED_BREADCRUMBS.toArray()
+}
+
+let BREADCRUMB_SNAPSHOT: Sentry.Breadcrumb[] | null = null
+
+export const takeBreadcrumbSnapshot = (): void => {
+ BREADCRUMB_SNAPSHOT = getMirroredBreadcrumbs()
+}
+
+export const getOwnershipOfBreadcrumbSnapshot = (): Sentry.Breadcrumb[] | null => {
+ const snapshot = BREADCRUMB_SNAPSHOT
+ BREADCRUMB_SNAPSHOT = null
+ return snapshot
+}
diff --git a/apps/studio/lib/ringBuffer.test.ts b/apps/studio/lib/ringBuffer.test.ts
new file mode 100644
index 00000000000..5aaac0d6042
--- /dev/null
+++ b/apps/studio/lib/ringBuffer.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it } from 'vitest'
+
+import { RingBuffer } from './ringBuffer'
+
+describe('RingBuffer', () => {
+ it('follows FIFO order when popping from the front', () => {
+ const buffer = new RingBuffer(5)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+
+ expect(buffer.popFront()).toBe(1)
+ expect(buffer.popFront()).toBe(2)
+ expect(buffer.popFront()).toBe(3)
+ expect(buffer.popFront()).toBeUndefined()
+ })
+
+ it('supports popping from the back', () => {
+ const buffer = new RingBuffer(3)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+
+ expect(buffer.popBack()).toBe(3)
+ expect(buffer.popBack()).toBe(2)
+ expect(buffer.popBack()).toBe(1)
+ expect(buffer.popBack()).toBeUndefined()
+ })
+
+ it('drops the oldest element when full', () => {
+ const buffer = new RingBuffer(3)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+ buffer.pushBack(4)
+
+ expect(buffer.length).toBe(3)
+ expect(buffer.popFront()).toBe(2)
+ expect(buffer.popFront()).toBe(3)
+ expect(buffer.popFront()).toBe(4)
+ })
+
+ it('handles mixed operations correctly', () => {
+ const buffer = new RingBuffer(2)
+
+ buffer.pushBack('a')
+ buffer.pushBack('b')
+
+ expect(buffer.popFront()).toBe('a')
+
+ buffer.pushBack('c')
+ buffer.pushBack('d')
+
+ expect(buffer.length).toBe(2)
+ expect(buffer.popBack()).toBe('d')
+ expect(buffer.popFront()).toBe('c')
+ expect(buffer.popFront()).toBeUndefined()
+ })
+
+ it('returns undefined when popping from an empty buffer', () => {
+ const buffer = new RingBuffer(1)
+
+ expect(buffer.popFront()).toBeUndefined()
+ expect(buffer.popBack()).toBeUndefined()
+ })
+
+ it('requires a positive integer capacity', () => {
+ expect(() => new RingBuffer(0)).toThrow('positive integer')
+ expect(() => new RingBuffer(-1)).toThrow('positive integer')
+ expect(() => new RingBuffer(1.5 as unknown as number)).toThrow('positive integer')
+ })
+
+ it('returns the full contents in order via toArray', () => {
+ const buffer = new RingBuffer(5)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+
+ expect(buffer.toArray()).toEqual([1, 2, 3])
+ })
+
+ it('supports slice-style bounds for toArray', () => {
+ const buffer = new RingBuffer(5)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+ buffer.pushBack(4)
+
+ expect(buffer.toArray(1, 3)).toEqual([2, 3])
+ expect(buffer.toArray(2)).toEqual([3, 4])
+ })
+
+ it('handles negative and overflowing bounds in toArray', () => {
+ const buffer = new RingBuffer(4)
+
+ buffer.pushBack(10)
+ buffer.pushBack(20)
+ buffer.pushBack(30)
+ buffer.pushBack(40)
+
+ expect(buffer.toArray(-2)).toEqual([30, 40])
+ expect(buffer.toArray(0, -1)).toEqual([10, 20, 30])
+ expect(buffer.toArray(-5, 10)).toEqual([10, 20, 30, 40])
+ })
+
+ it('returns an empty array when the slice is empty', () => {
+ const buffer = new RingBuffer(3)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+
+ expect(buffer.toArray(5)).toEqual([])
+ expect(buffer.toArray(2, 2)).toEqual([])
+ expect(buffer.toArray(2, 1)).toEqual([])
+
+ const emptyBuffer = new RingBuffer(3)
+ expect(emptyBuffer.toArray()).toEqual([])
+ expect(emptyBuffer.toArray(1)).toEqual([])
+ })
+
+ it('returns entries in order after overwriting oldest values', () => {
+ const buffer = new RingBuffer(3)
+
+ buffer.pushBack(1)
+ buffer.pushBack(2)
+ buffer.pushBack(3)
+ buffer.pushBack(4)
+ buffer.pushBack(5)
+
+ expect(buffer.toArray()).toEqual([3, 4, 5])
+ expect(buffer.toArray(1)).toEqual([4, 5])
+ expect(buffer.toArray(-1)).toEqual([5])
+ })
+})
diff --git a/apps/studio/lib/ringBuffer.ts b/apps/studio/lib/ringBuffer.ts
new file mode 100644
index 00000000000..0f1b21e2ea7
--- /dev/null
+++ b/apps/studio/lib/ringBuffer.ts
@@ -0,0 +1,87 @@
+export class RingBuffer {
+ private readonly capacity: number
+ private readonly buffer: (T | undefined)[]
+ private head = 0
+ private tail = 0
+ private size = 0
+
+ constructor(capacity: number) {
+ if (!Number.isInteger(capacity) || capacity <= 0) {
+ throw new Error('RingBuffer capacity must be a positive integer')
+ }
+
+ this.capacity = capacity
+ this.buffer = new Array(capacity).fill(undefined)
+ }
+
+ get length(): number {
+ return this.size
+ }
+
+ pushBack(value: T): void {
+ this.buffer[this.tail] = value
+
+ if (this.size === this.capacity) {
+ this.head = (this.head + 1) % this.capacity
+ } else {
+ this.size += 1
+ }
+
+ this.tail = (this.tail + 1) % this.capacity
+ }
+
+ popFront(): T | undefined {
+ if (this.size === 0) {
+ return undefined
+ }
+
+ const value = this.buffer[this.head]
+ this.buffer[this.head] = undefined
+ this.head = (this.head + 1) % this.capacity
+ this.size -= 1
+
+ return value
+ }
+
+ popBack(): T | undefined {
+ if (this.size === 0) {
+ return undefined
+ }
+
+ const index = (this.tail - 1 + this.capacity) % this.capacity
+ const value = this.buffer[index]
+ this.buffer[index] = undefined
+ this.tail = index
+ this.size -= 1
+
+ return value
+ }
+
+ toArray(start?: number, end?: number): T[] {
+ const len = this.size
+
+ let startIndex = start === undefined ? 0 : Math.trunc(start)
+ if (startIndex < 0) {
+ startIndex = Math.max(len + startIndex, 0)
+ } else {
+ startIndex = Math.min(startIndex, len)
+ }
+
+ let endIndex = end === undefined ? len : Math.trunc(end)
+ if (endIndex < 0) {
+ endIndex = Math.max(len + endIndex, 0)
+ } else {
+ endIndex = Math.min(endIndex, len)
+ }
+
+ const sliceLength = Math.max(endIndex - startIndex, 0)
+ const result = new Array(sliceLength)
+
+ for (let offset = 0; offset < sliceLength; offset += 1) {
+ const physicalIndex = (this.head + startIndex + offset) % this.capacity
+ result[offset] = this.buffer[physicalIndex] as T
+ }
+
+ return result
+ }
+}
diff --git a/apps/studio/lib/sanitize.test.ts b/apps/studio/lib/sanitize.test.ts
new file mode 100644
index 00000000000..f0707ef4671
--- /dev/null
+++ b/apps/studio/lib/sanitize.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it } from 'vitest'
+
+import { sanitizeArrayOfObjects } from './sanitize'
+
+describe('sanitizeArrayOfObjects', () => {
+ it('redacts sensitive keys case-insensitively', () => {
+ const input = [{ Password: 'hunter2', username: 'alice' }]
+
+ const result = sanitizeArrayOfObjects(input) as Array>
+
+ expect(result).toEqual([{ Password: '[REDACTED]', username: 'alice' }])
+ })
+
+ it('honors custom redaction and extra sensitive keys', () => {
+ const input = [
+ {
+ customSensitive: 'value',
+ token: 'should hide',
+ nested: { customSensitive: 'also hide' },
+ },
+ ]
+
+ const result = sanitizeArrayOfObjects(input, {
+ redaction: '',
+ sensitiveKeys: ['customSensitive'],
+ }) as Array
+
+ expect(result[0].customSensitive).toBe('')
+ expect(result[0].token).toBe('')
+ expect(result[0].nested).toEqual({ customSensitive: '' })
+ expect(input[0].nested.customSensitive).toBe('also hide')
+ })
+
+ it('redacts known secret patterns in strings', () => {
+ const samples = [
+ { value: '192.168.0.1' },
+ { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334' },
+ { value: 'AKIAIOSFODNN7EXAMPLE' },
+ { value: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' },
+ { value: 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789' },
+ {
+ value:
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
+ },
+ { value: 'A'.repeat(32) },
+ ]
+
+ const result = sanitizeArrayOfObjects(samples) as Array<{ value: string }>
+
+ for (const item of result) {
+ expect(item.value).toBe('[REDACTED]')
+ }
+ })
+
+ it('limits recursion depth and uses truncation notice', () => {
+ const input = [
+ {
+ level1: {
+ level2: {
+ level3: {
+ password: 'secret',
+ },
+ },
+ },
+ },
+ ]
+
+ const [result] = sanitizeArrayOfObjects(input, {
+ maxDepth: 2,
+ truncationNotice: '',
+ }) as Array
+
+ expect(result.level1.level2).toBe('')
+ expect(result.level1).not.toBe(input[0].level1)
+ expect(input[0].level1.level2.level3.password).toBe('secret')
+ })
+
+ it('handles circular references without crashing', () => {
+ const obj: any = { name: 'loop' }
+ obj.self = obj
+
+ const [result] = sanitizeArrayOfObjects([obj]) as Array
+
+ expect(result.self).toBe('[Circular]')
+ expect(result.name).toBe('loop')
+ })
+
+ it('sanitizes complex types consistently', () => {
+ const date = new Date('2024-01-01T00:00:00.000Z')
+ const regex = /abc/gi
+ const fn = () => {}
+ const arrayBuffer = new ArrayBuffer(8)
+ const typedArray = new Uint8Array([1, 2, 3])
+ const map = new Map()
+ map.set('password', 'hunter2')
+ map.set('public', date)
+ const set = new Set([1, date])
+ const url = new URL('https://example.com/path')
+ const error = new Error('Token is Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789')
+ const custom = new (class Custom {
+ toString() {
+ return 'custom-instance'
+ }
+ })()
+
+ const [result] = sanitizeArrayOfObjects([
+ {
+ date,
+ regex,
+ fn,
+ arrayBuffer,
+ typedArray,
+ map,
+ set,
+ url,
+ error,
+ custom,
+ },
+ ]) as Array
+
+ expect(result.date).toBe('2024-01-01T00:00:00.000Z')
+ expect(result.regex).toBe('/abc/gi')
+ expect(result.fn).toBe('[Function]')
+ expect(result.arrayBuffer).toBe('[ArrayBuffer byteLength=8]')
+ expect(result.typedArray).toBe('[TypedArray byteLength=3]')
+ expect(result.map).toEqual([
+ ['[REDACTED]', '[REDACTED]'],
+ ['public', '2024-01-01T00:00:00.000Z'],
+ ])
+ expect(result.set).toEqual([1, '2024-01-01T00:00:00.000Z'])
+ expect(result.url).toBe('https://example.com/path')
+ expect(result.error).toEqual({
+ name: 'Error',
+ message: 'Token is [REDACTED]',
+ stack: '[REDACTED: max depth reached]',
+ })
+ expect(result.custom).toBe('custom-instance')
+ })
+
+ it('sanitizes primitive array entries', () => {
+ const [redacted, number] = sanitizeArrayOfObjects([
+ 'Bearer abcdEFGHijklMNOPqrstUVWXyz0123456789',
+ 42,
+ ]) as Array
+
+ expect(redacted).toBe('[REDACTED]')
+ expect(number).toBe(42)
+ })
+
+ it('applies maxDepth=0 to top-level entries', () => {
+ const result = sanitizeArrayOfObjects(
+ [{ password: 'secret', nested: { value: 'test' } }, 'visible'],
+ {
+ maxDepth: 0,
+ truncationNotice: '',
+ }
+ ) as Array
+
+ expect(result[0]).toBe('')
+ expect(result[1]).toBe('visible')
+ })
+})
diff --git a/apps/studio/lib/sanitize.ts b/apps/studio/lib/sanitize.ts
new file mode 100644
index 00000000000..c6ae53b5414
--- /dev/null
+++ b/apps/studio/lib/sanitize.ts
@@ -0,0 +1,218 @@
+export function sanitizeUrlHashParams(url: string): string {
+ return url.split('#')[0]
+}
+
+/**
+ * Best-effort sanitizer for arrays of objects.
+ * - Redacts likely secrets by key name (password, token, apiKey, etc.)
+ * - Redacts likely secrets by value pattern (IPv4/IPv6, AWS keys, Bearer/JWT, generic long tokens)
+ * - Recurses into nested arrays/objects up to `maxDepth`; beyond that replaces with a notice
+ * - Handles circular references
+ *
+ * @param {any[]} inputArr - Array of items to sanitize (non-objects are copied as-is).
+ * @param {Object} [opts]
+ * @param {number} [opts.maxDepth=3] - Maximum depth to traverse (0 == only top level).
+ * @param {string} [opts.redaction="[REDACTED]"] - Replacement text for sensitive values.
+ * @param {string} [opts.truncationNotice="[REDACTED: max depth reached]"] - Used when depth limit is hit.
+ * @param {string[]} [opts.sensitiveKeys] - Extra key names to treat as sensitive (case-insensitive).
+ * @returns {any[]} a deeply-sanitized clone of the input array
+ */
+export function sanitizeArrayOfObjects(
+ inputArr: unknown[],
+ opts: {
+ maxDepth?: number
+ redaction?: string
+ truncationNotice?: string
+ sensitiveKeys?: string[]
+ } = {}
+): unknown[] {
+ const {
+ maxDepth = 3,
+ redaction = '[REDACTED]',
+ truncationNotice = '[REDACTED: max depth reached]',
+ sensitiveKeys = [],
+ } = opts
+
+ // Common sensitive key names (case-insensitive). Extendable via opts.sensitiveKeys.
+ const sensitiveKeySet = new Set(
+ [
+ 'password',
+ 'passwd',
+ 'pwd',
+ 'pass',
+ 'secret',
+ 'token',
+ 'id_token',
+ 'access_token',
+ 'refresh_token',
+ 'apikey',
+ 'api_key',
+ 'api-key',
+ 'apiKey',
+ 'key',
+ 'privatekey',
+ 'private_key',
+ 'client_secret',
+ 'clientSecret',
+ 'auth',
+ 'authorization',
+ 'ssh_key',
+ 'sshKey',
+ 'bearer',
+ 'session',
+ 'cookie',
+ 'csrf',
+ 'xsrf',
+ 'ip',
+ 'ip_address',
+ 'ipAddress',
+ 'aws_access_key_id',
+ 'aws_secret_access_key',
+ 'gcp_service_account_key',
+ ...sensitiveKeys,
+ ].map((k) => k.toLowerCase())
+ )
+
+ // Value patterns that often indicate secrets or PII
+ const patterns = [
+ // IPv4
+ { re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, reason: 'ip' },
+ // IPv6 (simplified but effective)
+ { re: /\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b/g, reason: 'ip6' },
+ // AWS Access Key ID (starts with AKIA/ASIA, 16 remaining upper alnum)
+ { re: /\b(AKI|ASI)A[0-9A-Z]{16}\b/g, reason: 'aws_access_key_id' },
+ // AWS Secret Access Key (40 base64-ish chars)
+ { re: /\b[0-9A-Za-z/+]{40}\b/g, reason: 'aws_secret_access_key_like' },
+ // Bearer tokens
+ { re: /\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b/g, reason: 'bearer' },
+ // JWT (three base64url segments separated by dots)
+ { re: /\b[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\b/g, reason: 'jwt_like' },
+ // Generic long API-ish token (conservative: 24–64 safe chars)
+ { re: /\b[A-Za-z0-9_\-]{24,64}\b/g, reason: 'long_token' },
+ ]
+
+ const seen = new WeakMap()
+
+ function isPlainObject(v: unknown): v is Record {
+ if (v === null || typeof v !== 'object') return false
+ const proto = Object.getPrototypeOf(v)
+ return proto === Object.prototype || proto === null
+ }
+
+ function redactString(str: string) {
+ let out = str
+ for (const { re } of patterns) out = out.replace(re, redaction)
+ return out
+ }
+
+ function shouldRedactByKey(key: string | symbol | number) {
+ return sensitiveKeySet.has(String(key).toLowerCase())
+ }
+
+ function sanitizeValue(value: unknown, depth: number): unknown {
+ if (
+ value == null ||
+ typeof value === 'number' ||
+ typeof value === 'boolean' ||
+ typeof value === 'bigint'
+ ) {
+ return value
+ }
+
+ if (typeof value === 'string') {
+ return redactString(value)
+ }
+
+ if (typeof value === 'function') {
+ return '[Function]'
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString()
+ }
+
+ if (value instanceof RegExp) {
+ return value.toString()
+ }
+
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
+ return `[TypedArray byteLength=${value.byteLength}]`
+ }
+ if (value instanceof ArrayBuffer) {
+ return `[ArrayBuffer byteLength=${value.byteLength}]`
+ }
+
+ if (depth >= maxDepth) {
+ return truncationNotice
+ }
+
+ if (typeof value === 'object') {
+ if (seen.has(value)) {
+ return '[Circular]'
+ }
+
+ if (Array.isArray(value)) {
+ const outArr: unknown[] = []
+ seen.set(value, outArr)
+ for (let i = 0; i < value.length; i++) {
+ outArr[i] = sanitizeValue(value[i], depth + 1)
+ }
+ return outArr
+ }
+
+ if (isPlainObject(value)) {
+ const outObj: Record = {}
+ seen.set(value, outObj)
+ for (const [k, v] of Object.entries(value)) {
+ if (shouldRedactByKey(k)) {
+ outObj[k] = redaction
+ } else {
+ outObj[k] = sanitizeValue(v, depth + 1)
+ }
+ }
+ return outObj
+ }
+
+ if (value instanceof Map) {
+ const out: unknown[] = []
+ seen.set(value, out)
+ for (const [k, v] of value.entries()) {
+ const redactedKey = shouldRedactByKey(k) ? redaction : sanitizeValue(k, depth + 1)
+ const redactedVal = shouldRedactByKey(k) ? redaction : sanitizeValue(v, depth + 1)
+ out.push([redactedKey, redactedVal])
+ }
+ return out
+ }
+
+ if (value instanceof Set) {
+ const out: unknown[] = []
+ seen.set(value, out)
+ for (const v of value.values()) {
+ out.push(sanitizeValue(v, depth + 1))
+ }
+ return out
+ }
+
+ if (value instanceof URL) return value.toString()
+ if (value instanceof Error) {
+ const o = {
+ name: value.name,
+ message: redactString(value.message),
+ stack: truncationNotice,
+ }
+ seen.set(value, o)
+ return o
+ }
+
+ try {
+ return redactString(String(value))
+ } catch {
+ return redactString(Object.prototype.toString.call(value))
+ }
+ }
+
+ return redactString(String(value))
+ }
+
+ return inputArr.map((item) => sanitizeValue(item, 0))
+}
diff --git a/apps/studio/pages/500.tsx b/apps/studio/pages/500.tsx
index 72ff3bbb328..e37f834dd87 100644
--- a/apps/studio/pages/500.tsx
+++ b/apps/studio/pages/500.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { LOCAL_STORAGE_KEYS } from 'common'
+import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSignOut } from 'lib/auth'
import { Button } from 'ui'
@@ -69,7 +70,7 @@ const Error500: NextPage = () => {
Head back
)}
- Submit a support request
+ Submit a support request
diff --git a/apps/studio/pages/api/generate-attachment-url.ts b/apps/studio/pages/api/generate-attachment-url.ts
index 87c3b892117..1bb8fcbff42 100644
--- a/apps/studio/pages/api/generate-attachment-url.ts
+++ b/apps/studio/pages/api/generate-attachment-url.ts
@@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js'
import type { NextApiRequest, NextApiResponse } from 'next'
import z from 'zod'
+import { DASHBOARD_LOG_BUCKET } from 'components/interfaces/Support/dashboard-logs'
import apiWrapper from 'lib/api/apiWrapper'
import { getUserClaims } from 'lib/gotrue'
@@ -9,7 +10,9 @@ export const maxDuration = 120
const GenerateAttachmentUrlSchema = z.object({
filenames: z.array(z.string()),
- bucket: z.enum(['support-attachments', 'feedback-attachments']).default('support-attachments'),
+ bucket: z
+ .enum(['support-attachments', 'feedback-attachments', DASHBOARD_LOG_BUCKET])
+ .default('support-attachments'),
})
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx
index 1aca37d67f9..54d41edfe57 100644
--- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx
+++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx
@@ -81,8 +81,8 @@ const LabelContainerVariants = cva('transition-all duration-500 ease-in-out', {
layout: {
horizontal: 'flex flex-col gap-2 col-span-4',
vertical: 'flex flex-row gap-2 justify-between',
- flex: 'flex flex-col gap-0',
- 'flex-row-reverse': 'flex flex-col',
+ flex: 'flex flex-col gap-0 min-w-0',
+ 'flex-row-reverse': 'flex flex-col min-w-0',
},
labelLayout: {
horizontal: '',