Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 69 additions & 17 deletions apps/studio/components/interfaces/Auth/Users/UsersV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FilterPopover } from 'components/ui/FilterPopover'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { authKeys } from 'data/auth/keys'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
Expand Down Expand Up @@ -54,6 +55,8 @@ import { formatUserColumns, formatUsersData } from './Users.utils'
import { UsersFooter } from './UsersFooter'
import { UsersSearch } from './UsersSearch'

const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000

export const UsersV2 = () => {
const queryClient = useQueryClient()
const { ref: projectRef } = useParams()
Expand Down Expand Up @@ -97,7 +100,7 @@ export const UsersV2 = () => {
parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
)
const [filterKeywords, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
const [sortByValue, setSortByValue] = useQueryState('id:asc', { defaultValue: 'id:asc' })
const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'id:asc' })
const [sortColumn, sortOrder] = sortByValue.split(':')
const [selectedColumns, setSelectedColumns] = useQueryState(
'columns',
Expand All @@ -116,6 +119,15 @@ export const UsersV2 = () => {
'id'
)

const [
localStorageSortByValue,
setLocalStorageSortByValue,
{ isSuccess: isLocalStorageSortByValueLoaded },
] = useLocalStorageQuery<string>(
LOCAL_STORAGE_KEYS.AUTH_USERS_SORT_BY_VALUE(projectRef ?? ''),
'id'
)

const [
columnConfiguration,
setColumnConfiguration,
Expand All @@ -134,6 +146,23 @@ export const UsersV2 = () => {
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [showFreeformWarning, setShowFreeformWarning] = useState(false)

const { data: totalUsersCountData, isSuccess: isCountLoaded } = useUsersCountQuery(
{
projectRef,
connectionString: project?.connectionString,
// [Joshen] Do not change the following, these are to match the count query in UsersFooter
// on initial load with no search configuration so that we only fire 1 count request at the
// beginning. The count value is for all users - should disregard any search configuration
keywords: '',
filter: undefined,
providers: [],
forceExactCount: false,
},
{ keepPreviousData: true }
)
const totalUsers = totalUsersCountData?.count ?? 0
const isCountWithinThresholdForSortBy = totalUsers <= SORT_BY_VALUE_COUNT_THRESHOLD

const {
data,
error,
Expand Down Expand Up @@ -195,6 +224,19 @@ export const UsersV2 = () => {
organization: selectedOrg?.slug ?? 'Unknown',
}

const updateStorageFilter = (value: 'id' | 'email' | 'phone' | 'freeform') => {
setLocalStorageFilter(value)
setSpecificFilterColumn(value)
if (value !== 'freeform') {
updateSortByValue('id:asc')
}
}

const updateSortByValue = (value: string) => {
if (isCountWithinThresholdForSortBy) setLocalStorageSortByValue(value)
setSortByValue(value)
}

const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
xScroll.current = event.currentTarget.scrollLeft
Expand Down Expand Up @@ -281,7 +323,7 @@ export const UsersV2 = () => {
config: columnConfiguration ?? [],
users: users ?? [],
visibleColumns: selectedColumns,
setSortByValue,
setSortByValue: updateSortByValue,
onSelectDeleteUser: setSelectedUserToDelete,
})
setColumns(columns)
Expand All @@ -301,16 +343,22 @@ export const UsersV2 = () => {
specificFilterColumn,
])

// [Joshen] Load URL state for filter column only once, if no filter column found in URL params
// [Joshen] Load URL state for filter column and sort by only once, if no respective values found in URL params
useEffect(() => {
if (specificFilterColumn === 'id' && localStorageFilter !== 'id') {
setSpecificFilterColumn(localStorageFilter)
if (
isLocalStorageFilterLoaded &&
isLocalStorageSortByValueLoaded &&
isCountLoaded &&
isCountWithinThresholdForSortBy
) {
if (specificFilterColumn === 'id' && localStorageFilter !== 'id') {
setSpecificFilterColumn(localStorageFilter)
}
if (sortByValue === 'id:asc' && localStorageSortByValue !== 'id:asc') {
setSortByValue(localStorageSortByValue)
}
}
}, [])

useEffect(() => {
setLocalStorageFilter(specificFilterColumn)
}, [specificFilterColumn])
}, [isLocalStorageFilterLoaded, isLocalStorageSortByValueLoaded, isCountLoaded])

return (
<>
Expand Down Expand Up @@ -353,9 +401,13 @@ export const UsersV2 = () => {
}}
setSpecificFilterColumn={(value) => {
if (value === 'freeform') {
setShowFreeformWarning(true)
if (isCountWithinThresholdForSortBy) {
updateStorageFilter(value)
} else {
setShowFreeformWarning(true)
}
} else {
setSpecificFilterColumn(value)
updateStorageFilter(value)
}
}}
/>
Expand Down Expand Up @@ -469,7 +521,7 @@ export const UsersV2 = () => {
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
setSortByValue,
setSortByValue: updateSortByValue,
onSelectDeleteUser: setSelectedUserToDelete,
})

