Files
supabase/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
T
Danny White 1470c51021 fix(studio): align rls confirmation dialogs (#45961)
## What kind of change does this PR introduce?

Studio UI cleanup and docs update. Resolves FE-3217.

## What is the current behavior?

The table RLS confirmation dialogs differ across the Policies page and
Table Editor surfaces. The table editor disable flow also uses a verbose
confirmation modal with admonitions and repeated warning copy.

| Policies | Table Editor |
| --- | --- |
| <img width="852" height="448" alt="25125"
src="https://github.com/user-attachments/assets/baa9fafa-752c-4701-bf54-40da4e32030b"
/> | <img width="1108" height="1016" alt="57354"
src="https://github.com/user-attachments/assets/b1b88355-be39-49b9-aed2-a60f20c25104"
/> |
| <img width="866" height="434" alt="CleanShot 2026-05-15 at 18 01
15@2x"
src="https://github.com/user-attachments/assets/d67f69ee-0a49-4dbd-939e-ca968361fc33"
/> | <img width="840" height="426" alt="CleanShot 2026-05-15 at 18 01
52@2x"
src="https://github.com/user-attachments/assets/a0b5f390-abeb-453f-8636-ad097f22308b"
/> |

## What is the new behavior?

Table RLS enable and disable confirmations now use one shared concise
Alert Dialog treatment across the Policies page, Table Editor header,
and Table Editor side panel. The server-backed toggles use the async
Alert Dialog action behaviour from the stacked base branch, and the
design-system docs now clarify when to start with Alert Dialog versus
Confirmation Modal.

This PR is stacked on #45960.

| Before | After |
| --- | --- |
| <img width="852" height="448" alt="25125"
src="https://github.com/user-attachments/assets/baa9fafa-752c-4701-bf54-40da4e32030b"
/> | <img width="864" height="518" alt="CleanShot 2026-05-15 at 18 07
21@2x"
src="https://github.com/user-attachments/assets/bda53246-164b-4d5b-81a1-25d3bc661eb3"
/> |
| <img width="1108" height="1016" alt="57354"
src="https://github.com/user-attachments/assets/b1b88355-be39-49b9-aed2-a60f20c25104"
/> | <img width="864" height="518" alt="CleanShot 2026-05-15 at 18 07
21@2x"
src="https://github.com/user-attachments/assets/bda53246-164b-4d5b-81a1-25d3bc661eb3"
/> |
| <img width="866" height="434" alt="CleanShot 2026-05-15 at 18 01
15@2x"
src="https://github.com/user-attachments/assets/d67f69ee-0a49-4dbd-939e-ca968361fc33"
/> | <img width="860" height="488" alt="CleanShot 2026-05-15 at 18 08
41@2x"
src="https://github.com/user-attachments/assets/4c67f797-e2cd-4d56-a49d-e6c6fc0edff3"
/> |
| <img width="840" height="426" alt="CleanShot 2026-05-15 at 18 01
52@2x"
src="https://github.com/user-attachments/assets/a0b5f390-abeb-453f-8636-ad097f22308b"
/> | <img width="860" height="488" alt="CleanShot 2026-05-15 at 18 08
41@2x"
src="https://github.com/user-attachments/assets/4c67f797-e2cd-4d56-a49d-e6c6fc0edff3"
/> |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Refined guidance and wording for dialog usage, clarifying when to use
Alert Dialog, Confirmation Modal, and modal modality.

* **New Features**
* Reworked RLS confirmation flow with a streamlined toggle dialog,
clearer messaging, and a “Learn more” link to docs.

* **Tests**
* Updated end-to-end tests to cover the updated RLS dialog flows and
explanatory content.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45961?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-25 22:47:40 +00:00

415 lines
15 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { Realtime } from 'icons'
import { BookOpenText, Lightbulb, Lock, MoreVertical, PlusCircle, Unlock } from 'lucide-react'
import Link from 'next/link'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { EnableIndexAdvisorDialog } from '../QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton'
import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover'
import { InsertButton } from './InsertButton'
import { RealtimeToggleDialog } from './RealtimeToggleDialog'
import { SecurityDefinerViewPopover } from './SecurityDefinerViewPopover'
import { ViewEntityAutofixSecurityModal } from './ViewEntityAutofixSecurityModal'
import { RefreshButton } from '@/components/grid/components/header/RefreshButton'
import { useTableIndexAdvisor } from '@/components/grid/context/TableIndexAdvisorContext'
import { RLSToggleDialog } from '@/components/interfaces/Database/RLSToggleDialog'
import {
getEntityLintDetails,
getTablePoliciesUrl,
} from '@/components/interfaces/TableGridEditor/TableEntity.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
import { useIsTableRealtimeEnabled } from '@/data/database-publications/database-publications-query'
import { useProjectLintsQuery } from '@/data/lint/lint-query'
import {
Entity,
isTableLike,
isForeignTable as isTableLikeForeignTable,
isMaterializedView as isTableLikeMaterializedView,
isView as isTableLikeView,
} from '@/data/table-editor/table-editor-types'
import { useTableUpdateMutation } from '@/data/tables/table-update-mutation'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
import { DOCS_URL } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
import { useAppStateSnapshot } from '@/state/app-state'
import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table'
export interface GridHeaderActionsProps {
table: Entity
isRefetching: boolean
}
export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProps) => {
const track = useTrack()
const { ref } = useParams()
const appSnap = useAppStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
const [realtimeDialogOpen, setRealtimeDialogOpen] = useState(false)
const [indexAdvisorDialogOpen, setIndexAdvisorDialogOpen] = useState(false)
const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false)
const [showWarning, setShowWarning] = useQueryState(
'showWarning',
parseAsBoolean.withDefault(false)
)
// need project lints to get security status for views
const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref })
// Use table-specific index advisor context
const { isAvailable: isIndexAdvisorAvailable, isEnabled: isIndexAdvisorEnabled } =
useTableIndexAdvisor()
const isTable = isTableLike(table)
const isForeignTable = isTableLikeForeignTable(table)
const isView = isTableLikeView(table)
const isMaterializedView = isTableLikeMaterializedView(table)
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema })
const isRealtimeEnabled = useIsTableRealtimeEnabled({ id: table.id })
const { mutateAsync: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({
onError: (error) => {
toast.error(`Failed to toggle RLS: ${error.message}`)
},
})
const showHeaderActions = snap.selectedRows.size === 0
const projectRef = project?.ref
const { data } = useDatabasePoliciesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const policies = (data ?? []).filter(
(policy) => policy.schema === table.schema && policy.table === table.name
)
const { can: canSqlWriteTables, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'tables'
)
const { can: canSqlWriteColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
const isReadOnly = !isLoadingPermissions && !canSqlWriteTables && !canSqlWriteColumns
// This will change when we allow autogenerated API docs for schemas other than `public`
const doesHaveAutoGeneratedAPIDocs = table.schema === 'public'
const { hasLint: tableHasLints } = getEntityLintDetails(
table.name,
'rls_disabled_in_public',
['ERROR'],
lints,
table.schema
)
const { hasLint: viewHasLints, matchingLint: matchingViewLint } = getEntityLintDetails(
table.name,
'security_definer_view',
['ERROR', 'WARN'],
lints,
table.schema
)
const { hasLint: materializedViewHasLints, matchingLint: matchingMaterializedViewLint } =
getEntityLintDetails(
table.name,
'materialized_view_in_api',
['ERROR', 'WARN'],
lints,
table.schema
)
const onViewAPIDocs = () => {
appSnap.setActiveDocsSection(['entities', table.name])
appSnap.setShowProjectApiDocs(true)
sendEvent({
action: 'api_docs_opened',
properties: {
source: 'table_editor',
},
groups: {
project: ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
}
const onToggleRLS = async () => {
const payload = {
id: table.id,
rls_enabled: !(isTable && table.rls_enabled),
}
const updateTablePromise = updateTable({
projectRef: project?.ref!,
connectionString: project?.connectionString,
id: table.id,
name: table.name,
schema: table.schema,
payload: payload,
})
track('table_rls_enabled', {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
})
return updateTablePromise
}
return (
<div className="sb-grid-header__inner">
{showHeaderActions && (
<div className="flex items-center gap-x-2">
{isReadOnly && (
<Tooltip>
<TooltipTrigger asChild>
<div className="border border-strong rounded-sm bg-overlay-hover px-3 py-1 text-xs">
Viewing as read-only
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
You need additional permissions to manage your project's data
</TooltipContent>
</Tooltip>
)}
{isTable && !isSchemaLocked ? (
table.rls_enabled ? (
<>
{policies.length < 1 && !isSchemaLocked ? (
<ButtonTooltip
asChild
type="default"
className="group"
icon={<PlusCircle strokeWidth={1.5} className="text-foreground-muted" />}
tooltip={{
content: {
side: 'bottom',
className: 'w-[280px]',
text: 'RLS is enabled for this table, but no policies are set. Select queries may return 0 results.',
},
}}
>
<Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}>
Add RLS policy
</Link>
</ButtonTooltip>
) : (
<Button
asChild
type={policies.length < 1 && !isSchemaLocked ? 'warning' : 'default'}
className="group"
icon={
isSchemaLocked || policies.length > 0 ? (
<div
className={cn(
'flex items-center justify-center rounded-full bg-border-stronger h-[16px]',
policies.length > 9 ? ' px-1' : 'w-[16px]',
''
)}
>
<span className="text-[11px] text-foreground font-mono text-center">
{policies.length}
</span>
</div>
) : (
<PlusCircle strokeWidth={1.5} />
)
}
>
<Link passHref href={getTablePoliciesUrl(projectRef, table.schema, table.name)}>
RLS {policies.length > 1 ? 'policies' : 'policy'}
</Link>
</Button>
)}
</>
) : tableHasLints ? (
<Popover modal={false} open={showWarning} onOpenChange={setShowWarning}>
<PopoverTrigger asChild>
<Button type="danger" icon={<Lock strokeWidth={1.5} />}>
RLS disabled
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm" align="end">
<h4 className="flex items-center gap-2">
<Lock size={16} /> Row Level Security (RLS)
</h4>
<div className="grid gap-2 mt-4 text-foreground-light text-xs">
<p>
You can restrict and control who can read, write and update data in this table
using Row Level Security.
</p>
<p>
With RLS enabled, anonymous users will not be able to read/write data in the
table.
</p>
{!isSchemaLocked && (
<Button
type="default"
className="mt-2 w-min"
onClick={() => setRlsConfirmModalOpen(!rlsConfirmModalOpen)}
>
Enable RLS for this table
</Button>
)}
</div>
</PopoverContent>
</Popover>
) : null
) : null}
{isView && viewHasLints && (
<SecurityDefinerViewPopover
lint={matchingViewLint}
onAutofix={() => {
setIsAutofixViewSecurityModalOpen(true)
}}
/>
)}
{isMaterializedView && materializedViewHasLints && (
<SecurityDefinerViewPopover lint={matchingMaterializedViewLint} />
)}
{isForeignTable && table.schema === 'public' && (
<Popover modal={false} open={showWarning} onOpenChange={setShowWarning}>
<PopoverTrigger asChild>
<Button type="warning" icon={<Unlock strokeWidth={1.5} />}>
Unprotected Data API access
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[395px] text-sm" align="end">
<h3 className="flex items-center gap-2">
<Unlock size={16} /> Secure Foreign table
</h3>
<div className="grid gap-2 mt-4 text-foreground-light text-sm">
<p>
Foreign tables do not enforce RLS, which may allow unrestricted access. To
secure them, either move foreign tables to a private schema not exposed by
PostgREST, or <a href="">disable PostgREST access</a> entirely.
</p>
<div className="mt-2">
<Button type="default" asChild>
<Link
target="_blank"
href={`${DOCS_URL}/guides/database/extensions/wrappers/overview#security`}
>
Learn more
</Link>
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
<RoleImpersonationPopover header="View data as a role" align="center" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
icon={<MoreVertical />}
className="h-7 w-7"
aria-label="More actions"
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{isTable && realtimeEnabled && (
<DropdownMenuItem className="gap-x-2" onClick={() => setRealtimeDialogOpen(true)}>
<Realtime size={14} className={isRealtimeEnabled ? 'text-brand' : ''} />
<span>{isRealtimeEnabled ? 'Disable' : 'Enable'} Realtime</span>
</DropdownMenuItem>
)}
{doesHaveAutoGeneratedAPIDocs && (
<DropdownMenuItem className="gap-x-2" onClick={() => onViewAPIDocs()}>
<BookOpenText size={14} />
<span>View API docs</span>
</DropdownMenuItem>
)}
{isTable && isIndexAdvisorAvailable && !isIndexAdvisorEnabled && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2"
onClick={() => setIndexAdvisorDialogOpen(true)}
>
<Lightbulb size={14} />
<span>Enable Index Advisor</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<RefreshButton tableId={table.id} isRefetching={isRefetching} />
{showHeaderActions && <InsertButton />}
</div>
)}
<ViewEntityAutofixSecurityModal
table={table}
isAutofixViewSecurityModalOpen={isAutofixViewSecurityModalOpen}
setIsAutofixViewSecurityModalOpen={setIsAutofixViewSecurityModalOpen}
/>
{isTable && (
<RLSToggleDialog
open={rlsConfirmModalOpen}
tableName={table.name}
isEnabled={table.rls_enabled}
isSubmitting={isUpdatingTable}
onOpenChange={setRlsConfirmModalOpen}
onConfirm={onToggleRLS}
/>
)}
<RealtimeToggleDialog
table={table}
open={realtimeDialogOpen}
setOpen={setRealtimeDialogOpen}
/>
<EnableIndexAdvisorDialog open={indexAdvisorDialogOpen} setOpen={setIndexAdvisorDialogOpen} />
</div>
)
}