| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { Monaco } from '@monaco-editor/react'
- import {
- acceptUntrustedSql,
- ident,
- joinSqlFragments,
- safeSql,
- untrustedSql,
- type DisplayableSqlFragment,
- type SafeSqlFragment,
- } from '@supabase/pg-meta/src/pg-format'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useQueryClient } from '@tanstack/react-query'
- import { useParams } from 'common'
- import { isEqual } from 'lodash'
- import { memo, useCallback, useEffect, useRef, useState } from 'react'
- import { useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Checkbox,
- cn,
- Form,
- Label,
- ScrollArea,
- Sheet,
- SheetContent,
- SheetFooter,
- Tabs_Shadcn_,
- TabsContent_Shadcn_,
- TabsList_Shadcn_,
- TabsTrigger_Shadcn_,
- } from 'ui'
- import * as z from 'zod'
- import { LockedCreateQuerySection, LockedRenameQuerySection } from './LockedQuerySection'
- import { PolicyDetailsV2 } from './PolicyDetailsV2'
- import { checkIfPolicyHasChanged, generateCreatePolicyQuery } from './PolicyEditorPanel.utils'
- import { PolicyEditorPanelHeader } from './PolicyEditorPanelHeader'
- import { PolicyTemplates } from './PolicyTemplates'
- import { QueryError } from './QueryError'
- import { RLSCodeEditor } from './RLSCodeEditor'
- import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
- import { IStandaloneCodeEditor } from '@/components/interfaces/SQLEditor/SQLEditor.types'
- import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { useDatabasePolicyUpdateMutation } from '@/data/database-policies/database-policy-update-mutation'
- import { databasePoliciesKeys } from '@/data/database-policies/keys'
- import { QueryResponseError, useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
- import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
- interface PolicyEditorPanelProps {
- visible: boolean
- schema: string
- searchString?: string
- selectedTable?: string
- selectedPolicy?: Policy
- onSelectCancel: () => void
- authContext: 'database' | 'realtime'
- }
- const FORM_ID = 'rls-editor'
- const FormSchema = z.object({
- name: z.string().min(1, 'Please provide a name'),
- table: z.string(),
- behavior: z.string(),
- command: z.string(),
- roles: z.string(),
- })
- const defaultValues = {
- name: '',
- table: '',
- behavior: 'permissive',
- command: 'select',
- roles: '',
- }
- /**
- * Using memo for this component because everything rerenders on window focus because of outside fetches
- */
- export const PolicyEditorPanel = memo(function ({
- visible,
- schema,
- searchString,
- selectedTable,
- selectedPolicy,
- onSelectCancel,
- authContext,
- }: PolicyEditorPanelProps) {
- const { ref } = useParams()
- const queryClient = useQueryClient()
- const { data: selectedProject } = useSelectedProjectQuery()
- const { can: canUpdatePolicies } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- 'tables'
- )
- // [Joshen] Hyrid form fields, just spit balling to get a decent POC out
- const [using, setUsing] = useState<DisplayableSqlFragment | undefined>(undefined)
- const [check, setCheck] = useState<DisplayableSqlFragment | undefined>(undefined)
- const [rolesFragment, setRolesFragment] = useState<SafeSqlFragment>(safeSql`public`)
- const [fieldError, setFieldError] = useState<string>()
- const [showCheckBlock, setShowCheckBlock] = useState(true)
- const monacoOneRef = useRef<Monaco | null>(null)
- const editorOneRef = useRef<IStandaloneCodeEditor | null>(null)
- const [expOneLineCount, setExpOneLineCount] = useState(1)
- const [expOneContentHeight, setExpOneContentHeight] = useState(0)
- const monacoTwoRef = useRef<Monaco | null>(null)
- const editorTwoRef = useRef<IStandaloneCodeEditor | null>(null)
- const [expTwoLineCount, setExpTwoLineCount] = useState(1)
- const [expTwoContentHeight, setExpTwoContentHeight] = useState(0)
- const [error, setError] = useState<QueryResponseError>()
- const [errorPanelOpen, setErrorPanelOpen] = useState<boolean>(true)
- const [showDetails, setShowDetails] = useState<boolean>(false)
- const [selectedDiff, setSelectedDiff] = useState<string>()
- const [showTools, setShowTools] = useState<boolean>(false)
- const form = useForm<z.infer<typeof FormSchema>>({
- mode: 'onBlur',
- reValidateMode: 'onBlur',
- resolver: zodResolver(FormSchema as any),
- defaultValues,
- })
- const { name, table, behavior, command, roles } = form.watch()
- const supportWithCheck = ['update', 'all'].includes(command)
- const isRenamingPolicy = selectedPolicy !== undefined && name !== selectedPolicy.name
- const { mutate: executeMutation, isPending: isExecuting } = useExecuteSqlMutation({
- onSuccess: async () => {
- // refresh all policies
- await queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(ref) })
- toast.success('Successfully created new policy')
- onSelectCancel()
- },
- onError: (error) => setError(error),
- })
- const { mutate: updatePolicy, isPending: isUpdating } = useDatabasePolicyUpdateMutation({
- onSuccess: () => {
- toast.success('Successfully updated policy')
- onSelectCancel()
- },
- })
- const hasUnsavedChanges = useCallback(() => {
- const editorOneValue = editorOneRef.current?.getValue().trim() ?? null
- const editorOneFormattedValue = !editorOneValue ? null : editorOneValue
- const editorTwoValue = editorTwoRef.current?.getValue().trim() ?? null
- const editorTwoFormattedValue = !editorTwoValue ? null : editorTwoValue
- const policyCreateUnsaved =
- selectedPolicy === undefined &&
- (name.length > 0 ||
- roles.length > 0 ||
- !!editorOneFormattedValue ||
- !!editorTwoFormattedValue)
- const policyUpdateUnsaved =
- selectedPolicy !== undefined
- ? checkIfPolicyHasChanged(selectedPolicy, {
- name,
- roles: roles.length === 0 ? ['public'] : roles.split(', '),
- definition: editorOneFormattedValue,
- check: command === 'INSERT' ? editorOneFormattedValue : editorTwoFormattedValue,
- })
- : false
- return policyCreateUnsaved || policyUpdateUnsaved
- }, [command, name, roles, selectedPolicy])
- const { confirmOnClose, handleOpenChange, modalProps } = useConfirmOnClose({
- checkIsDirty: hasUnsavedChanges,
- onClose: onSelectCancel,
- })
- const onSubmit = (data: z.infer<typeof FormSchema>) => {
- const { name, table, behavior, command, roles } = data
- // For INSERT: editor one holds the check expression (not using)
- // For others: editor one = using, editor two = optional check
- const usingExpr = command !== 'insert' ? using : undefined
- const checkExpr = command === 'insert' ? using : check
- if (command === 'insert' && !checkExpr?.trim()) {
- return setFieldError('Please provide a SQL expression for the WITH CHECK statement')
- } else if (command !== 'insert' && !usingExpr?.trim()) {
- return setFieldError('Please provide a SQL expression for the USING statement')
- } else {
- setFieldError(undefined)
- }
- if (selectedPolicy === undefined) {
- const sql = generateCreatePolicyQuery({
- name,
- schema,
- table,
- behavior,
- command,
- roles: rolesFragment,
- using: usingExpr ? acceptUntrustedSql(usingExpr) : undefined,
- check: checkExpr ? acceptUntrustedSql(checkExpr) : undefined,
- })
- setError(undefined)
- executeMutation({
- sql,
- projectRef: selectedProject?.ref,
- connectionString: selectedProject?.connectionString,
- handleError: (error) => {
- throw error
- },
- })
- } else if (selectedProject !== undefined) {
- const payload: {
- name?: string
- definition?: SafeSqlFragment
- check?: SafeSqlFragment
- roles?: Array<string>
- } = {}
- const updatedRoles = roles.length === 0 ? ['public'] : roles.split(', ')
- // Trim for string comparison against the stored policy values. The Save click is the
- // explicit user gesture that promotes editor content to executable SQL.
- const usingVal = using?.trim()
- const checkVal = check?.trim()
- if (name !== selectedPolicy.name) payload.name = name
- if (!isEqual(selectedPolicy.roles, updatedRoles)) payload.roles = updatedRoles
- if (selectedPolicy.definition !== null && selectedPolicy.definition !== usingVal)
- payload.definition =
- usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal))
- if (selectedPolicy.command === 'INSERT') {
- // [Joshen] Cause editor one will be the check statement in this scenario
- if (selectedPolicy.check !== usingVal)
- payload.check =
- usingVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(usingVal))
- } else {
- if (selectedPolicy.check !== checkVal)
- payload.check =
- checkVal === undefined ? undefined : acceptUntrustedSql(untrustedSql(checkVal))
- }
- if (Object.keys(payload).length === 0) return onSelectCancel()
- updatePolicy({
- projectRef: selectedProject.ref,
- connectionString: selectedProject?.connectionString,
- originalPolicy: selectedPolicy,
- payload,
- })
- }
- }
- const resetState = useStaticEffectEvent(() => {
- if (!visible) {
- editorOneRef.current?.setValue('')
- editorTwoRef.current?.setValue('')
- setShowTools(false)
- setError(undefined)
- setShowDetails(false)
- setSelectedDiff(undefined)
- setUsing(undefined)
- setCheck(undefined)
- setRolesFragment(safeSql`public`)
- setShowCheckBlock(false)
- setFieldError(undefined)
- form.reset(defaultValues)
- } else {
- if (canUpdatePolicies) setShowTools(true)
- if (selectedPolicy !== undefined) {
- const { name, action, table, command, roles } = selectedPolicy
- form.reset({
- name,
- table,
- behavior: action.toLowerCase(),
- command: command.toLowerCase(),
- roles: roles.length === 1 && roles[0] === 'public' ? '' : roles.join(', '),
- })
- if (selectedPolicy.definition) setUsing(safeSql` ${selectedPolicy.definition}`)
- if (selectedPolicy.check && selectedPolicy.command === 'INSERT')
- setUsing(safeSql` ${selectedPolicy.check}`)
- if (selectedPolicy.check && selectedPolicy.command !== 'INSERT') {
- setCheck(safeSql` ${selectedPolicy.check}`)
- setShowCheckBlock(true)
- }
- setRolesFragment(
- roles.length === 1 && roles[0] === 'public'
- ? safeSql`public`
- : joinSqlFragments(
- roles.map((r) => ident(r)),
- ', '
- )
- )
- } else if (selectedTable !== undefined) {
- form.reset({ ...defaultValues, table: selectedTable })
- }
- }
- })
- // when the panel is closed, reset all values
- useEffect(resetState, [visible, resetState])
- // whenever the deps (current policy details, new error or error panel opens) change, recalculate
- // the height of the editor
- useEffect(() => {
- editorOneRef.current?.layout({ width: 0, height: 0 })
- window.requestAnimationFrame(() => {
- editorOneRef.current?.layout()
- })
- }, [showDetails, error, errorPanelOpen])
- return (
- <>
- <Form {...form}>
- <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
- <Sheet open={visible} onOpenChange={handleOpenChange}>
- <SheetContent
- showClose={false}
- size={showTools ? 'lg' : 'default'}
- className={cn(
- 'bg-surface-200 p-0 flex flex-row gap-0',
- showTools ? 'min-w-screen! lg:min-w-[1000px]!' : 'min-w-screen! lg:min-w-[600px]!'
- )}
- >
- <div className={cn('flex flex-col grow w-full', showTools && 'w-[60%]')}>
- <PolicyEditorPanelHeader
- selectedPolicy={selectedPolicy}
- showTools={showTools}
- setShowTools={setShowTools}
- />
- <div className="flex flex-col h-full w-full justify-between overflow-y-auto">
- <PolicyDetailsV2
- schema={schema}
- searchString={searchString}
- selectedTable={selectedTable}
- isEditing={selectedPolicy !== undefined}
- form={form}
- onUpdateCommand={(command: string) => {
- setFieldError(undefined)
- if (!['update', 'all'].includes(command)) {
- setShowCheckBlock(false)
- } else {
- setShowCheckBlock(true)
- }
- }}
- onRolesChange={(frag) => setRolesFragment(frag)}
- authContext={authContext}
- />
- <div className="h-full">
- <LockedCreateQuerySection
- schema={schema}
- selectedPolicy={selectedPolicy}
- isRenamingPolicy={isRenamingPolicy}
- formFields={{ name, table, behavior, command, roles }}
- />
- <div
- className="mt-1 relative block"
- style={{
- height:
- expOneContentHeight <= 100 ? `${8 + expOneContentHeight}px` : '108px',
- }}
- >
- <RLSCodeEditor
- disableTabToUsePlaceholder
- readOnly={!canUpdatePolicies}
- id="rls-exp-one-editor"
- placeholder={
- command === 'insert'
- ? '-- Provide a SQL expression for the with check statement'
- : '-- Provide a SQL expression for the using statement'
- }
- defaultValue={using}
- value={using}
- editorRef={editorOneRef}
- monacoRef={monacoOneRef as any}
- lineNumberStart={6}
- onInputChange={(value) => setUsing(untrustedSql(value ?? ''))}
- onChange={() => {
- setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0)
- setExpOneLineCount(editorOneRef.current?.getModel()?.getLineCount() ?? 1)
- }}
- onMount={() => {
- setTimeout(() => {
- setExpOneContentHeight(editorOneRef.current?.getContentHeight() ?? 0)
- setExpOneLineCount(
- editorOneRef.current?.getModel()?.getLineCount() ?? 1
- )
- }, 200)
- }}
- />
- </div>
- <div className="bg-surface-300 py-1">
- <div className="flex items-center" style={{ fontSize: '14px' }}>
- <div className="w-[57px]">
- <p className="w-[31px] flex justify-end font-mono text-sm text-foreground-light select-none">
- {7 + expOneLineCount}
- </p>
- </div>
- <p className="font-mono tracking-tighter">
- {showCheckBlock ? (
- <>
- {supportWithCheck && showCheckBlock && (
- <span className="text-[#ffd700]">) </span>
- )}
- <span className="text-[#569cd6]">with check</span>{' '}
- <span className="text-[#ffd700]">(</span>
- </>
- ) : (
- <>
- <span className="text-[#ffd700]">)</span>;
- </>
- )}
- </p>
- </div>
- </div>
- {showCheckBlock && (
- <>
- <div
- className="mt-1 min-h-[28px] relative block"
- style={{
- height:
- expTwoContentHeight <= 100 ? `${8 + expTwoContentHeight}px` : '108px',
- }}
- >
- <RLSCodeEditor
- disableTabToUsePlaceholder
- readOnly={!canUpdatePolicies}
- id="rls-exp-two-editor"
- placeholder="-- Provide a SQL expression for the with check statement"
- defaultValue={check}
- value={check}
- editorRef={editorTwoRef}
- monacoRef={monacoTwoRef as any}
- lineNumberStart={7 + expOneLineCount}
- onInputChange={(value) => setCheck(untrustedSql(value ?? ''))}
- onChange={() => {
- setExpTwoContentHeight(editorTwoRef.current?.getContentHeight() ?? 0)
- setExpTwoLineCount(
- editorTwoRef.current?.getModel()?.getLineCount() ?? 1
- )
- }}
- onMount={() => {
- setTimeout(() => {
- setExpTwoContentHeight(
- editorTwoRef.current?.getContentHeight() ?? 0
- )
- setExpTwoLineCount(
- editorTwoRef.current?.getModel()?.getLineCount() ?? 1
- )
- }, 200)
- }}
- />
- </div>
- <div className="bg-surface-300 py-1">
- <div className="flex items-center" style={{ fontSize: '14px' }}>
- <div className="w-[57px]">
- <p className="w-[31px] flex justify-end font-mono text-sm text-foreground-light select-none">
- {8 + expOneLineCount + expTwoLineCount}
- </p>
- </div>
- <p className="font-mono tracking-tighter">
- <span className="text-[#ffd700]">)</span>;
- </p>
- </div>
- </div>
- </>
- )}
- {isRenamingPolicy && (
- <LockedRenameQuerySection
- oldName={selectedPolicy.name}
- newName={name}
- schema={schema}
- table={table}
- lineNumber={8 + expOneLineCount + (showCheckBlock ? expTwoLineCount : 0)}
- />
- )}
- {fieldError !== undefined && (
- <p className="px-5 py-2 pb-0 text-sm text-red-900">{fieldError}</p>
- )}
- {supportWithCheck && (
- <div className="px-5 py-3 flex items-center gap-x-2">
- <Checkbox
- id="use-check"
- name="use-check"
- checked={showCheckBlock}
- onCheckedChange={() => {
- setFieldError(undefined)
- setShowCheckBlock(!showCheckBlock)
- }}
- />
- <Label className="text-xs cursor-pointer" htmlFor="use-check">
- Use check expression
- </Label>
- </div>
- )}
- </div>
- <div className="flex flex-col">
- {error !== undefined && (
- <QueryError error={error} open={errorPanelOpen} setOpen={setErrorPanelOpen} />
- )}
- <SheetFooter className="flex items-center justify-end! px-5 py-4 w-full border-t">
- <Button
- type="default"
- disabled={isExecuting || isUpdating}
- onClick={confirmOnClose}
- >
- Cancel
- </Button>
- <ButtonTooltip
- form={FORM_ID}
- htmlType="submit"
- loading={isExecuting || isUpdating}
- disabled={!canUpdatePolicies || isExecuting || isUpdating}
- tooltip={{
- content: {
- side: 'top',
- text: !canUpdatePolicies
- ? 'You need additional permissions to update policies'
- : undefined,
- },
- }}
- >
- Save policy
- </ButtonTooltip>
- </SheetFooter>
- </div>
- </div>
- </div>
- {showTools && (
- <div
- className={cn(
- 'border-l shadow-[rgba(0,0,0,0.13)_-4px_0px_6px_0px] z-10',
- showTools && 'w-[50%]',
- 'bg-studio overflow-auto'
- )}
- >
- <Tabs_Shadcn_ defaultValue="templates" className="flex flex-col h-full w-full">
- <TabsList_Shadcn_ className="flex gap-4 px-content pt-2">
- <TabsTrigger_Shadcn_
- key="templates"
- value="templates"
- className="px-0 data-[state=active]:bg-transparent"
- >
- Templates
- </TabsTrigger_Shadcn_>
- </TabsList_Shadcn_>
- <TabsContent_Shadcn_
- value="templates"
- className={cn(
- 'mt-0! overflow-y-auto',
- 'data-[state=active]:flex data-[state=active]:grow'
- )}
- >
- <ScrollArea className="h-full w-full">
- <PolicyTemplates
- schema={schema}
- table={table}
- selectedPolicy={selectedPolicy}
- selectedTemplate={selectedDiff}
- onSelectTemplate={(value) => {
- form.setValue('name', value.name)
- form.setValue('behavior', 'permissive')
- form.setValue('command', value.command.toLowerCase())
- form.setValue('roles', value.roles.join(', ') ?? '')
- setUsing(safeSql` ${value.definition}`)
- if (value.check) {
- if (value.command === 'INSERT') {
- setUsing(safeSql` ${value.check}`)
- } else {
- setCheck(safeSql` ${value.check}`)
- }
- }
- setRolesFragment(
- value.roles.length === 0 ||
- (value.roles.length === 1 && value.roles[0] === 'public')
- ? safeSql`public`
- : joinSqlFragments(
- value.roles.map((r: string) => ident(r)),
- ', '
- )
- )
- setExpOneLineCount(1)
- setExpTwoLineCount(1)
- setFieldError(undefined)
- if (!['update', 'all'].includes(value.command.toLowerCase())) {
- setShowCheckBlock(false)
- } else if (value.check.length > 0) {
- setShowCheckBlock(true)
- } else {
- setShowCheckBlock(false)
- }
- }}
- />
- </ScrollArea>
- </TabsContent_Shadcn_>
- </Tabs_Shadcn_>
- </div>
- )}
- </SheetContent>
- </Sheet>
- </form>
- </Form>
- <DiscardChangesConfirmationDialog
- {...modalProps}
- description="Are you sure you want to close the editor? Any unsaved changes on your policy and conversations with the Assistant will be lost."
- />
- </>
- )
- })
- PolicyEditorPanel.displayName = 'PolicyEditorPanel'
|