GridHeaderActions.tsx425 lines · main
1// @ts-nocheck
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { Realtime } from 'icons'
5import { BookOpenText, Lightbulb, Lock, MoreVertical, PlusCircle, Unlock } from 'lucide-react'
6import Link from 'next/link'
7import { parseAsBoolean, useQueryState } from 'nuqs'
8import { useState } from 'react'
9import { toast } from 'sonner'
10import {
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'
25import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
26
27import { EnableIndexAdvisorDialog } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
28import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover'
29import { InsertButton } from './InsertButton'
30import { RealtimeToggleDialog } from './RealtimeToggleDialog'
31import { SecurityDefinerViewPopover } from './SecurityDefinerViewPopover'
32import { ViewEntityAutofixSecurityModal } from './ViewEntityAutofixSecurityModal'
33import { RefreshButton } from '@/components/grid/components/header/RefreshButton'
34import { useTableIndexAdvisor } from '@/components/grid/context/TableIndexAdvisorContext'
35import {
36 getEntityLintDetails,
37 getTablePoliciesUrl,
38} from '@/components/interfaces/TableGridEditor/TableEntity.utils'
39import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
40import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
41import { useIsTableRealtimeEnabled } from '@/data/database-publications/database-publications-query'
42import { useProjectLintsQuery } from '@/data/lint/lint-query'
43import {
44 Entity,
45 isTableLike,
46 isForeignTable as isTableLikeForeignTable,
47 isMaterializedView as isTableLikeMaterializedView,
48 isView as isTableLikeView,
49} from '@/data/table-editor/table-editor-types'
50import { useTableUpdateMutation } from '@/data/tables/table-update-mutation'
51import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
52import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
53import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
54import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
55import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
56import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
57import { DOCS_URL } from '@/lib/constants'
58import { useTrack } from '@/lib/telemetry/track'
59import { useAppStateSnapshot } from '@/state/app-state'
60import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
61
62export interface GridHeaderActionsProps {
63 table: Entity
64 isRefetching: boolean
65}
66export 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}