index.tsx257 lines · main
1import { acceptUntrustedSql, PGPolicy } from '@supabase/pg-meta'
2import { isEmpty, noop } from 'lodash'
3import { useCallback, useEffect, useState } from 'react'
4import { toast } from 'sonner'
5import { Modal } from 'ui'
6
7import { POLICY_MODAL_VIEWS } from '../Policies.constants'
8import {
9 DraftPostgresPolicyCreatePayload,
10 DraftPostgresPolicyUpdatePayload,
11 PolicyFormField,
12 PolicyForReview,
13 PostgresPolicyCreatePayload,
14 PostgresPolicyUpdatePayload,
15} from '../Policies.types'
16import {
17 createPayloadForCreatePolicy,
18 createPayloadForUpdatePolicy,
19 createSQLPolicy,
20} from '../Policies.utils'
21import { PolicyEditor } from '../PolicyEditor'
22import { PolicyReview } from '../PolicyReview'
23import PolicySelection from '../PolicySelection'
24import PolicyTemplates from '../PolicyTemplates'
25import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
26import { getGeneralPolicyTemplates } from './PolicyEditorModal.constants'
27import PolicyEditorModalTitle from './PolicyEditorModalTitle'
28import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
29import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
30import useLatest from '@/hooks/misc/useLatest'
31import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
32
33// Call only from a user-gesture handler (the Save click). Promotes the draft payload's
34// `definition`/`check` from `DisplayableSqlFragment` to executable `SafeSqlFragment`.
35const acceptCreatePayload = (
36 draft: DraftPostgresPolicyCreatePayload
37): PostgresPolicyCreatePayload => ({
38 ...draft,
39 definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
40 check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
41})
42
43const acceptUpdatePayload = (
44 draft: DraftPostgresPolicyUpdatePayload
45): PostgresPolicyUpdatePayload => ({
46 ...draft,
47 definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
48 check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
49})
50
51interface PolicyEditorModalProps {
52 visible?: boolean
53 schema?: string
54 table?: string
55 selectedPolicyToEdit?: PGPolicy
56 showAssistantPreview?: boolean
57 onSelectCancel: () => void
58 onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise<boolean>
59 onUpdatePolicy: (payload: PostgresPolicyUpdatePayload) => Promise<boolean>
60 onSaveSuccess: () => void
61}
62
63export const PolicyEditorModal = ({
64 visible = false,
65 schema = '',
66 table = '',
67 selectedPolicyToEdit,
68 showAssistantPreview = false,
69 onSelectCancel = noop,
70 onCreatePolicy,
71 onUpdatePolicy,
72 onSaveSuccess = noop,
73}: PolicyEditorModalProps) => {
74 const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
75
76 const newPolicyTemplate: PolicyFormField = {
77 schema,
78 table,
79 name: '',
80 definition: '',
81 check: '',
82 command: null,
83 roles: [],
84 }
85
86 const isNewPolicy = isEmpty(selectedPolicyToEdit)
87 const initializedPolicyFormFields = isNewPolicy ? newPolicyTemplate : selectedPolicyToEdit
88
89 // Mainly to decide which view to show when back from templates
90 const [previousView, setPreviousView] = useState('')
91 const [view, setView] = useState(POLICY_MODAL_VIEWS.EDITOR)
92
93 const [policyFormFields, setPolicyFormFields] = useState<PolicyFormField>(
94 initializedPolicyFormFields
95 )
96 const [policyStatementForReview, setPolicyStatementForReview] = useState<PolicyForReview>()
97 const [isDirty, setIsDirty] = useState(false)
98
99 const { confirmOnClose, modalProps } = useConfirmOnClose({
100 checkIsDirty: () => isDirty,
101 onClose: () => {
102 onSelectCancel()
103 setIsDirty(false)
104 },
105 })
106
107 const onViewIntro = useCallback(() => setView(POLICY_MODAL_VIEWS.SELECTION), [])
108 const onViewEditor = useCallback(() => setView(POLICY_MODAL_VIEWS.EDITOR), [])
109 const onViewTemplates = () => {
110 setPreviousView(view)
111 setView(POLICY_MODAL_VIEWS.TEMPLATES)
112 }
113 const onReviewPolicy = () => setView(POLICY_MODAL_VIEWS.REVIEW)
114 const onSelectBackFromTemplates = () => setView(previousView)
115
116 const isNewPolicyRef = useLatest(isNewPolicy)
117 const initializedPolicyFormFieldsRef = useLatest(initializedPolicyFormFields)
118 useEffect(() => {
119 if (visible) {
120 if (isNewPolicyRef.current) {
121 onViewIntro()
122 } else {
123 onViewEditor()
124 }
125 setPolicyFormFields(initializedPolicyFormFieldsRef.current)
126 }
127 }, [
128 onViewIntro,
129 onViewEditor,
130 isNewPolicyRef,
131 initializedPolicyFormFieldsRef,
132 // end of stable references
133 visible,
134 ])
135
136 /* Methods that are for the UI */
137
138 const onToggleFeaturePreviewModal = () => {
139 toggleFeaturePreviewModal(true)
140 onSelectCancel()
141 }
142
143 const onUseTemplate = (template: PolicyTemplate) => {
144 setPolicyFormFields({
145 ...policyFormFields,
146 name: template.name,
147 definition: template.definition,
148 check: template.check,
149 command: template.command,
150 roles: template.roles,
151 })
152 onViewEditor()
153 }
154
155 const onUpdatePolicyFormFields = (field: Partial<PolicyFormField>) => {
156 setIsDirty(true)
157 if (field.name && field.name.length > 63) return
158 setPolicyFormFields({ ...policyFormFields, ...field })
159 }
160
161 const validatePolicyFormFields = () => {
162 const { name, definition, check, command } = policyFormFields
163
164 if (name.length === 0) {
165 return toast.error('Please provide a name for your policy')
166 }
167 if (!command) {
168 return toast.error('Please select an operation for your policy')
169 }
170 if (['SELECT', 'DELETE'].includes(command) && !definition) {
171 return toast.error('Please provide a USING expression for your policy')
172 }
173 if (command === 'INSERT' && !check) {
174 return toast.error('Please provide a WITH CHECK expression for your policy')
175 }
176 if (command === 'UPDATE' && !definition && !check) {
177 return toast.error(
178 'Please provide either a USING, or WITH CHECK expression, or both for your policy'
179 )
180 }
181 const policySQLStatement = createSQLPolicy(policyFormFields, selectedPolicyToEdit)
182 setPolicyStatementForReview(policySQLStatement)
183 onReviewPolicy()
184 }
185
186 // The Save click is the explicit user gesture that promotes editor SQL to executable.
187 const onReviewSave = () => {
188 const payload = isNewPolicy
189 ? acceptCreatePayload(createPayloadForCreatePolicy(policyFormFields))
190 : acceptUpdatePayload(createPayloadForUpdatePolicy(policyFormFields, selectedPolicyToEdit))
191 onSavePolicy(payload)
192 setIsDirty(false)
193 }
194
195 const onSavePolicy = async (
196 payload: PostgresPolicyCreatePayload | PostgresPolicyUpdatePayload
197 ) => {
198 // @ts-ignore
199 const hasError = isNewPolicy ? await onCreatePolicy(payload) : await onUpdatePolicy(payload)
200 hasError ? onViewEditor() : onSaveSuccess()
201 }
202
203 return (
204 <Modal
205 hideFooter
206 size={view === POLICY_MODAL_VIEWS.SELECTION ? 'medium' : 'xxlarge'}
207 visible={visible}
208 contentStyle={{ padding: 0 }}
209 header={[
210 <PolicyEditorModalTitle
211 key="0"
212 view={view}
213 isNewPolicy={isNewPolicy}
214 schema={schema}
215 table={table}
216 showAssistantPreview={showAssistantPreview}
217 onSelectBackFromTemplates={onSelectBackFromTemplates}
218 onToggleFeaturePreviewModal={onToggleFeaturePreviewModal}
219 />,
220 ]}
221 onCancel={confirmOnClose}
222 >
223 <div>
224 <DiscardChangesConfirmationDialog {...modalProps} />
225 {view === POLICY_MODAL_VIEWS.SELECTION ? (
226 <PolicySelection
227 description="Write rules with PostgreSQL's policies to fit your unique business needs."
228 onViewTemplates={onViewTemplates}
229 onViewEditor={onViewEditor}
230 showAssistantPreview={showAssistantPreview}
231 onToggleFeaturePreviewModal={onToggleFeaturePreviewModal}
232 />
233 ) : view === POLICY_MODAL_VIEWS.EDITOR ? (
234 <PolicyEditor
235 isNewPolicy={isNewPolicy}
236 policyFormFields={policyFormFields}
237 onUpdatePolicyFormFields={onUpdatePolicyFormFields}
238 onViewTemplates={onViewTemplates}
239 onReviewPolicy={validatePolicyFormFields}
240 />
241 ) : view === POLICY_MODAL_VIEWS.TEMPLATES ? (
242 <PolicyTemplates
243 templates={getGeneralPolicyTemplates(schema, table).filter((policy) => !policy.preview)}
244 templatesNote="* References a specific column in the table"
245 onUseTemplate={onUseTemplate}
246 />
247 ) : view === POLICY_MODAL_VIEWS.REVIEW && !!policyStatementForReview ? (
248 <PolicyReview
249 policy={policyStatementForReview}
250 onSelectBack={onViewEditor}
251 onSelectSave={onReviewSave}
252 />
253 ) : null}
254 </div>
255 </Modal>
256 )
257}