index.tsx634 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { Monaco } from '@monaco-editor/react'
3import {
4 acceptUntrustedSql,
5 ident,
6 joinSqlFragments,
7 safeSql,
8 untrustedSql,
9 type DisplayableSqlFragment,
10 type SafeSqlFragment,
11} from '@supabase/pg-meta/src/pg-format'
12import { PermissionAction } from '@supabase/shared-types/out/constants'
13import { useQueryClient } from '@tanstack/react-query'
14import { useParams } from 'common'
15import { isEqual } from 'lodash'
16import { memo, useCallback, useEffect, useRef, useState } from 'react'
17import { useForm } from 'react-hook-form'
18import { toast } from 'sonner'
19import {
20 Button,
21 Checkbox,
22 cn,
23 Form,
24 Label,
25 ScrollArea,
26 Sheet,
27 SheetContent,
28 SheetFooter,
29 Tabs_Shadcn_,
30 TabsContent_Shadcn_,
31 TabsList_Shadcn_,
32 TabsTrigger_Shadcn_,
33} from 'ui'
34import * as z from 'zod'
35
36import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection'
37import { PolicyDetailsV2 } from './PolicyDetailsV2'
38import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils'
39import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader'
40import { PolicyTemplates } from './PolicyTemplates'
41import { QueryError } from './QueryError'
42import { RLSCodeEditor } from './RLSCodeEditor'
43import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
44import { IStandaloneCodeEditor } from '@/components/interfaces/SQLEditor/SQLEditor.types'
45import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
46import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
47import { useDatabasePolicyUpdateMutation } from '@/data/database-policies/database-policy-update-mutation'
48import { databasePoliciesKeys } from '@/data/database-policies/keys'
49import { QueryResponseError, useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
50import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
51import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
52import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
53import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
54
55interface PolicyEditorPanelProps {
56 visible: boolean
57 schema: string
58 searchString?: string
59 selectedTable?: string
60 selectedPolicy?: Policy
61 onSelectCancel: () => void
62 authContext: 'database' | 'realtime'
63}
64
65const FORM_ID = 'rls-editor'
66const FormSchema = z.object({
67 name: z.string().min(1, 'Please provide a name'),
68 table: z.string(),
69 behavior: z.string(),
70 command: z.string(),
71 roles: z.string(),
72})
73const defaultValues = {
74 name: '',
75 table: '',
76 behavior: 'permissive',
77 command: 'select',
78 roles: '',
79}
80
81/**
82 * Using memo for this component because everything rerenders on window focus because of outside fetches
83 */
84export const PolicyEditorPanel = memo(function ({
85 visible,
86 schema,
87 searchString,
88 selectedTable,
89 selectedPolicy,
90 onSelectCancel,
91 authContext,
92}: PolicyEditorPanelProps) {
93 const { ref } = useParams()
94 const queryClient = useQueryClient()
95 const { data: selectedProject } = useSelectedProjectQuery()
96
97 const { can: canUpdatePolicies } = useAsyncCheckPermissions(
98 PermissionAction.TENANT_SQL_ADMIN_WRITE,
99 'tables'
100 )
101
102 // [Joshen] Hyrid form fields, just spit balling to get a decent POC out
103 const [using, setUsing] = useState<DisplayableSqlFragment | undefined>(undefined)
104 const [check, setCheck] = useState<DisplayableSqlFragment | undefined>(undefined)
105 const [rolesFragment, setRolesFragment] = useState<SafeSqlFragment>(safeSql`public`)
106 const [fieldError, setFieldError] = useState<string>()
107 const [showCheckBlock, setShowCheckBlock] = useState(true)
108
109 const monacoOneRef = useRef<Monaco | null>(null)
110 const editorOneRef = useRef<IStandaloneCodeEditor | null>(null)
111 const [expOneLineCount, setExpOneLineCount] = useState(1)
112 const [expOneContentHeight, setExpOneContentHeight] = useState(0)
113
114 const monacoTwoRef = useRef<Monaco | null>(null)
115 const editorTwoRef = useRef<IStandaloneCodeEditor | null>(null)
116 const [expTwoLineCount, setExpTwoLineCount] = useState(1)
117 const [expTwoContentHeight, setExpTwoContentHeight] = useState(0)
118
119 const [error, setError] = useState<QueryResponseError>()
120 const [errorPanelOpen, setErrorPanelOpen] = useState<boolean>(true)
121 const [showDetails, setShowDetails] = useState<boolean>(false)
122 const [selectedDiff, setSelectedDiff] = useState<string>()
123
124 const [showTools, setShowTools] = useState<boolean>(false)
125
126 const form = useForm<z.infer<typeof FormSchema>>({
127 mode: 'onBlur',
128 reValidateMode: 'onBlur',
129 resolver: zodResolver(FormSchema as any),
130 defaultValues,
131 })
132
133 const { name, table, behavior, command, roles } = form.watch()
134 const supportWithCheck = ['update', 'all'].includes(command)
135 const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name
136
137 const { mutate: executeMutation, isPending: isExecuting } = useExecuteSqlMutation({
138 onSuccess: async () => {
139 // refresh all policies
140 await queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(ref) })
141 toast.success('Successfully created new policy')
142 onSelectCancel()
143 },
144 onError: (error) => setError(error),
145 })
146
147 const { mutate: updatePolicy, isPending: isUpdating } = useDatabasePolicyUpdateMutation({
148 onSuccess: () => {
149 toast.success('Successfully updated policy')
150 onSelectCancel()
151 },
152 })
153
154 const hasUnsavedChanges = useCallback(() => {
155 const editorOneValue = editorOneRef.current?.getValue().trim() ?? null
156 const editorOneFormattedValue = !editorOneValue ? null : editorOneValue
157 const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null
158 const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue
159
160 const policyCreateUnsaved =
161 selectedPolicy === undefined &&
162 (name.length > 0 ||
163 roles.length > 0 ||
164 !!editorOneFormattedValue ||
165 !!editorTwoFormattedValue)
166 const policyUpdateUnsaved =
167 selectedPolicy !== undefined
168 ? checkIfPolicyHasChanged(selectedPolicy, {
169 name,
170 roles: roles.length === 0 ? ['public'] : roles.split(', '),
171 definition: editorOneFormattedValue,
172 check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue,
173 })
174 : false
175
176 return policyCreateUnsaved || policyUpdateUnsaved
177 }, [command, name, roles, selectedPolicy])
178
179 const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
180 checkIsDirty: hasUnsavedChanges,
181 onClose: onSelectCancel,
182 })
183
184 const onSubmit = (data: z.infer<typeof FormSchema>) => {
185 const { name, table, behavior, command, roles } = data
186
187 // For INSERT: editor one holds the check expression (not using)
188 // For others: editor one = using, editor two = optional check
189 const usingExpr = command !== 'insert' ? using : undefined
190 const checkExpr = command === 'insert' ? using : check
191
192 if (command === 'insert' && !checkExpr?.trim()) {
193 return setFieldError('Please provide a SQL expression for the WITH CHECK statement')
194 } else if (command !== 'insert' && !usingExpr?.trim()) {
195 return setFieldError('Please provide a SQL expression for the USING statement')
196 } else {
197 setFieldError(undefined)
198 }
199
200 if (selectedPolicy === undefined) {
201 const sql = generateCreatePolicyQuery({
202 name,
203 schema,
204 table,
205 behavior,
206 command,
207 roles: rolesFragment,
208 using: usingExpr ? acceptUntrustedSql(usingExpr) : undefined,
209 check: checkExpr ? acceptUntrustedSql(checkExpr) : undefined,
210 })
211
212 setError(undefined)
213 executeMutation({
214 sql,
215 projectRef: selectedProject?.ref,
216 connectionString: selectedProject?.connectionString,
217 handleError: (error) => {
218 throw error
219 },
220 })
221 } else if (selectedProject !== undefined) {
222 const payload: {
223 name?: string
224 definition?: SafeSqlFragment
225 check?: SafeSqlFragment
226 roles?: Array<string>
227 } = {}
228 const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ')
229 // Trim for string comparison against the stored policy values. The Save click is the
230 // explicit user gesture that promotes editor content to executable SQL.
231 const usingVal = using?.trim()
232 const checkVal = check?.trim()
233
234 if (name !== selectedPolicy.name) payload.name = name
235 if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles
236 if (selectedPolicy.definition !== null && selectedPolicy.definition !== usingVal)
237 payload.definition =
238 usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal))
239
240 if (selectedPolicy.command === 'INSERT') {
241 // [Joshen] Cause editor one will be the check statement in this scenario
242 if (selectedPolicy.check !== usingVal)
243 payload.check =
244 usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal))
245 } else {
246 if (selectedPolicy.check !== checkVal)
247 payload.check =
248 checkVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(checkVal))
249 }
250
251 if (Object.keys(payload).length === 0) return onSelectCancel()
252
253 updatePolicy({
254 projectRef: selectedProject.ref,
255 connectionString: selectedProject?.connectionString,
256 originalPolicy: selectedPolicy,
257 payload,
258 })
259 }
260 }
261
262 const resetState = useStaticEffectEvent(() => {
263 if (!visible) {
264 editorOneRef.current?.setValue('')
265 editorTwoRef.current?.setValue('')
266 setShowTools(false)
267 setError(undefined)
268 setShowDetails(false)
269 setSelectedDiff(undefined)
270
271 setUsing(undefined)
272 setCheck(undefined)
273 setRolesFragment(safeSql`public`)
274 setShowCheckBlock(false)
275 setFieldError(undefined)
276
277 form.reset(defaultValues)
278 } else {
279 if (canUpdatePolicies) setShowTools(true)
280 if (selectedPolicy !== undefined) {
281 const { name, action, table, command, roles } = selectedPolicy
282 form.reset({
283 name,
284 table,
285 behavior: action.toLowerCase(),
286 command: command.toLowerCase(),
287 roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '),
288 })
289 if (selectedPolicy.definition) setUsing(safeSql` ${selectedPolicy.definition}`)
290 if (selectedPolicy.check && selectedPolicy.command === 'INSERT')
291 setUsing(safeSql` ${selectedPolicy.check}`)
292 if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') {
293 setCheck(safeSql` ${selectedPolicy.check}`)
294 setShowCheckBlock(true)
295 }
296 setRolesFragment(
297 roles.length === 1 && roles[0] === 'public'
298 ? safeSql`public`
299 : joinSqlFragments(
300 roles.map((r) => ident(r)),
301 ', '
302 )
303 )
304 } else if (selectedTable !== undefined) {
305 form.reset({ ...defaultValues, table: selectedTable })
306 }
307 }
308 })
309
310 // when the panel is closed, reset all values
311 useEffect(resetState, [visible, resetState])
312
313 // whenever the deps (current policy details, new error or error panel opens) change, recalculate
314 // the height of the editor
315 useEffect(() => {
316 editorOneRef.current?.layout({ width: 0, height: 0 })
317 window.requestAnimationFrame(() => {
318 editorOneRef.current?.layout()
319 })
320 }, [showDetails, error, errorPanelOpen])
321
322 return (
323 <>
324 <Form {...form}>
325 <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
326 <Sheet open={visible} onOpenChange={handleOpenChange}>
327 <SheetContent
328 showClose={false}
329 size={showTools ? 'lg' : 'default'}
330 className={cn(
331 'bg-surface-200 p-0 flex flex-row gap-0',
332 showTools ? 'min-w-screen! lg:min-w-[1000px]!' : 'min-w-screen! lg:min-w-[600px]!'
333 )}
334 >
335 <div className={cn('flex flex-col grow w-full', showTools && 'w-[60%]')}>
336 <PolicyEditorPanelHeader
337 selectedPolicy={selectedPolicy}
338 showTools={showTools}
339 setShowTools={setShowTools}
340 />
341
342 <div className="flex flex-col h-full w-full justify-between overflow-y-auto">
343 <PolicyDetailsV2
344 schema={schema}
345 searchString={searchString}
346 selectedTable={selectedTable}
347 isEditing={selectedPolicy !== undefined}
348 form={form}
349 onUpdateCommand={(command: string) => {
350 setFieldError(undefined)
351 if (!['update', 'all'].includes(command)) {
352 setShowCheckBlock(false)
353 } else {
354 setShowCheckBlock(true)
355 }
356 }}
357 onRolesChange={(frag) => setRolesFragment(frag)}
358 authContext={authContext}
359 />
360 <div className="h-full">
361 <LockedCreateQuerySection
362 schema={schema}
363 selectedPolicy={selectedPolicy}
364 isRenamingPolicy={isRenamingPolicy}
365 formFields={{ name, table, behavior, command, roles }}
366 />
367
368 <div
369 className="mt-1 relative block"
370 style={{
371 height:
372 expOneContentHeight <= 100 ? `${8 + expOneContentHeight}px` : '108px',
373 }}
374 >
375 <RLSCodeEditor
376 disableTabToUsePlaceholder
377 readOnly={!canUpdatePolicies}
378 id="rls-exp-one-editor"
379 placeholder={
380 command === 'insert'
381 ? '-- Provide a SQL expression for the with check statement'
382 : '-- Provide a SQL expression for the using statement'
383 }
384 defaultValue={using}
385 value={using}
386 editorRef={editorOneRef}
387 monacoRef={monacoOneRef as any}
388 lineNumberStart={6}
389 onInputChange={(value) => setUsing(untrustedSql(value ?? ''))}
390 onChange={() => {
391 setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0)
392 setExpOneLineCount(editorOneRef.current?.getModel()?.getLineCount() ?? 1)
393 }}
394 onMount={() => {
395 setTimeout(() => {
396 setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0)
397 setExpOneLineCount(
398 editorOneRef.current?.getModel()?.getLineCount() ?? 1
399 )
400 }, 200)
401 }}
402 />
403 </div>
404
405 <div className="bg-surface-300 py-1">
406 <div className="flex items-center" style={{ fontSize: '14px' }}>
407 <div className="w-[57px]">
408 <p className="w-[31px] flex justify-end font-mono text-sm text-foreground-light select-none">
409 {7 + expOneLineCount}
410 </p>
411 </div>
412 <p className="font-mono tracking-tighter">
413 {showCheckBlock ? (
414 <>
415 {supportWithCheck && showCheckBlock && (
416 <span className="text-[#ffd700]">) </span>
417 )}
418 <span className="text-[#569cd6]">with check</span>{' '}
419 <span className="text-[#ffd700]">(</span>
420 </>
421 ) : (
422 <>
423 <span className="text-[#ffd700]">)</span>;
424 </>
425 )}
426 </p>
427 </div>
428 </div>
429
430 {showCheckBlock && (
431 <>
432 <div
433 className="mt-1 min-h-[28px] relative block"
434 style={{
435 height:
436 expTwoContentHeight <= 100 ? `${8 + expTwoContentHeight}px` : '108px',
437 }}
438 >
439 <RLSCodeEditor
440 disableTabToUsePlaceholder
441 readOnly={!canUpdatePolicies}
442 id="rls-exp-two-editor"
443 placeholder="-- Provide a SQL expression for the with check statement"
444 defaultValue={check}
445 value={check}
446 editorRef={editorTwoRef}
447 monacoRef={monacoTwoRef as any}
448 lineNumberStart={7 + expOneLineCount}
449 onInputChange={(value) => setCheck(untrustedSql(value ?? ''))}
450 onChange={() => {
451 setExpTwoContentHeight(editorTwoRef.current?.getContentHeight() ?? 0)
452 setExpTwoLineCount(
453 editorTwoRef.current?.getModel()?.getLineCount() ?? 1
454 )
455 }}
456 onMount={() => {
457 setTimeout(() => {
458 setExpTwoContentHeight(
459 editorTwoRef.current?.getContentHeight() ?? 0
460 )
461 setExpTwoLineCount(
462 editorTwoRef.current?.getModel()?.getLineCount() ?? 1
463 )
464 }, 200)
465 }}
466 />
467 </div>
468 <div className="bg-surface-300 py-1">
469 <div className="flex items-center" style={{ fontSize: '14px' }}>
470 <div className="w-[57px]">
471 <p className="w-[31px] flex justify-end font-mono text-sm text-foreground-light select-none">
472 {8 + expOneLineCount + expTwoLineCount}
473 </p>
474 </div>
475 <p className="font-mono tracking-tighter">
476 <span className="text-[#ffd700]">)</span>;
477 </p>
478 </div>
479 </div>
480 </>
481 )}
482
483 {isRenamingPolicy && (
484 <LockedRenameQuerySection
485 oldName={selectedPolicy.name}
486 newName={name}
487 schema={schema}
488 table={table}
489 lineNumber={8 + expOneLineCount + (showCheckBlock ? expTwoLineCount : 0)}
490 />
491 )}
492
493 {fieldError !== undefined && (
494 <p className="px-5 py-2 pb-0 text-sm text-red-900">{fieldError}</p>
495 )}
496
497 {supportWithCheck && (
498 <div className="px-5 py-3 flex items-center gap-x-2">
499 <Checkbox
500 id="use-check"
501 name="use-check"
502 checked={showCheckBlock}
503 onCheckedChange={() => {
504 setFieldError(undefined)
505 setShowCheckBlock(!showCheckBlock)
506 }}
507 />
508 <Label className="text-xs cursor-pointer" htmlFor="use-check">
509 Use check expression
510 </Label>
511 </div>
512 )}
513 </div>
514
515 <div className="flex flex-col">
516 {error !== undefined && (
517 <QueryError error={error} open={errorPanelOpen} setOpen={setErrorPanelOpen} />
518 )}
519 <SheetFooter className="flex items-center justify-end! px-5 py-4 w-full border-t">
520 <Button
521 type="default"
522 disabled={isExecuting || isUpdating}
523 onClick={confirmOnClose}
524 >
525 Cancel
526 </Button>
527
528 <ButtonTooltip
529 form={FORM_ID}
530 htmlType="submit"
531 loading={isExecuting || isUpdating}
532 disabled={!canUpdatePolicies || isExecuting || isUpdating}
533 tooltip={{
534 content: {
535 side: 'top',
536 text: !canUpdatePolicies
537 ? 'You need additional permissions to update policies'
538 : undefined,
539 },
540 }}
541 >
542 Save policy
543 </ButtonTooltip>
544 </SheetFooter>
545 </div>
546 </div>
547 </div>
548 {showTools && (
549 <div
550 className={cn(
551 'border-l shadow-[rgba(0,0,0,0.13)_-4px_0px_6px_0px] z-10',
552 showTools && 'w-[50%]',
553 'bg-studio overflow-auto'
554 )}
555 >
556 <Tabs_Shadcn_ defaultValue="templates" className="flex flex-col h-full w-full">
557 <TabsList_Shadcn_ className="flex gap-4 px-content pt-2">
558 <TabsTrigger_Shadcn_
559 key="templates"
560 value="templates"
561 className="px-0 data-[state=active]:bg-transparent"
562 >
563 Templates
564 </TabsTrigger_Shadcn_>
565 </TabsList_Shadcn_>
566
567 <TabsContent_Shadcn_
568 value="templates"
569 className={cn(
570 'mt-0! overflow-y-auto',
571 'data-[state=active]:flex data-[state=active]:grow'
572 )}
573 >
574 <ScrollArea className="h-full w-full">
575 <PolicyTemplates
576 schema={schema}
577 table={table}
578 selectedPolicy={selectedPolicy}
579 selectedTemplate={selectedDiff}
580 onSelectTemplate={(value) => {
581 form.setValue('name', value.name)
582 form.setValue('behavior', 'permissive')
583 form.setValue('command', value.command.toLowerCase())
584 form.setValue('roles', value.roles.join(', ') ?? '')
585
586 setUsing(safeSql` ${value.definition}`)
587 if (value.check) {
588 if (value.command === 'INSERT') {
589 setUsing(safeSql` ${value.check}`)
590 } else {
591 setCheck(safeSql` ${value.check}`)
592 }
593 }
594 setRolesFragment(
595 value.roles.length === 0 ||
596 (value.roles.length === 1 && value.roles[0] === 'public')
597 ? safeSql`public`
598 : joinSqlFragments(
599 value.roles.map((r: string) => ident(r)),
600 ', '
601 )
602 )
603 setExpOneLineCount(1)
604 setExpTwoLineCount(1)
605 setFieldError(undefined)
606
607 if (!['update', 'all'].includes(value.command.toLowerCase())) {
608 setShowCheckBlock(false)
609 } else if (value.check.length > 0) {
610 setShowCheckBlock(true)
611 } else {
612 setShowCheckBlock(false)
613 }
614 }}
615 />
616 </ScrollArea>
617 </TabsContent_Shadcn_>
618 </Tabs_Shadcn_>
619 </div>
620 )}
621 </SheetContent>
622 </Sheet>
623 </form>
624 </Form>
625
626 <DiscardChangesConfirmationDialog
627 {...modalProps}
628 description="Are you sure you want to close the editor? Any unsaved changes on your policy and conversations with the Assistant will be lost."
629 />
630 </>
631 )
632})
633
634PolicyEditorPanel.displayName = 'PolicyEditorPanel'