| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- import { useParams } from 'common'
- import { Copy, Download, Edit, Globe, Lock, MoreVertical, Trash } from 'lucide-react'
- import Link from 'next/link'
- import { type CSSProperties } from 'react'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- cn,
- copyToClipboard,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TreeViewItemVariant,
- } from 'ui'
- import { useExportAllRowsAsCsv, useExportAllRowsAsSql } from './ExportAllRows'
- import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
- import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
- import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/TableEntity.utils'
- import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
- import { InlineLink } from '@/components/ui/InlineLink'
- import { getTableDefinition } from '@/data/database/table-definition-query'
- import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
- import { Entity } from '@/data/entity-types/entity-types-infinite-query'
- import { useProjectLintsQuery } from '@/data/lint/lint-query'
- import { EditorTablePageLink } from '@/data/prefetchers/project.$ref.editor.$id'
- import type {
- TableApiAccessData,
- TableApiAccessMap,
- } from '@/data/privileges/table-api-access-query'
- import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query'
- import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { formatSql } from '@/lib/formatSql'
- import {
- useRoleImpersonationStateSnapshot,
- type RoleImpersonationState,
- } from '@/state/role-impersonation-state'
- import { useTableEditorStateSnapshot } from '@/state/table-editor'
- import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
- export interface EntityListItemProps {
- id: number | string
- projectRef: string
- item: Entity
- isLocked: boolean
- isActive?: boolean
- style?: CSSProperties
- onExportCLI: () => void
- apiAccessMap?: TableApiAccessMap
- }
- // [jordi] Used to determine the entity is a table and not a view or other unsupported entity type
- function isTableLikeEntityListItem(entity: { type?: string }) {
- return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE
- }
- export const EntityListItem = ({
- id,
- projectRef,
- item: entity,
- isLocked,
- isActive: _isActive,
- style,
- onExportCLI,
- apiAccessMap,
- }: EntityListItemProps) => {
- const { data: project } = useSelectedProjectQuery()
- const snap = useTableEditorStateSnapshot()
- const { selectedSchema } = useQuerySchemaState()
- const tabId = createTabId(entity.type, { id: entity.id })
- const tabs = useTabsStateSnapshot()
- const isPreview = tabs.previewTabId === tabId
- const isActive = Number(id) === entity.id
- const canEdit = isActive && !isLocked
- const { filters } = useTableFilter()
- const roleImpersonationState = useRoleImpersonationStateSnapshot()
- const { data: countData } = useTableRowsCountQuery(
- {
- projectRef,
- tableId: entity.id,
- filters,
- enforceExactCount: false,
- roleImpersonationState: roleImpersonationState as RoleImpersonationState,
- },
- {
- enabled: isTableLikeEntityListItem(entity) && isActive,
- }
- )
- const rowCount = countData?.count
- const { data: lints = [] } = useProjectLintsQuery({
- projectRef: project?.ref,
- })
- const tableHasRlsDisabledLint: boolean = getEntityLintDetails(
- entity.name,
- 'rls_disabled_in_public',
- ['ERROR'],
- lints,
- selectedSchema
- ).hasLint
- const tableHasRlsEnabledNoPolicyLint: boolean = getEntityLintDetails(
- entity.name,
- 'rls_enabled_no_policy',
- ['ERROR', 'WARN', 'INFO'],
- lints,
- selectedSchema
- ).hasLint
- const viewHasLints: boolean = getEntityLintDetails(
- entity.name,
- 'security_definer_view',
- ['ERROR', 'WARN'],
- lints,
- selectedSchema
- ).hasLint
- const materializedViewHasLints: boolean = getEntityLintDetails(
- entity.name,
- 'materialized_view_in_api',
- ['ERROR', 'WARN'],
- lints,
- selectedSchema
- ).hasLint
- const foreignTableHasLints: boolean = getEntityLintDetails(
- entity.name,
- 'foreign_table_in_api',
- ['ERROR', 'WARN'],
- lints,
- selectedSchema
- ).hasLint
- const apiAccessData = apiAccessMap?.[entity.name]
- const formatTooltipText = (entityType: string) => {
- const text =
- Object.entries(ENTITY_TYPE)
- .find(([, value]) => value === entityType)?.[0]
- ?.toLowerCase()
- ?.split('_')
- ?.join(' ') || ''
- // Return sentence case (capitalize first letter only)
- return text.charAt(0).toUpperCase() + text.slice(1)
- }
- const { exportCsv, confirmationModal: exportCsvConfirmationModal } = useExportAllRowsAsCsv({
- enabled: true,
- projectRef,
- connectionString: project?.connectionString ?? null,
- entity,
- type: 'fetch_all',
- totalRows: rowCount,
- })
- const { exportSql, confirmationModal: exportSqlConfirmationModal } = useExportAllRowsAsSql({
- enabled: true,
- projectRef,
- connectionString: project?.connectionString ?? null,
- entity,
- type: 'fetch_all',
- totalRows: rowCount,
- })
- return (
- <EditorTablePageLink
- title={entity.name}
- style={style}
- id={String(entity.id)}
- href={buildTableEditorUrl({ projectRef, tableId: entity.id, schema: entity.schema })}
- role="button"
- aria-label={`View ${entity.name}`}
- className={cn(
- TreeViewItemVariant({
- isSelected: isActive && !isPreview,
- isPreview,
- }),
- 'pl-4 pr-1'
- )}
- onDoubleClick={(e) => {
- e.preventDefault()
- const tabId = createTabId(entity.type, { id: entity.id })
- tabs.makeTabPermanent(tabId)
- }}
- >
- <>
- {isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
- <Tooltip disableHoverableContent={true}>
- <TooltipTrigger className="min-w-4">
- <EntityTypeIcon type={entity.type} isActive={isActive} />
- </TooltipTrigger>
- <TooltipContent side="bottom">{formatTooltipText(entity.type)}</TooltipContent>
- </Tooltip>
- <div
- className={cn(
- 'truncate overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
- isActive && 'text-foreground'
- )}
- >
- <span
- className={cn(
- isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
- 'text-sm transition truncate'
- )}
- >
- {entity.name}
- </span>
- <EntityTooltipTrigger
- entity={entity}
- tableHasRlsDisabledLint={tableHasRlsDisabledLint}
- tableHasRlsEnabledNoPolicyLint={tableHasRlsEnabledNoPolicyLint}
- viewHasLints={viewHasLints}
- materializedViewHasLints={materializedViewHasLints}
- foreignTableHasLints={foreignTableHasLints}
- apiAccessData={apiAccessData}
- />
- </div>
- {canEdit && (
- <DropdownMenu>
- <DropdownMenuTrigger
- asChild
- className="text-foreground-lighter transition-all text-transparent group-hover:text-foreground data-open:text-foreground"
- >
- <Button
- type="text"
- className="w-6 h-6"
- icon={<MoreVertical size={14} strokeWidth={2} />}
- onClick={(e) => e.preventDefault()}
- />
- </DropdownMenuTrigger>
- <DropdownMenuContent side="bottom" align="start" className="w-44">
- <DropdownMenuItem
- key="copy-name"
- className="space-x-2"
- onClick={(e) => {
- e.stopPropagation()
- copyToClipboard(entity.name)
- }}
- >
- <Copy size={12} />
- <span>Copy name</span>
- </DropdownMenuItem>
- {isTableLikeEntityListItem(entity) && (
- <DropdownMenuItem
- key="copy-schema"
- className="space-x-2"
- onClick={async (e) => {
- e.stopPropagation()
- const toastId = toast.loading('Getting table schema...')
- const formattedSchema = getTableDefinition({
- id: entity.id,
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- }).then((tableDefinition) => {
- if (!tableDefinition) {
- throw new Error('Failed to get table schema')
- }
- return formatSql(tableDefinition)
- })
- try {
- await copyToClipboard(formattedSchema, () => {
- toast.success('Table schema copied to clipboard', { id: toastId })
- })
- } catch (err: any) {
- toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId })
- }
- }}
- >
- <Copy size={12} />
- <span>Copy table schema</span>
- </DropdownMenuItem>
- )}
- {entity.type === ENTITY_TYPE.TABLE && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- key="edit-table"
- className="space-x-2"
- onClick={(e) => {
- e.stopPropagation()
- snap.onEditTable()
- }}
- >
- <Edit size={12} />
- <span>Edit table</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- key="duplicate-table"
- className="space-x-2"
- onClick={(e) => {
- e.stopPropagation()
- snap.onDuplicateTable()
- }}
- >
- <Copy size={12} />
- <span>Duplicate table</span>
- </DropdownMenuItem>
- <DropdownMenuItem key="view-policies" className="space-x-2" asChild>
- <Link
- key="view-policies"
- href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`}
- >
- <Lock size={12} />
- <span>View policies</span>
- </Link>
- </DropdownMenuItem>
- <DropdownMenuSub>
- <DropdownMenuSubTrigger className="gap-x-2">
- <Download size={12} />
- Export data
- </DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuItem
- key="download-table-csv"
- className="space-x-2"
- onClick={(e) => {
- e.stopPropagation()
- exportCsv()
- }}
- >
- <span>Export table as CSV</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- key="download-table-sql"
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- exportSql()
- }}
- >
- <span>Export table as SQL</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- key="download-table-cli"
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- onExportCLI()
- }}
- >
- <span>Export table via CLI</span>
- </DropdownMenuItem>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- key="delete-table"
- className="gap-x-2"
- onClick={(e) => {
- e.stopPropagation()
- snap.onDeleteTable()
- }}
- >
- <Trash size={12} />
- <span>Delete table</span>
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </>
- {exportCsvConfirmationModal}
- {exportSqlConfirmationModal}
- </EditorTablePageLink>
- )
- }
- const EntityTooltipTrigger = ({
- entity,
- tableHasRlsDisabledLint,
- tableHasRlsEnabledNoPolicyLint,
- viewHasLints,
- materializedViewHasLints,
- foreignTableHasLints,
- apiAccessData,
- }: {
- entity: Entity
- tableHasRlsDisabledLint: boolean
- tableHasRlsEnabledNoPolicyLint: boolean
- viewHasLints: boolean
- materializedViewHasLints: boolean
- foreignTableHasLints: boolean
- apiAccessData?: TableApiAccessData
- }) => {
- const { ref } = useParams()
- let tooltipContent = null
- const accessWarning = 'Data is publicly accessible via API'
- const learnMoreCTA = (
- <InlineLink
- href={`/project/${ref}/editor/${entity.id}?schema=${entity.schema}&showWarning=true`}
- >
- Learn more
- </InlineLink>
- )
- switch (entity.type) {
- case ENTITY_TYPE.TABLE:
- if (tableHasRlsDisabledLint) {
- tooltipContent = (
- <>
- This table can be accessed by anyone via the Data API as RLS is disabled. {learnMoreCTA}
- .
- </>
- )
- }
- break
- case ENTITY_TYPE.VIEW:
- if (viewHasLints) {
- tooltipContent = (
- <>
- {accessWarning} as this is a Security definer view. {learnMoreCTA}.
- </>
- )
- }
- break
- case ENTITY_TYPE.MATERIALIZED_VIEW:
- if (materializedViewHasLints) {
- tooltipContent = (
- <>
- {accessWarning} as this is a Security definer view {learnMoreCTA}.
- </>
- )
- }
- break
- case ENTITY_TYPE.FOREIGN_TABLE:
- if (foreignTableHasLints) {
- tooltipContent = (
- <>
- {accessWarning} as RLS is not enforced on foreign tables. {learnMoreCTA}.
- </>
- )
- }
- break
- default:
- break
- }
- if (tooltipContent) {
- return (
- <Tooltip>
- <TooltipTrigger className="min-w-4">
- <Badge variant="destructive">Unrestricted</Badge>
- </TooltipTrigger>
- <TooltipContent side="right" className="max-w-52">
- {tooltipContent}
- </TooltipContent>
- </Tooltip>
- )
- }
- const isRlsEnabledNoPolicies =
- entity.type === ENTITY_TYPE.TABLE &&
- apiAccessData?.apiAccessType === 'access' &&
- tableHasRlsEnabledNoPolicyLint
- if (isRlsEnabledNoPolicies) {
- return (
- <Tooltip>
- <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
- <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
- </TooltipTrigger>
- <TooltipContent side="right" className="max-w-52">
- This table can be accessed via the Data API but no RLS policies exist so no data will be
- returned
- </TooltipContent>
- </Tooltip>
- )
- }
- const isApiExposedWithRlsAndPolicies =
- apiAccessData?.apiAccessType === 'access' && !tableHasRlsEnabledNoPolicyLint
- if (isApiExposedWithRlsAndPolicies) {
- return (
- <Tooltip>
- <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
- <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
- </TooltipTrigger>
- <TooltipContent side="right">This table can be accessed via the Data API</TooltipContent>
- </Tooltip>
- )
- }
- return null
- }
|