Files
supabase/apps/studio/components/ui/CodeEditor/CodeEditor.tsx
Joshen Lim 5f867e5f6c Feature Preview: RLS Tester (#45121)
## Context

Resolves FE-3077
Related discussion: https://github.com/orgs/supabase/discussions/45233

Verifying the correctness of your RLS policies set up has always been a
gap, as highlighted by a number of GitHub discussions like
[here](https://github.com/orgs/supabase/discussions/12269) and
[here](https://github.com/orgs/supabase/discussions/14401). As such,
we're piloting a dedicated UI for RLS testing (using role impersonation
as the base), in which you'll be able to
- Run a SQL query as a user (not logged in / logged in - this is the
role impersonation part)
- See which RLS policies are being evaluated as part of the query
- And hopefully be able to debug which policies are not set up correctly

Changes are currently set as a feature preview - and we'll iterate as we
get feedback from everyone 🙂 🙏

<img width="613" height="957" alt="image"
src="https://github.com/user-attachments/assets/83c37f8a-28fc-43b3-b0ff-e28571d8710c"
/>


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

* **New Features**
* RLS Tester: run queries as anon or authenticated users, view inferred
SQL, per-table policy summaries, and data previews of accessible rows.
* UI preview: new RLS Tester preview card and modal with opt-in toggle;
RLS Tester sheet with role/user selector and query editor.
  * SQLEditor: “Explain” tab is always visible.

* **Chores**
* Added supporting API endpoints, background checks for table RLS
status, and a local-storage flag to persist the preview opt-in.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-28 15:02:49 +08:00

262 lines
7.7 KiB
TypeScript

import Editor, { EditorProps, Monaco, OnChange, OnMount, useMonaco } from '@monaco-editor/react'
import { merge, noop } from 'lodash'
import type { editor } from 'monaco-editor'
import { MutableRefObject, useEffect, useRef, useState } from 'react'
import { cn, LogoLoader } from 'ui'
import { alignEditor } from './CodeEditor.utils'
import { Markdown } from '@/components/interfaces/Markdown'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import { timeout } from '@/lib/helpers'
type CodeEditorActions = { enabled: boolean; callback: (value: any) => void }
const DEFAULT_ACTIONS = {
runQuery: { enabled: false, callback: noop },
explainCode: { enabled: false, callback: noop },
formatDocument: { enabled: true, callback: noop },
placeholderFill: { enabled: true },
closeAssistant: { enabled: false, callback: noop },
}
interface CodeEditorProps {
id: string
language: 'pgsql' | 'json' | 'html' | 'typescript' | undefined
autofocus?: boolean
defaultValue?: string
isReadOnly?: boolean
hideLineNumbers?: boolean
className?: string
loading?: boolean
options?: EditorProps['options']
value?: string
placeholder?: string
/* Determines what actions to add for code editor context menu */
actions?: Partial<{
runQuery: CodeEditorActions
formatDocument: CodeEditorActions
placeholderFill: Omit<CodeEditorActions, 'callback'>
explainCode: CodeEditorActions
closeAssistant: CodeEditorActions
}>
editorRef?: MutableRefObject<editor.IStandaloneCodeEditor | undefined>
onInputChange?: (value?: string) => void
}
export const CodeEditor = ({
id,
language,
defaultValue,
autofocus = true,
isReadOnly = false,
hideLineNumbers = false,
className,
loading,
options,
value,
placeholder,
actions = DEFAULT_ACTIONS,
editorRef: editorRefProps,
onInputChange = noop,
}: CodeEditorProps) => {
const monaco = useMonaco()
const { data: project } = useSelectedProjectQuery()
const hasValue = useRef<any>()
const ref = useRef<editor.IStandaloneCodeEditor>()
const editorRef = editorRefProps || ref
const monacoRef = useRef<Monaco>()
const { runQuery, placeholderFill, formatDocument, explainCode, closeAssistant } = {
...DEFAULT_ACTIONS,
...actions,
}
const runQueryCallbackRef = useRef(runQuery.callback)
useEffect(() => {
runQueryCallbackRef.current = runQuery.callback
}, [runQuery.callback])
const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0
const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault)
const optionsMerged = merge(
{
tabSize: 2,
fontSize: 13,
readOnly: isReadOnly,
minimap: { enabled: false },
wordWrap: 'on',
fixedOverflowWidgets: true,
contextmenu: true,
lineNumbers: hideLineNumbers ? 'off' : undefined,
glyphMargin: hideLineNumbers ? false : undefined,
lineNumbersMinChars: hideLineNumbers ? 0 : 4,
folding: hideLineNumbers ? false : undefined,
scrollBeyondLastLine: false,
},
options
)
const onMount: OnMount = async (editor, monaco) => {
editorRef.current = editor
monacoRef.current = monaco
alignEditor(editor)
hasValue.current = editor.createContextKey('hasValue', false)
hasValue.current.set(value !== undefined && value.trim().length > 0)
setShowPlaceholder(showPlaceholderDefault)
if (placeholderFill.enabled) {
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? '').split('\n\n').join('\n').replaceAll('&nbsp;', ' '),
},
])
},
'!hasValue'
)
}
if (runQuery.enabled) {
editor.addAction({
id: 'run-query',
label: 'Run Query',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter],
contextMenuGroupId: 'operation',
contextMenuOrder: 0,
run: () => {
const selectedValue = (editorRef?.current as any)
.getModel()
.getValueInRange((editorRef?.current as any)?.getSelection())
runQueryCallbackRef.current(selectedValue || (editorRef?.current as any)?.getValue())
},
})
}
if (explainCode.enabled) {
editor.addAction({
id: 'explain-code',
label: 'Explain Code',
contextMenuGroupId: 'operation',
contextMenuOrder: 1,
run: () => {
const selectedValue = (editorRef?.current as any)
.getModel()
.getValueInRange((editorRef?.current as any)?.getSelection())
explainCode.callback(selectedValue)
},
})
}
if (closeAssistant.enabled) {
editor.addAction({
id: 'close-assistant',
label: 'Close Assistant',
keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI],
run: () => closeAssistant.callback(),
})
}
const model = editor.getModel()
if (model) {
const position = model.getPositionAt((value ?? '').length)
editor.setPosition(position)
}
await timeout(500)
if (autofocus) editor?.focus()
}
const onChangeContent: OnChange = (value) => {
hasValue.current.set((value ?? '').length > 0)
setShowPlaceholder(!value)
onInputChange(value)
}
useEffect(() => {
setShowPlaceholder(showPlaceholderDefault)
}, [showPlaceholderDefault])
useEffect(() => {
if (
placeholderFill.enabled &&
editorRef.current !== undefined &&
monacoRef.current !== undefined
) {
const editor = editorRef.current
const monaco = monacoRef.current
editor.addCommand(
monaco.KeyCode.Tab,
() => {
editor.executeEdits('source', [
{
// @ts-ignore
identifier: 'add-placeholder',
range: new monaco.Range(1, 1, 1, 1),
text: (placeholder ?? ' ')
.split('\n\n')
.join('\n')
.replaceAll('*', '')
.replaceAll('&nbsp;', ''),
},
])
},
'!hasValue'
)
}
}, [placeholder, placeholderFill.enabled])
useEffect(() => {
if (monaco && project && formatDocument.enabled) {
const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', {
async provideDocumentFormattingEdits(model: any) {
const value = model.getValue()
const formatted = formatSql(value)
formatDocument.callback(formatted)
return [{ range: model.getFullModelRange(), text: formatted }]
},
})
return () => formatProvider.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [monaco, project, formatDocument.enabled])
return (
<>
<Editor
path={id}
theme="supabase"
className={cn(className, 'monaco-editor')}
value={value ?? undefined}
language={language}
defaultValue={defaultValue ?? undefined}
loading={loading || <LogoLoader />}
options={optionsMerged}
onMount={onMount}
onChange={onChangeContent}
/>
{placeholder !== undefined && (
<div
className={cn(
'monaco-placeholder absolute top-[3px] left-[57px] text-sm pointer-events-none font-mono',
'[&>div>p]:text-foreground-lighter [&>div>p]:!m-0 tracking-tighter',
showPlaceholder ? 'block' : 'hidden'
)}
>
<Markdown content={placeholder} />
</div>
)}
</>
)
}
export default CodeEditor