EntityListItem.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import { useParams } from 'common'
  2. import { Copy, Download, Edit, Globe, Lock, MoreVertical, Trash } from 'lucide-react'
  3. import Link from 'next/link'
  4. import { type CSSProperties } from 'react'
  5. import { toast } from 'sonner'
  6. import {
  7. Badge,
  8. Button,
  9. cn,
  10. copyToClipboard,
  11. DropdownMenu,
  12. DropdownMenuContent,
  13. DropdownMenuItem,
  14. DropdownMenuSeparator,
  15. DropdownMenuSub,
  16. DropdownMenuSubContent,
  17. DropdownMenuSubTrigger,
  18. DropdownMenuTrigger,
  19. Tooltip,
  20. TooltipContent,
  21. TooltipTrigger,
  22. TreeViewItemVariant,
  23. } from 'ui'
  24. import { useExportAllRowsAsCsv, useExportAllRowsAsSql } from './ExportAllRows'
  25. import { useTableFilter } from '@/components/grid/hooks/useTableFilter'
  26. import { buildTableEditorUrl } from '@/components/grid/BrivenGrid.utils'
  27. import { getEntityLintDetails } from '@/components/interfaces/TableGridEditor/TableEntity.utils'
  28. import { EntityTypeIcon } from '@/components/ui/EntityTypeIcon'
  29. import { InlineLink } from '@/components/ui/InlineLink'
  30. import { getTableDefinition } from '@/data/database/table-definition-query'
  31. import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
  32. import { Entity } from '@/data/entity-types/entity-types-infinite-query'
  33. import { useProjectLintsQuery } from '@/data/lint/lint-query'
  34. import { EditorTablePageLink } from '@/data/prefetchers/project.$ref.editor.$id'
  35. import type {
  36. TableApiAccessData,
  37. TableApiAccessMap,
  38. } from '@/data/privileges/table-api-access-query'
  39. import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query'
  40. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  41. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  42. import { formatSql } from '@/lib/formatSql'
  43. import {
  44. useRoleImpersonationStateSnapshot,
  45. type RoleImpersonationState,
  46. } from '@/state/role-impersonation-state'
  47. import { useTableEditorStateSnapshot } from '@/state/table-editor'
  48. import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
  49. export interface EntityListItemProps {
  50. id: number | string
  51. projectRef: string
  52. item: Entity
  53. isLocked: boolean
  54. isActive?: boolean
  55. style?: CSSProperties
  56. onExportCLI: () => void
  57. apiAccessMap?: TableApiAccessMap
  58. }
  59. // [jordi] Used to determine the entity is a table and not a view or other unsupported entity type
  60. function isTableLikeEntityListItem(entity: { type?: string }) {
  61. return entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE
  62. }
  63. export const EntityListItem = ({
  64. id,
  65. projectRef,
  66. item: entity,
  67. isLocked,
  68. isActive: _isActive,
  69. style,
  70. onExportCLI,
  71. apiAccessMap,
  72. }: EntityListItemProps) => {
  73. const { data: project } = useSelectedProjectQuery()
  74. const snap = useTableEditorStateSnapshot()
  75. const { selectedSchema } = useQuerySchemaState()
  76. const tabId = createTabId(entity.type, { id: entity.id })
  77. const tabs = useTabsStateSnapshot()
  78. const isPreview = tabs.previewTabId === tabId
  79. const isActive = Number(id) === entity.id
  80. const canEdit = isActive && !isLocked
  81. const { filters } = useTableFilter()
  82. const roleImpersonationState = useRoleImpersonationStateSnapshot()
  83. const { data: countData } = useTableRowsCountQuery(
  84. {
  85. projectRef,
  86. tableId: entity.id,
  87. filters,
  88. enforceExactCount: false,
  89. roleImpersonationState: roleImpersonationState as RoleImpersonationState,
  90. },
  91. {
  92. enabled: isTableLikeEntityListItem(entity) && isActive,
  93. }
  94. )
  95. const rowCount = countData?.count
  96. const { data: lints = [] } = useProjectLintsQuery({
  97. projectRef: project?.ref,
  98. })
  99. const tableHasRlsDisabledLint: boolean = getEntityLintDetails(
  100. entity.name,
  101. 'rls_disabled_in_public',
  102. ['ERROR'],
  103. lints,
  104. selectedSchema
  105. ).hasLint
  106. const tableHasRlsEnabledNoPolicyLint: boolean = getEntityLintDetails(
  107. entity.name,
  108. 'rls_enabled_no_policy',
  109. ['ERROR', 'WARN', 'INFO'],
  110. lints,
  111. selectedSchema
  112. ).hasLint
  113. const viewHasLints: boolean = getEntityLintDetails(
  114. entity.name,
  115. 'security_definer_view',
  116. ['ERROR', 'WARN'],
  117. lints,
  118. selectedSchema
  119. ).hasLint
  120. const materializedViewHasLints: boolean = getEntityLintDetails(
  121. entity.name,
  122. 'materialized_view_in_api',
  123. ['ERROR', 'WARN'],
  124. lints,
  125. selectedSchema
  126. ).hasLint
  127. const foreignTableHasLints: boolean = getEntityLintDetails(
  128. entity.name,
  129. 'foreign_table_in_api',
  130. ['ERROR', 'WARN'],
  131. lints,
  132. selectedSchema
  133. ).hasLint
  134. const apiAccessData = apiAccessMap?.[entity.name]
  135. const formatTooltipText = (entityType: string) => {
  136. const text =
  137. Object.entries(ENTITY_TYPE)
  138. .find(([, value]) => value === entityType)?.[0]
  139. ?.toLowerCase()
  140. ?.split('_')
  141. ?.join(' ') || ''
  142. // Return sentence case (capitalize first letter only)
  143. return text.charAt(0).toUpperCase() + text.slice(1)
  144. }
  145. const { exportCsv, confirmationModal: exportCsvConfirmationModal } = useExportAllRowsAsCsv({
  146. enabled: true,
  147. projectRef,
  148. connectionString: project?.connectionString ?? null,
  149. entity,
  150. type: 'fetch_all',
  151. totalRows: rowCount,
  152. })
  153. const { exportSql, confirmationModal: exportSqlConfirmationModal } = useExportAllRowsAsSql({
  154. enabled: true,
  155. projectRef,
  156. connectionString: project?.connectionString ?? null,
  157. entity,
  158. type: 'fetch_all',
  159. totalRows: rowCount,
  160. })
  161. return (
  162. <EditorTablePageLink
  163. title={entity.name}
  164. style={style}
  165. id={String(entity.id)}
  166. href={buildTableEditorUrl({ projectRef, tableId: entity.id, schema: entity.schema })}
  167. role="button"
  168. aria-label={`View ${entity.name}`}
  169. className={cn(
  170. TreeViewItemVariant({
  171. isSelected: isActive && !isPreview,
  172. isPreview,
  173. }),
  174. 'pl-4 pr-1'
  175. )}
  176. onDoubleClick={(e) => {
  177. e.preventDefault()
  178. const tabId = createTabId(entity.type, { id: entity.id })
  179. tabs.makeTabPermanent(tabId)
  180. }}
  181. >
  182. <>
  183. {isActive && <div className="absolute left-0 h-full w-0.5 bg-foreground" />}
  184. <Tooltip disableHoverableContent={true}>
  185. <TooltipTrigger className="min-w-4">
  186. <EntityTypeIcon type={entity.type} isActive={isActive} />
  187. </TooltipTrigger>
  188. <TooltipContent side="bottom">{formatTooltipText(entity.type)}</TooltipContent>
  189. </Tooltip>
  190. <div
  191. className={cn(
  192. 'truncate overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2 relative w-full',
  193. isActive && 'text-foreground'
  194. )}
  195. >
  196. <span
  197. className={cn(
  198. isActive ? 'text-foreground' : 'text-foreground-light group-hover:text-foreground',
  199. 'text-sm transition truncate'
  200. )}
  201. >
  202. {entity.name}
  203. </span>
  204. <EntityTooltipTrigger
  205. entity={entity}
  206. tableHasRlsDisabledLint={tableHasRlsDisabledLint}
  207. tableHasRlsEnabledNoPolicyLint={tableHasRlsEnabledNoPolicyLint}
  208. viewHasLints={viewHasLints}
  209. materializedViewHasLints={materializedViewHasLints}
  210. foreignTableHasLints={foreignTableHasLints}
  211. apiAccessData={apiAccessData}
  212. />
  213. </div>
  214. {canEdit && (
  215. <DropdownMenu>
  216. <DropdownMenuTrigger
  217. asChild
  218. className="text-foreground-lighter transition-all text-transparent group-hover:text-foreground data-open:text-foreground"
  219. >
  220. <Button
  221. type="text"
  222. className="w-6 h-6"
  223. icon={<MoreVertical size={14} strokeWidth={2} />}
  224. onClick={(e) => e.preventDefault()}
  225. />
  226. </DropdownMenuTrigger>
  227. <DropdownMenuContent side="bottom" align="start" className="w-44">
  228. <DropdownMenuItem
  229. key="copy-name"
  230. className="space-x-2"
  231. onClick={(e) => {
  232. e.stopPropagation()
  233. copyToClipboard(entity.name)
  234. }}
  235. >
  236. <Copy size={12} />
  237. <span>Copy name</span>
  238. </DropdownMenuItem>
  239. {isTableLikeEntityListItem(entity) && (
  240. <DropdownMenuItem
  241. key="copy-schema"
  242. className="space-x-2"
  243. onClick={async (e) => {
  244. e.stopPropagation()
  245. const toastId = toast.loading('Getting table schema...')
  246. const formattedSchema = getTableDefinition({
  247. id: entity.id,
  248. projectRef: project?.ref,
  249. connectionString: project?.connectionString,
  250. }).then((tableDefinition) => {
  251. if (!tableDefinition) {
  252. throw new Error('Failed to get table schema')
  253. }
  254. return formatSql(tableDefinition)
  255. })
  256. try {
  257. await copyToClipboard(formattedSchema, () => {
  258. toast.success('Table schema copied to clipboard', { id: toastId })
  259. })
  260. } catch (err: any) {
  261. toast.error('Failed to copy schema: ' + (err.message || err), { id: toastId })
  262. }
  263. }}
  264. >
  265. <Copy size={12} />
  266. <span>Copy table schema</span>
  267. </DropdownMenuItem>
  268. )}
  269. {entity.type === ENTITY_TYPE.TABLE && (
  270. <>
  271. <DropdownMenuSeparator />
  272. <DropdownMenuItem
  273. key="edit-table"
  274. className="space-x-2"
  275. onClick={(e) => {
  276. e.stopPropagation()
  277. snap.onEditTable()
  278. }}
  279. >
  280. <Edit size={12} />
  281. <span>Edit table</span>
  282. </DropdownMenuItem>
  283. <DropdownMenuItem
  284. key="duplicate-table"
  285. className="space-x-2"
  286. onClick={(e) => {
  287. e.stopPropagation()
  288. snap.onDuplicateTable()
  289. }}
  290. >
  291. <Copy size={12} />
  292. <span>Duplicate table</span>
  293. </DropdownMenuItem>
  294. <DropdownMenuItem key="view-policies" className="space-x-2" asChild>
  295. <Link
  296. key="view-policies"
  297. href={`/project/${projectRef}/auth/policies?schema=${encodeURIComponent(selectedSchema ?? '')}&search=${encodeURIComponent(String(entity.id))}`}
  298. >
  299. <Lock size={12} />
  300. <span>View policies</span>
  301. </Link>
  302. </DropdownMenuItem>
  303. <DropdownMenuSub>
  304. <DropdownMenuSubTrigger className="gap-x-2">
  305. <Download size={12} />
  306. Export data
  307. </DropdownMenuSubTrigger>
  308. <DropdownMenuSubContent>
  309. <DropdownMenuItem
  310. key="download-table-csv"
  311. className="space-x-2"
  312. onClick={(e) => {
  313. e.stopPropagation()
  314. exportCsv()
  315. }}
  316. >
  317. <span>Export table as CSV</span>
  318. </DropdownMenuItem>
  319. <DropdownMenuItem
  320. key="download-table-sql"
  321. className="gap-x-2"
  322. onClick={(e) => {
  323. e.stopPropagation()
  324. exportSql()
  325. }}
  326. >
  327. <span>Export table as SQL</span>
  328. </DropdownMenuItem>
  329. <DropdownMenuItem
  330. key="download-table-cli"
  331. className="gap-x-2"
  332. onClick={(e) => {
  333. e.stopPropagation()
  334. onExportCLI()
  335. }}
  336. >
  337. <span>Export table via CLI</span>
  338. </DropdownMenuItem>
  339. </DropdownMenuSubContent>
  340. </DropdownMenuSub>
  341. <DropdownMenuSeparator />
  342. <DropdownMenuItem
  343. key="delete-table"
  344. className="gap-x-2"
  345. onClick={(e) => {
  346. e.stopPropagation()
  347. snap.onDeleteTable()
  348. }}
  349. >
  350. <Trash size={12} />
  351. <span>Delete table</span>
  352. </DropdownMenuItem>
  353. </>
  354. )}
  355. </DropdownMenuContent>
  356. </DropdownMenu>
  357. )}
  358. </>
  359. {exportCsvConfirmationModal}
  360. {exportSqlConfirmationModal}
  361. </EditorTablePageLink>
  362. )
  363. }
  364. const EntityTooltipTrigger = ({
  365. entity,
  366. tableHasRlsDisabledLint,
  367. tableHasRlsEnabledNoPolicyLint,
  368. viewHasLints,
  369. materializedViewHasLints,
  370. foreignTableHasLints,
  371. apiAccessData,
  372. }: {
  373. entity: Entity
  374. tableHasRlsDisabledLint: boolean
  375. tableHasRlsEnabledNoPolicyLint: boolean
  376. viewHasLints: boolean
  377. materializedViewHasLints: boolean
  378. foreignTableHasLints: boolean
  379. apiAccessData?: TableApiAccessData
  380. }) => {
  381. const { ref } = useParams()
  382. let tooltipContent = null
  383. const accessWarning = 'Data is publicly accessible via API'
  384. const learnMoreCTA = (
  385. <InlineLink
  386. href={`/project/${ref}/editor/${entity.id}?schema=${entity.schema}&showWarning=true`}
  387. >
  388. Learn more
  389. </InlineLink>
  390. )
  391. switch (entity.type) {
  392. case ENTITY_TYPE.TABLE:
  393. if (tableHasRlsDisabledLint) {
  394. tooltipContent = (
  395. <>
  396. This table can be accessed by anyone via the Data API as RLS is disabled. {learnMoreCTA}
  397. .
  398. </>
  399. )
  400. }
  401. break
  402. case ENTITY_TYPE.VIEW:
  403. if (viewHasLints) {
  404. tooltipContent = (
  405. <>
  406. {accessWarning} as this is a Security definer view. {learnMoreCTA}.
  407. </>
  408. )
  409. }
  410. break
  411. case ENTITY_TYPE.MATERIALIZED_VIEW:
  412. if (materializedViewHasLints) {
  413. tooltipContent = (
  414. <>
  415. {accessWarning} as this is a Security definer view {learnMoreCTA}.
  416. </>
  417. )
  418. }
  419. break
  420. case ENTITY_TYPE.FOREIGN_TABLE:
  421. if (foreignTableHasLints) {
  422. tooltipContent = (
  423. <>
  424. {accessWarning} as RLS is not enforced on foreign tables. {learnMoreCTA}.
  425. </>
  426. )
  427. }
  428. break
  429. default:
  430. break
  431. }
  432. if (tooltipContent) {
  433. return (
  434. <Tooltip>
  435. <TooltipTrigger className="min-w-4">
  436. <Badge variant="destructive">Unrestricted</Badge>
  437. </TooltipTrigger>
  438. <TooltipContent side="right" className="max-w-52">
  439. {tooltipContent}
  440. </TooltipContent>
  441. </Tooltip>
  442. )
  443. }
  444. const isRlsEnabledNoPolicies =
  445. entity.type === ENTITY_TYPE.TABLE &&
  446. apiAccessData?.apiAccessType === 'access' &&
  447. tableHasRlsEnabledNoPolicyLint
  448. if (isRlsEnabledNoPolicies) {
  449. return (
  450. <Tooltip>
  451. <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
  452. <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
  453. </TooltipTrigger>
  454. <TooltipContent side="right" className="max-w-52">
  455. This table can be accessed via the Data API but no RLS policies exist so no data will be
  456. returned
  457. </TooltipContent>
  458. </Tooltip>
  459. )
  460. }
  461. const isApiExposedWithRlsAndPolicies =
  462. apiAccessData?.apiAccessType === 'access' && !tableHasRlsEnabledNoPolicyLint
  463. if (isApiExposedWithRlsAndPolicies) {
  464. return (
  465. <Tooltip>
  466. <TooltipTrigger className="min-w-4" aria-label="Table exposed via Data API">
  467. <Globe size={14} strokeWidth={1} className="text-foreground-lighter" />
  468. </TooltipTrigger>
  469. <TooltipContent side="right">This table can be accessed via the Data API</TooltipContent>
  470. </Tooltip>
  471. )
  472. }
  473. return null
  474. }