Expand All @@ -486,7 +538,7 @@ export const UsersV2 = () => {
sortByValue={sortByValue}
setSortByValue={(value) => {
const [sortColumn, sortOrder] = value.split(':')
setSortByValue(value)
updateSortByValue(value)
sendEvent({
action: 'auth_users_search_submitted',
properties: {
Expand Down Expand Up @@ -669,15 +721,15 @@ export const UsersV2 = () => {
confirmLabel="Confirm"
title="Confirm to search across all columns"
onConfirm={() => {
setSpecificFilterColumn('freeform')
updateStorageFilter('freeform')
setShowFreeformWarning(false)
}}
onCancel={() => setShowFreeformWarning(false)}
alert={{
base: { variant: 'warning' },
title: 'Searching across all columns is not recommended',
title: 'Searching across all columns is not recommended with many users',
description:
'This may adversely impact your database, in particular if your project has a large number of users - use with caution.',
'This may adversely impact your database, in particular if your project has a large number of users - use with caution. Search mode will not be persisted across browser sessions as a safeguard.',
}}
>
<p className="text-foreground-light text-sm">
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/components/interfaces/Support/MessageField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function MessageField({ form, originalError }: MessageFieldProps) {
<Admonition
showIcon={false}
type="default"
className="mt-2"
className="mt-2 max-h-[150px] overflow-y-auto"
title="The error that you ran into will be included in your message for reference"
description={`Error: ${originalError}`}
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { Button, cn, KeyboardShortcut } from 'ui'
import { Admonition } from 'ui-patterns'
import { ButtonTooltip } from '../ButtonTooltip'
import { ErrorBoundary } from '../ErrorBoundary'
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'
import type { SqlSnippet } from './AIAssistant.types'
import { onErrorChat } from './AIAssistant.utils'
import { AIAssistantHeader } from './AIAssistantHeader'
Expand Down
115 changes: 115 additions & 0 deletions apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ExternalLink } from 'lucide-react'

import { SupportCategories } from '@supabase/shared-types/out/constants'
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { useRouter } from 'next/router'
import { Button, cn } from 'ui'
import { Admonition } from 'ui-patterns'
import CopyButton from '../CopyButton'
import { InlineLinkClassName } from '../InlineLink'

interface ClientSideExceptionHandlerProps {
message: string
sentryIssueId: string
urlMessage: string
resetErrorBoundary: () => void
}

export const ClientSideExceptionHandler = ({
message,
sentryIssueId,
urlMessage,
resetErrorBoundary,
}: ClientSideExceptionHandlerProps) => {
const router = useRouter()

const isProduction = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'

const handleClearStorage = () => {
try {
localStorage.clear()
sessionStorage.clear()
} catch (e) {
// ignore
}
window.location.reload()
}

return (
<>
<div className="flex flex-col gap-y-1 text-left py-2 w-full">
<div className="flex items-center justify-between mb-3">
<p className="text-lg font-bold">Sorry! An unexpected error occurred.</p>
<CopyButton type="outline" text={message} copyLabel="Copy error" />
</div>
<p className="text-sm">
Application error: a client-side exception has occurred (see browser console for more
information)
</p>
<p className="text-foreground-light text-sm">{message}</p>
</div>
<Admonition type="warning" showIcon={false} title="We recommend trying the following:">
<ul className="list-disc pl-2 list-inside text-sm space-y-1 [&_b]:font-medium [&_b]:text-foreground">
<li>
<span
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => window.location.reload()}
>
Refresh
</span>{' '}
the page
</li>
<li>
<span
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => router.push('/logout')}
>
Sign out
</span>{' '}
and sign back in
</li>
<li>
<span
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={handleClearStorage}
>
Clear your browser storage
</span>{' '}
to clean potentially outdated data
</li>
<li>
Disable browser extensions that might modify page content (e.g., Google Translate)
</li>
<li>If the problem persists, please contact support for assistance</li>
</ul>
</Admonition>

<div className={cn('w-full mx-auto grid gap-2', 'grid-cols-2 sm:w-1/2')}>
<Button asChild type="default" icon={<ExternalLink />}>
<SupportLink
queryParams={{
category: SupportCategories.DASHBOARD_BUG,
subject: 'Client side exception occurred on dashboard',
sid: sentryIssueId,
error: urlMessage,
}}
>
Contact support
</SupportLink>
</Button>

{/* [Joshen] For local and staging, allow us to escape the error boundary */}
{/* We could actually investigate how to make this available on prod, but without being able to reliably test this, I'm not keen to do it now */}
{isProduction ? (
<Button type="outline" onClick={() => router.reload()}>
Reload dashboard
</Button>
) : (
<Button type="outline" onClick={() => resetErrorBoundary()}>
Return to dashboard
</Button>
)}
</div>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isError } from 'lodash'
import Link from 'next/link'
import { useRouter } from 'next/router'

import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { ClientSideExceptionHandler } from './ClientSideExceptionHandler'
import { InsertBeforeRemoveChildErrorHandler } from './InsertBeforeRemoveChildErrorHandler'

export type FallbackProps = {
error: unknown
resetErrorBoundary: (...args: unknown[]) => void
}

export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) => {
const router = useRouter()
const checkIsError = isError(error)

const largeLogo = useIsFeatureEnabled('branding:large_logo')

const errorMessage = checkIsError ? error.message : ''
const urlMessage = checkIsError ? `Path name: ${router.pathname}\n\n${error?.stack}` : ''

const isRemoveChildError = checkIsError
? errorMessage.includes("Failed to execute 'removeChild' on 'Node'")
: false
const isInsertBeforeError = checkIsError
? errorMessage.includes("Failed to execute 'insertBefore' on 'Node'")
: false

// Get Sentry issue ID from error if available
const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? ''

return (
<div className="w-screen mx-auto h-screen flex items-center justify-center">
<header className="h-12 absolute top-0 w-full border-b px-4 flex items-center">
<Link href="/" className="items-center justify-center">
<img
alt="Supabase"
src={`${router.basePath}/img/supabase-logo.svg`}
className={largeLogo ? 'h-[20px]' : 'h-[18px]'}
/>
</Link>
</header>

<div className="flex flex-col gap-y-4 max-w-full sm:max-w-[660px] px-4 sm:px-0">
{isRemoveChildError || isInsertBeforeError ? (
<InsertBeforeRemoveChildErrorHandler
message={errorMessage}
sentryIssueId={sentryIssueId}
urlMessage={urlMessage}
isRemoveChildError={isRemoveChildError}
isInsertBeforeError={isInsertBeforeError}
/>
) : (
<ClientSideExceptionHandler
message={errorMessage}
sentryIssueId={sentryIssueId}
urlMessage={urlMessage}
resetErrorBoundary={resetErrorBoundary}
/>
)}
</div>
</div>
)
}
Loading
Loading