StoragePolicies.tsx342 lines · main
1import { useParams } from 'common'
2import { isEmpty } from 'lodash'
3import { parseAsString, useQueryState } from 'nuqs'
4import { useMemo, useState } from 'react'
5import { toast } from 'sonner'
6import { GenericSkeletonLoader } from 'ui-patterns'
7import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
8import { PageContainer } from 'ui-patterns/PageContainer'
9import {
10 PageSection,
11 PageSectionContent,
12 PageSectionDescription,
13 PageSectionMeta,
14 PageSectionSummary,
15 PageSectionTitle,
16} from 'ui-patterns/PageSection'
17
18import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils'
19import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
20import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
21import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal'
22import type {
23 PostgresPolicyCreatePayload,
24 PostgresPolicyUpdatePayload,
25} from '@/components/interfaces/Auth/Policies/Policies.types'
26import { PolicyEditorModal } from '@/components/interfaces/Auth/Policies/PolicyEditorModal'
27import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
28import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
29import { useDatabasePolicyCreateMutation } from '@/data/database-policies/database-policy-create-mutation'
30import { useDatabasePolicyDeleteMutation } from '@/data/database-policies/database-policy-delete-mutation'
31import { useDatabasePolicyUpdateMutation } from '@/data/database-policies/database-policy-update-mutation'
32import { usePaginatedBucketsQuery } from '@/data/storage/buckets-query'
33import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue'
34import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
35
36export const StoragePolicies = () => {
37 const { ref: projectRef } = useParams()
38 const { data: project } = useSelectedProjectQuery()
39
40 const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<Policy>()
41 const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<Policy>()
42 const [isEditingPolicyForBucket, setIsEditingPolicyForBucket] = useState<{
43 bucket: string
44 table: string
45 }>()
46 const [searchString, setSearchString] = useQueryState(
47 'search',
48 parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
49 )
50 const debouncedSearchString = useDebouncedValue(searchString, 250)
51
52 const {
53 data: bucketsData,
54 isPending: isLoadingBuckets,
55 hasNextPage,
56 isFetchingNextPage,
57 fetchNextPage,
58 } = usePaginatedBucketsQuery({
59 projectRef,
60 search: debouncedSearchString || undefined,
61 })
62 const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData])
63
64 const {
65 data: policies = [],
66 refetch,
67 isPending: isLoadingPolicies,
68 } = useDatabasePoliciesQuery({
69 projectRef: project?.ref,
70 connectionString: project?.connectionString,
71 schema: 'storage',
72 })
73
74 const isLoading = isLoadingBuckets || isLoadingPolicies
75
76 const { mutateAsync: createDatabasePolicy } = useDatabasePolicyCreateMutation({
77 onError: () => {},
78 })
79 const { mutateAsync: updateDatabasePolicy } = useDatabasePolicyUpdateMutation()
80 const { mutate: deleteDatabasePolicy, isPending: isDeletingPolicy } =
81 useDatabasePolicyDeleteMutation({
82 onSuccess: async () => {
83 await refetch()
84 toast.success('Successfully deleted policy!')
85 setSelectedPolicyToDelete(undefined)
86 },
87 onError: (error: any) => {
88 toast.error(`Failed to delete policy: ${error.message}`)
89 },
90 })
91
92 // Only use storage policy editor when creating new policies for buckets
93 const showStoragePolicyEditor =
94 isEmpty(selectedPolicyToEdit) &&
95 !isEmpty(isEditingPolicyForBucket) &&
96 (isEditingPolicyForBucket.bucket ?? '').length > 0
97
98 const showGeneralPolicyEditor = !isEmpty(isEditingPolicyForBucket) && !showStoragePolicyEditor
99
100 // Policies under storage.buckets
101 const storageBucketPolicies = policies.filter(
102 (x) => x.schema === 'storage' && x.table === 'buckets'
103 )
104 // Policies under storage.objects
105 const storageObjectsPolicies = policies.filter(
106 (x) => x.schema === 'storage' && x.table === 'objects'
107 )
108
109 const formattedStorageObjectPolicies = useMemo(
110 () => formatPoliciesForStorage(buckets, storageObjectsPolicies),
111 [buckets, storageObjectsPolicies]
112 )
113
114 const ungroupedPolicies =
115 formattedStorageObjectPolicies.find((x) => x.name === UNGROUPED_POLICY_SYMBOL)?.policies ?? []
116
117 const bucketsWithPolicies = useMemo(() => {
118 // Get policies for filtered buckets (show all policies, don't filter them)
119 // Show all filtered buckets, even if they don't have policies
120 return buckets.map((bucket) => {
121 const bucketPolicies =
122 formattedStorageObjectPolicies.find((x) => x.name === bucket.name)?.policies ?? []
123 return { bucket, policies: bucketPolicies }
124 })
125 }, [buckets, formattedStorageObjectPolicies])
126
127 const onSelectPolicyAdd: SelectBucketPolicyForAction['addPolicy'] = (
128 bucketName = '',
129 table = ''
130 ) => {
131 setSelectedPolicyToEdit(undefined)
132 setIsEditingPolicyForBucket({ bucket: bucketName, table })
133 }
134
135 const onSelectPolicyEdit: SelectBucketPolicyForAction['editPolicy'] = (
136 policy,
137 bucketName = '',
138 table = ''
139 ) => {
140 setIsEditingPolicyForBucket({ bucket: bucketName, table })
141 setSelectedPolicyToEdit(policy)
142 }
143
144 const onCancelPolicyEdit = () => {
145 setIsEditingPolicyForBucket(undefined)
146 }
147
148 const onSelectPolicyDelete: SelectBucketPolicyForAction['deletePolicy'] = (policy: any) =>
149 setSelectedPolicyToDelete(policy)
150 const onCancelPolicyDelete = () => setSelectedPolicyToDelete(undefined)
151
152 const onSavePolicySuccess = async () => {
153 toast.success('Successfully saved policy!')
154 await refetch()
155 onCancelPolicyEdit()
156 }
157
158 /*
159 Functions that involve the CRUD for policies
160 For each API call within the Promise.all, return true if an error occurred, else return false
161 */
162 const onCreatePolicies = async (payloads: PostgresPolicyCreatePayload[]) => {
163 if (!project) {
164 console.error('Project is required')
165 return true
166 }
167
168 try {
169 return await Promise.all(
170 payloads.map(async (payload) => {
171 try {
172 await createDatabasePolicy({
173 projectRef: project?.ref,
174 connectionString: project?.connectionString,
175 payload,
176 })
177 return false
178 } catch (error: any) {
179 toast.error(`Error adding policy: ${error.message}`)
180 return true
181 }
182 })
183 )
184 } finally {
185 }
186 }
187
188 const onCreatePolicy = async (payload: PostgresPolicyCreatePayload) => {
189 if (!project) {
190 console.error('Project is required')
191 return true
192 }
193
194 try {
195 await createDatabasePolicy({
196 projectRef: project?.ref,
197 connectionString: project?.connectionString,
198 payload,
199 })
200 return false
201 } catch (error: any) {
202 toast.error(`Error adding policy: ${error.message}`)
203 return true
204 }
205 }
206
207 const onUpdatePolicy = async (payload: PostgresPolicyUpdatePayload) => {
208 if (!project) {
209 console.error('Project is required')
210 return true
211 }
212 if (!selectedPolicyToEdit) {
213 console.error('Unable to find policy')
214 return true
215 }
216
217 try {
218 await updateDatabasePolicy({
219 projectRef: project?.ref,
220 connectionString: project?.connectionString,
221 originalPolicy: selectedPolicyToEdit,
222 payload,
223 })
224 return false
225 } catch (error: any) {
226 toast.error(`Error updating policy: ${error.message}`)
227 return true
228 }
229 }
230
231 const onDeletePolicy = async () => {
232 if (!project) return console.error('Project is required')
233 if (!selectedPolicyToDelete) return console.error('Unable to find policy')
234
235 deleteDatabasePolicy({
236 projectRef: project?.ref,
237 connectionString: project?.connectionString,
238 originalPolicy: selectedPolicyToDelete,
239 })
240 }
241
242 return (
243 <>
244 <PageContainer>
245 {isLoading ? (
246 <PageSection>
247 <PageSectionContent>
248 <GenericSkeletonLoader />
249 </PageSectionContent>
250 </PageSection>
251 ) : (
252 <div>
253 <BucketsPolicies
254 buckets={bucketsWithPolicies}
255 search={searchString}
256 debouncedSearch={debouncedSearchString}
257 setSearch={setSearchString}
258 actions={{
259 addPolicy: onSelectPolicyAdd,
260 editPolicy: onSelectPolicyEdit,
261 deletePolicy: onSelectPolicyDelete,
262 }}
263 pagination={{
264 hasNextPage,
265 isFetchingNextPage,
266 fetchNextPage,
267 }}
268 />
269
270 <PageSection>
271 <PageSectionMeta>
272 <PageSectionSummary>
273 <PageSectionTitle>Schema</PageSectionTitle>
274 <PageSectionDescription>
275 Write policies for the tables under the storage schema directly for greater
276 control
277 </PageSectionDescription>
278 </PageSectionSummary>
279 </PageSectionMeta>
280 <PageSectionContent>
281 <div className="flex flex-col gap-y-4">
282 {/* Section for policies under storage.objects that are not tied to any buckets */}
283 <StoragePoliciesBucketRow
284 table="objects"
285 label="Other policies under storage.objects"
286 policies={ungroupedPolicies}
287 onSelectPolicyAdd={onSelectPolicyAdd}
288 onSelectPolicyEdit={onSelectPolicyEdit}
289 onSelectPolicyDelete={onSelectPolicyDelete}
290 />
291
292 {/* Section for policies under storage.buckets */}
293 <StoragePoliciesBucketRow
294 table="buckets"
295 label="Policies under storage.buckets"
296 policies={storageBucketPolicies}
297 onSelectPolicyAdd={onSelectPolicyAdd}
298 onSelectPolicyEdit={onSelectPolicyEdit}
299 onSelectPolicyDelete={onSelectPolicyDelete}
300 />
301 </div>
302 </PageSectionContent>
303 </PageSection>
304 </div>
305 )}
306 </PageContainer>
307
308 {/* Only used for adding policies to buckets */}
309 <StoragePoliciesEditPolicyModal
310 visible={showStoragePolicyEditor}
311 bucketName={isEditingPolicyForBucket?.bucket}
312 onSelectCancel={onCancelPolicyEdit}
313 onCreatePolicies={onCreatePolicies}
314 onSaveSuccess={onSavePolicySuccess}
315 />
316
317 {/* Adding policies to objets/buckets table or editting any policy uses the general policy editor */}
318 <PolicyEditorModal
319 schema="storage"
320 visible={showGeneralPolicyEditor}
321 table={isEditingPolicyForBucket?.table ?? ''}
322 selectedPolicyToEdit={selectedPolicyToEdit}
323 onSelectCancel={onCancelPolicyEdit}
324 onCreatePolicy={onCreatePolicy}
325 onUpdatePolicy={onUpdatePolicy}
326 onSaveSuccess={onSavePolicySuccess}
327 />
328
329 <ConfirmationModal
330 visible={!isEmpty(selectedPolicyToDelete)}
331 variant="destructive"
332 title="Delete policy"
333 description={`Are you sure you want to delete the policy “${selectedPolicyToDelete?.name}”? This action cannot be undone.`}
334 confirmLabel="Delete"
335 confirmLabelLoading="Deleting"
336 loading={isDeletingPolicy}
337 onCancel={onCancelPolicyDelete}
338 onConfirm={onDeletePolicy}
339 />
340 </>
341 )
342}