GridHeaderActions.tsx425 lines · main
| 1 | // @ts-nocheck |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { useParams } from 'common' |
| 4 | import { Realtime } from 'icons' |
| 5 | import { BookOpenText, Lightbulb, Lock, MoreVertical, PlusCircle, Unlock } from 'lucide-react' |
| 6 | import Link from 'next/link' |
| 7 | import { parseAsBoolean, useQueryState } from 'nuqs' |
| 8 | import { useState } from 'react' |
| 9 | import { toast } from 'sonner' |
| 10 | import { |
| 11 | Button, |
| 12 | cn, |
| 13 | DropdownMenu, |
| 14 | DropdownMenuContent, |
| 15 | DropdownMenuItem, |
| 16 | DropdownMenuSeparator, |
| 17 | DropdownMenuTrigger, |
| 18 | Popover, |
| 19 | PopoverContent, |
| 20 | PopoverTrigger, |
| 21 | Tooltip, |
| 22 | TooltipContent, |
| 23 | TooltipTrigger, |
| 24 | } from 'ui' |
| 25 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 26 | |
| 27 | import { EnableIndexAdvisorDialog } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton' |
| 28 | import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover' |
| 29 | import { InsertButton } from './InsertButton' |
| 30 | import { RealtimeToggleDialog } from './RealtimeToggleDialog' |
| 31 | import { SecurityDefinerViewPopover } from './SecurityDefinerViewPopover' |
| 32 | import { ViewEntityAutofixSecurityModal } from './ViewEntityAutofixSecurityModal' |
| 33 | import { RefreshButton } from '@/components/grid/components/header/RefreshButton' |
| 34 | import { useTableIndexAdvisor } from '@/components/grid/context/TableIndexAdvisorContext' |
| 35 | import { |
| 36 | getEntityLintDetails, |
| 37 | getTablePoliciesUrl, |
| 38 | } from '@/components/interfaces/TableGridEditor/TableEntity.utils' |
| 39 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 40 | import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' |
| 41 | import { useIsTableRealtimeEnabled } from '@/data/database-publications/database-publications-query' |
| 42 | import { useProjectLintsQuery } from '@/data/lint/lint-query' |
| 43 | import { |
| 44 | Entity, |
| 45 | isTableLike, |
| 46 | isForeignTable as isTableLikeForeignTable, |
| 47 | isMaterializedView as isTableLikeMaterializedView, |
| 48 | isView as isTableLikeView, |
| 49 | } from '@/data/table-editor/table-editor-types' |
| 50 | import { useTableUpdateMutation } from '@/data/tables/table-update-mutation' |
| 51 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 52 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 53 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 54 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 55 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 56 | import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas' |
| 57 | import { DOCS_URL } from '@/lib/constants' |
| 58 | import { useTrack } from '@/lib/telemetry/track' |
| 59 | import { useAppStateSnapshot } from '@/state/app-state' |
| 60 | import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' |
| 61 | |
| 62 | export interface GridHeaderActionsProps { |
| 63 | table: Entity |
| 64 | isRefetching: boolean |
| 65 | } |
| 66 | export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => { |
| 67 | const track = useTrack() |
| 68 | const { ref } = useParams() |
| 69 | const appSnap = useAppStateSnapshot() |
| 70 | const snap = useTableEditorTableStateSnapshot() |
| 71 | const { data: project } = useSelectedProjectQuery() |
| 72 | const { data: org } = useSelectedOrganizationQuery() |
| 73 | const { mutate: sendEvent } = useSendEventMutation() |
| 74 | |
| 75 | const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false) |
| 76 | const [realtimeDialogOpen, setRealtimeDialogOpen] = useState(false) |
| 77 | const [indexAdvisorDialogOpen, setIndexAdvisorDialogOpen] = useState(false) |
| 78 | const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false) |
| 79 | |
| 80 | const [showWarning, setShowWarning] = useQueryState( |
| 81 | 'showWarning', |
| 82 | parseAsBoolean.withDefault(false) |
| 83 | ) |
| 84 | |
| 85 | // need project lints to get security status for views |
| 86 | const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref }) |
| 87 | |
| 88 | // Use table-specific index advisor context |
| 89 | const { isAvailable: isIndexAdvisorAvailable, isEnabled: isIndexAdvisorEnabled } = |
| 90 | useTableIndexAdvisor() |
| 91 | |
| 92 | const isTable = isTableLike(table) |
| 93 | const isForeignTable = isTableLikeForeignTable(table) |
| 94 | const isView = isTableLikeView(table) |
| 95 | const isMaterializedView = isTableLikeMaterializedView(table) |
| 96 | |
| 97 | const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) |
| 98 | const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema }) |
| 99 | |
| 100 | const isRealtimeEnabled = useIsTableRealtimeEnabled({ id: table.id }) |
| 101 | |
| 102 | const { mutate: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({ |
| 103 | onError: (error) => { |
| 104 | toast.error(`Failed to toggle RLS: ${error.message}`) |
| 105 | }, |
| 106 | onSettled: () => { |
| 107 | closeConfirmModal() |
| 108 | }, |
| 109 | }) |
| 110 | |
| 111 | const showHeaderActions = snap.selectedRows.size === 0 |
| 112 | |
| 113 | const projectRef = project?.ref |
| 114 | const { data } = useDatabasePoliciesQuery({ |
| 115 | projectRef: project?.ref, |
| 116 | connectionString: project?.connectionString, |
| 117 | }) |
| 118 | const policies = (data ?? []).filter( |
| 119 | (policy) => policy.schema === table.schema && policy.table === table.name |
| 120 | ) |
| 121 | |
| 122 | const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( |
| 123 | PermissionAction.TENANT_SQL_ADMIN_WRITE, |
| 124 | 'tables' |
| 125 | ) |
| 126 | const { can: canSqlWriteColumns } = useAsyncCheckPermissions( |
| 127 | PermissionAction.TENANT_SQL_ADMIN_WRITE, |
| 128 | 'columns' |
| 129 | ) |
| 130 | const isReadOnly = !isLoadingPermissions && !canSqlWriteTables && !canSqlWriteColumns |
| 131 | // This will change when we allow autogenerated API docs for schemas other than `public` |
| 132 | const doesHaveAutoGeneratedAPIDocs = table.schema === 'public' |
| 133 | |
| 134 | const { hasLint: tableHasLints } = getEntityLintDetails( |
| 135 | table.name, |
| 136 | 'rls_disabled_in_public', |
| 137 | ['ERROR'], |
| 138 | lints, |
| 139 | table.schema |
| 140 | ) |
| 141 | |
| 142 | const { hasLint: viewHasLints, matchingLint: matchingViewLint } = getEntityLintDetails( |
| 143 | table.name, |
| 144 | 'security_definer_view', |
| 145 | ['ERROR', 'WARN'], |
| 146 | lints, |
| 147 | table.schema |
| 148 | ) |
| 149 | |
| 150 | const { hasLint: materializedViewHasLints, matchingLint: matchingMaterializedViewLint } = |
| 151 | getEntityLintDetails( |
| 152 | table.name, |
| 153 | 'materialized_view_in_api', |
| 154 | ['ERROR', 'WARN'], |
| 155 | lints, |
| 156 | table.schema |
| 157 | ) |
| 158 | |
| 159 | const closeConfirmModal = () => { |
| 160 | setRlsConfirmModalOpen(false) |
| 161 | } |
| 162 | |
| 163 | const onViewAPIDocs = () => { |
| 164 | appSnap.setActiveDocsSection(['entities', table.name]) |
| 165 | appSnap.setShowProjectApiDocs(true) |
| 166 | |
| 167 | sendEvent({ |
| 168 | action: 'api_docs_opened', |
| 169 | properties: { |
| 170 | source: 'table_editor', |
| 171 | }, |
| 172 | groups: { |
| 173 | project: ref ?? 'Unknown', |
| 174 | organization: org?.slug ?? 'Unknown', |
| 175 | }, |
| 176 | }) |
| 177 | } |
| 178 | |
| 179 | const onToggleRLS = async () => { |
| 180 | const payload = { |
| 181 | id: table.id, |
| 182 | rls_enabled: !(isTable && table.rls_enabled), |
| 183 | } |
| 184 | |
| 185 | updateTable({ |
| 186 | projectRef: project?.ref!, |
| 187 | connectionString: project?.connectionString, |
| 188 | id: table.id, |
| 189 | name: table.name, |
| 190 | schema: table.schema, |
| 191 | payload: payload, |
| 192 | }) |
| 193 | |
| 194 | track('table_rls_enabled', { |
| 195 | method: 'table_editor', |
| 196 | schema_name: table.schema, |
| 197 | table_name: table.name, |
| 198 | }) |
| 199 | } |
| 200 | |
| 201 | return ( |
| 202 | <div className="sb-grid-header__inner"> |
| 203 | {showHeaderActions && ( |
| 204 | <div className="flex items-center gap-x-2"> |
| 205 | {isReadOnly && ( |
| 206 | <Tooltip> |
| 207 | <TooltipTrigger asChild> |
| 208 | <div className="border border-strong rounded-sm bg-overlay-hover px-3 py-1 text-xs"> |
| 209 | Viewing as read-only |
| 210 | </div> |
| 211 | </TooltipTrigger> |
| 212 | <TooltipContent side="bottom"> |
| 213 | You need additional permissions to manage your project's data |
| 214 | </TooltipContent> |
| 215 | </Tooltip> |
| 216 | )} |
| 217 | |
| 218 | {isTable && !isSchemaLocked ? ( |
| 219 | table.rls_enabled ? ( |
| 220 | <> |
| 221 | {policies.length < 1 && !isSchemaLocked ? ( |
| 222 | <ButtonTooltip |
| 223 | asChild |
| 224 | type="default" |
| 225 | className="group" |
| 226 | icon={<PlusCircle strokeWidth={1.5} className="text-foreground-muted" />} |
| 227 | tooltip={{ |
| 228 | content: { |
| 229 | side: 'bottom', |
| 230 | className: 'w-[280px]', |
| 231 | text: 'RLS is enabled for this table, but no policies are set. Select queries may return 0 results.', |
| 232 | }, |
| 233 | }} |
| 234 | > |
| 235 | <Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}> |
| 236 | Add RLS policy |
| 237 | </Link> |
| 238 | </ButtonTooltip> |
| 239 | ) : ( |
| 240 | <Button |
| 241 | asChild |
| 242 | type={policies.length < 1 && !isSchemaLocked ? 'warning' : 'default'} |
| 243 | className="group" |
| 244 | icon={ |
| 245 | isSchemaLocked || policies.length > 0 ? ( |
| 246 | <div |
| 247 | className={cn( |
| 248 | 'flex items-center justify-center rounded-full bg-border-stronger h-[16px]', |
| 249 | policies.length > 9 ? ' px-1' : 'w-[16px]', |
| 250 | '' |
| 251 | )} |
| 252 | > |
| 253 | <span className="text-[11px] text-foreground font-mono text-center"> |
| 254 | {policies.length} |
| 255 | </span> |
| 256 | </div> |
| 257 | ) : ( |
| 258 | <PlusCircle strokeWidth={1.5} /> |
| 259 | ) |
| 260 | } |
| 261 | > |
| 262 | <Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}> |
| 263 | RLS {policies.length > 1 ? 'policies' : 'policy'} |
| 264 | </Link> |
| 265 | </Button> |
| 266 | )} |
| 267 | </> |
| 268 | ) : tableHasLints ? ( |
| 269 | <Popover modal={false} open={showWarning} onOpenChange={setShowWarning}> |
| 270 | <PopoverTrigger asChild> |
| 271 | <Button type="danger" icon={<Lock strokeWidth={1.5} />}> |
| 272 | RLS disabled |
| 273 | </Button> |
| 274 | </PopoverTrigger> |
| 275 | <PopoverContent className="w-80 text-sm" align="end"> |
| 276 | <h4 className="flex items-center gap-2"> |
| 277 | <Lock size={16} /> Row Level Security (RLS) |
| 278 | </h4> |
| 279 | <div className="grid gap-2 mt-4 text-foreground-light text-xs"> |
| 280 | <p> |
| 281 | You can restrict and control who can read, write and update data in this table |
| 282 | using Row Level Security. |
| 283 | </p> |
| 284 | <p> |
| 285 | With RLS enabled, anonymous users will not be able to read/write data in the |
| 286 | table. |
| 287 | </p> |
| 288 | {!isSchemaLocked && ( |
| 289 | <Button |
| 290 | type="default" |
| 291 | className="mt-2 w-min" |
| 292 | onClick={() => setRlsConfirmModalOpen(!rlsConfirmModalOpen)} |
| 293 | > |
| 294 | Enable RLS for this table |
| 295 | </Button> |
| 296 | )} |
| 297 | </div> |
| 298 | </PopoverContent> |
| 299 | </Popover> |
| 300 | ) : null |
| 301 | ) : null} |
| 302 | |
| 303 | {isView && viewHasLints && ( |
| 304 | <SecurityDefinerViewPopover |
| 305 | lint={matchingViewLint} |
| 306 | onAutofix={() => { |
| 307 | setIsAutofixViewSecurityModalOpen(true) |
| 308 | }} |
| 309 | /> |
| 310 | )} |
| 311 | |
| 312 | {isMaterializedView && materializedViewHasLints && ( |
| 313 | <SecurityDefinerViewPopover lint={matchingMaterializedViewLint} /> |
| 314 | )} |
| 315 | |
| 316 | {isForeignTable && table.schema === 'public' && ( |
| 317 | <Popover modal={false} open={showWarning} onOpenChange={setShowWarning}> |
| 318 | <PopoverTrigger asChild> |
| 319 | <Button type="warning" icon={<Unlock strokeWidth={1.5} />}> |
| 320 | Unprotected Data API access |
| 321 | </Button> |
| 322 | </PopoverTrigger> |
| 323 | <PopoverContent className="min-w-[395px] text-sm" align="end"> |
| 324 | <h3 className="flex items-center gap-2"> |
| 325 | <Unlock size={16} /> Secure Foreign table |
| 326 | </h3> |
| 327 | <div className="grid gap-2 mt-4 text-foreground-light text-sm"> |
| 328 | <p> |
| 329 | Foreign tables do not enforce RLS, which may allow unrestricted access. To |
| 330 | secure them, either move foreign tables to a private schema not exposed by |
| 331 | PostgREST, or <a href="">disable PostgREST access</a> entirely. |
| 332 | </p> |
| 333 | |
| 334 | <div className="mt-2"> |
| 335 | <Button type="default" asChild> |
| 336 | <Link |
| 337 | target="_blank" |
| 338 | href={`${DOCS_URL}/guides/database/extensions/wrappers/overview#security`} |
| 339 | > |
| 340 | Learn more |
| 341 | </Link> |
| 342 | </Button> |
| 343 | </div> |
| 344 | </div> |
| 345 | </PopoverContent> |
| 346 | </Popover> |
| 347 | )} |
| 348 | |
| 349 | <RoleImpersonationPopover header="View data as a role" align="center" /> |
| 350 | |
| 351 | <DropdownMenu> |
| 352 | <DropdownMenuTrigger asChild> |
| 353 | <Button |
| 354 | type="default" |
| 355 | icon={<MoreVertical />} |
| 356 | className="h-7 w-7" |
| 357 | aria-label="More actions" |
| 358 | /> |
| 359 | </DropdownMenuTrigger> |
| 360 | <DropdownMenuContent className="w-48"> |
| 361 | {isTable && realtimeEnabled && ( |
| 362 | <DropdownMenuItem className="gap-x-2" onClick={() => setRealtimeDialogOpen(true)}> |
| 363 | <Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} /> |
| 364 | <span>{isRealtimeEnabled ? 'Disable' : 'Enable'} Realtime</span> |
| 365 | </DropdownMenuItem> |
| 366 | )} |
| 367 | {doesHaveAutoGeneratedAPIDocs && ( |
| 368 | <DropdownMenuItem className="gap-x-2" onClick={() => onViewAPIDocs()}> |
| 369 | <BookOpenText size={14} /> |
| 370 | <span>View API docs</span> |
| 371 | </DropdownMenuItem> |
| 372 | )} |
| 373 | {isTable && isIndexAdvisorAvailable && !isIndexAdvisorEnabled && ( |
| 374 | <> |
| 375 | <DropdownMenuSeparator /> |
| 376 | <DropdownMenuItem |
| 377 | className="gap-x-2" |
| 378 | onClick={() => setIndexAdvisorDialogOpen(true)} |
| 379 | > |
| 380 | <Lightbulb size={14} /> |
| 381 | <span>Enable Index Advisor</span> |
| 382 | </DropdownMenuItem> |
| 383 | </> |
| 384 | )} |
| 385 | </DropdownMenuContent> |
| 386 | </DropdownMenu> |
| 387 | |
| 388 | <RefreshButton tableId={table.id} isRefetching={isRefetching} /> |
| 389 | |
| 390 | {showHeaderActions && <InsertButton />} |
| 391 | </div> |
| 392 | )} |
| 393 | |
| 394 | <ViewEntityAutofixSecurityModal |
| 395 | table={table} |
| 396 | isAutofixViewSecurityModalOpen={isAutofixViewSecurityModalOpen} |
| 397 | setIsAutofixViewSecurityModalOpen={setIsAutofixViewSecurityModalOpen} |
| 398 | /> |
| 399 | |
| 400 | {isTable && ( |
| 401 | <ConfirmationModal |
| 402 | visible={rlsConfirmModalOpen} |
| 403 | variant={table.rls_enabled ? 'destructive' : 'default'} |
| 404 | title={`${table.rls_enabled ? 'Disable' : 'Enable'} Row Level Security`} |
| 405 | description={`Are you sure you want to ${ |
| 406 | table.rls_enabled ? 'disable' : 'enable' |
| 407 | } Row Level Security for this table?`} |
| 408 | confirmLabel={`${table.rls_enabled ? 'Disable' : 'Enable'} RLS`} |
| 409 | confirmLabelLoading={`${table.rls_enabled ? 'Disabling' : 'Enabling'} RLS`} |
| 410 | loading={isUpdatingTable} |
| 411 | onCancel={closeConfirmModal} |
| 412 | onConfirm={onToggleRLS} |
| 413 | /> |
| 414 | )} |
| 415 | |
| 416 | <RealtimeToggleDialog |
| 417 | table={table} |
| 418 | open={realtimeDialogOpen} |
| 419 | setOpen={setRealtimeDialogOpen} |
| 420 | /> |
| 421 | |
| 422 | <EnableIndexAdvisorDialog open={indexAdvisorDialogOpen} setOpen={setIndexAdvisorDialogOpen} /> |
| 423 | </div> |
| 424 | ) |
| 425 | } |