PolicyDetailsV2.tsx369 lines · main
1import {
2 ident,
3 joinSqlFragments,
4 safeSql,
5 type SafeSqlFragment,
6} from '@supabase/pg-meta/src/pg-format'
7import { PermissionAction } from '@supabase/shared-types/out/constants'
8import { Check, ChevronsUpDown } from 'lucide-react'
9import { useEffect, useState } from 'react'
10import { UseFormReturn } from 'react-hook-form'
11import {
12 Button,
13 cn,
14 Command,
15 CommandEmpty,
16 CommandGroup,
17 CommandInput,
18 CommandItem,
19 CommandList,
20 FormControl,
21 FormField,
22 FormItem,
23 FormLabel,
24 FormMessage,
25 Input,
26 Popover,
27 PopoverContent,
28 PopoverTrigger,
29 RadioGroup,
30 RadioGroupLargeItem,
31 ScrollArea,
32 Select,
33 SelectContent,
34 SelectGroup,
35 SelectItem,
36 SelectTrigger,
37} from 'ui'
38import {
39 MultiSelector,
40 MultiSelectorContent,
41 MultiSelectorItem,
42 MultiSelectorList,
43 MultiSelectorTrigger,
44} from 'ui-patterns/multi-select'
45
46import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
47import { useTablesQuery } from '@/data/tables/tables-query'
48import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
49import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
50
51interface PolicyDetailsV2Props {
52 schema: string
53 searchString?: string
54 selectedTable?: string
55 isEditing: boolean
56 form: UseFormReturn<{
57 name: string
58 table: string
59 behavior: string
60 command: string
61 roles: string
62 }>
63 onUpdateCommand: (command: string) => void
64 onRolesChange?: (fragment: SafeSqlFragment) => void
65 authContext: 'database' | 'realtime'
66}
67
68export const PolicyDetailsV2 = ({
69 schema,
70 searchString,
71 selectedTable,
72 isEditing,
73 form,
74 onUpdateCommand,
75 onRolesChange,
76 authContext,
77}: PolicyDetailsV2Props) => {
78 const { data: project } = useSelectedProjectQuery()
79 const [open, setOpen] = useState(false)
80 const { can: canUpdatePolicies } = useAsyncCheckPermissions(
81 PermissionAction.TENANT_SQL_ADMIN_WRITE,
82 'tables'
83 )
84
85 const { data: tables, isSuccess: isSuccessTables } = useTablesQuery({
86 projectRef: project?.ref,
87 connectionString: project?.connectionString,
88 schema: schema,
89 sortByProperty: 'name',
90 includeColumns: true,
91 })
92
93 const { data: dbRoles } = useDatabaseRolesQuery({
94 projectRef: project?.ref,
95 connectionString: project?.connectionString,
96 })
97 const formattedRoles = (dbRoles ?? [])
98 .map((role) => {
99 return {
100 id: role.id,
101 name: role.name,
102 value: role.name,
103 disabled: false,
104 }
105 })
106 .sort((a, b) => a.name.localeCompare(b.name))
107
108 useEffect(() => {
109 if (!isEditing && selectedTable === undefined) {
110 const table = tables?.find(
111 (table) =>
112 table.schema === schema &&
113 (table.id.toString() === searchString || table.name === searchString)
114 )
115 if (table) {
116 form.setValue('table', table.name)
117 } else if (isSuccessTables && tables.length > 0) {
118 form.setValue('table', tables[0].name)
119 }
120 }
121 }, [isEditing, form, searchString, tables, isSuccessTables, selectedTable])
122
123 return (
124 <>
125 <div className="px-5 py-5 flex flex-col gap-y-4 border-b">
126 <div className="items-start justify-between gap-4 grid grid-cols-12">
127 <FormField
128 control={form.control}
129 name="name"
130 render={({ field }) => (
131 <FormItem className="col-span-6 flex flex-col gap-y-1">
132 <FormLabel>Policy Name</FormLabel>
133 <FormControl>
134 <Input
135 {...field}
136 disabled={!canUpdatePolicies}
137 className="bg-control border-control"
138 placeholder="Provide a name for your policy"
139 />
140 </FormControl>
141 <FormMessage />
142 </FormItem>
143 )}
144 />
145
146 <FormField
147 control={form.control}
148 name="table"
149 render={({ field }) => (
150 <FormItem className="col-span-6 flex flex-col gap-y-1">
151 <FormLabel>
152 Table
153 <code className="text-code-inline">on</code> clause
154 </FormLabel>
155 {authContext === 'database' && (
156 <FormControl>
157 <Popover open={open} onOpenChange={setOpen} modal={false}>
158 <PopoverTrigger asChild>
159 <Button
160 type="default"
161 disabled={!canUpdatePolicies}
162 className="w-full [&>span]:w-full h-[38px] text-sm"
163 iconRight={
164 <ChevronsUpDown
165 className="text-foreground-muted"
166 strokeWidth={2}
167 size={14}
168 />
169 }
170 >
171 <div className="w-full flex gap-1">
172 <span className="text-foreground">
173 {schema}.{field.value}
174 </span>
175 </div>
176 </Button>
177 </PopoverTrigger>
178
179 <PopoverContent
180 className="p-0"
181 side="bottom"
182 align="start"
183 sameWidthAsTrigger
184 >
185 <Command>
186 <CommandInput placeholder="Find a table..." />
187 <CommandList onWheel={(event) => event.stopPropagation()}>
188 <CommandEmpty>No tables found</CommandEmpty>
189 <CommandGroup>
190 <ScrollArea className={(tables ?? []).length > 7 ? 'h-[200px]' : ''}>
191 {(tables ?? []).map((table) => (
192 <CommandItem
193 key={table.id}
194 className="cursor-pointer flex items-center justify-between space-x-2 w-full"
195 onSelect={() => {
196 form.setValue('table', table.name)
197 setOpen(false)
198 }}
199 onClick={() => {
200 form.setValue('table', table.name)
201 setOpen(false)
202 }}
203 >
204 <span className="flex items-center gap-1.5">
205 {field.value === table.name ? <Check size={13} /> : ''}
206 {table.name}
207 </span>
208 </CommandItem>
209 ))}
210 </ScrollArea>
211 </CommandGroup>
212 </CommandList>
213 </Command>
214 </PopoverContent>
215 </Popover>
216 </FormControl>
217 )}
218 {authContext === 'realtime' && (
219 <FormControl>
220 <Input
221 disabled
222 value="messages.realtime"
223 className="bg-control border-control"
224 />
225 </FormControl>
226 )}
227
228 <FormMessage />
229 </FormItem>
230 )}
231 />
232
233 <FormField
234 control={form.control}
235 name="behavior"
236 render={({ field }) => (
237 <FormItem className="col-span-6 flex flex-col gap-y-1">
238 <FormLabel>
239 Policy Behavior <code className="text-code-inline">as</code> clause
240 </FormLabel>
241 <FormControl>
242 <Select
243 disabled={isEditing}
244 value={field.value}
245 onValueChange={(value) => form.setValue('behavior', value)}
246 >
247 <SelectTrigger className="text-sm h-10 capitalize">{field.value}</SelectTrigger>
248 <SelectContent>
249 <SelectGroup>
250 <SelectItem value="permissive" className="text-sm">
251 <p>Permissive</p>
252 <p className="text-foreground-light text-xs">
253 Policies are combined using the "OR" Boolean operator
254 </p>
255 </SelectItem>
256 <SelectItem value="restrictive" className="text-sm">
257 <p>Restrictive</p>
258 <p className="text-foreground-light text-xs">
259 Policies are combined using the "AND" Boolean operator
260 </p>
261 </SelectItem>
262 </SelectGroup>
263 </SelectContent>
264 </Select>
265 </FormControl>
266 <FormMessage />
267 </FormItem>
268 )}
269 />
270 <FormField
271 control={form.control}
272 name="command"
273 render={({ field }) => (
274 <FormItem className="col-span-12 flex flex-col gap-y-1">
275 <FormLabel>
276 Policy Command <code className="text-code-inline">for</code> clause
277 </FormLabel>
278 <FormControl>
279 <RadioGroup
280 disabled={isEditing}
281 value={field.value}
282 defaultValue={field.value}
283 onValueChange={(value) => {
284 form.setValue('command', value)
285 onUpdateCommand(value)
286 }}
287 className={cn('flex flex-wrap gap-3', isEditing && 'opacity-50')}
288 >
289 {[
290 'select',
291 'insert',
292 ...(authContext === 'database' ? ['update', 'delete', 'all'] : []),
293 ].map((x) => (
294 <RadioGroupLargeItem
295 key={x}
296 value={x}
297 disabled={isEditing}
298 label={x.toLocaleUpperCase()}
299 className={cn('w-auto', isEditing && 'cursor-not-allowed')}
300 />
301 ))}
302 </RadioGroup>
303 </FormControl>
304 <FormMessage />
305 </FormItem>
306 )}
307 />
308 <FormField
309 control={form.control}
310 name="roles"
311 render={({ field }) => (
312 <FormItem className="col-span-12 flex flex-col gap-y-1">
313 <FormLabel htmlFor="roles">
314 Target Roles <code className="text-code-inline">to</code> clause
315 </FormLabel>
316 <FormControl>
317 <MultiSelector
318 onValuesChange={(roles) => {
319 field.onChange(roles.join(', '))
320 onRolesChange?.(
321 roles.length === 0
322 ? safeSql`public`
323 : joinSqlFragments(
324 roles.map((r) => ident(r)),
325 ', '
326 )
327 )
328 }}
329 disabled={!canUpdatePolicies}
330 values={field.value.length === 0 ? [] : field.value?.split(', ')}
331 size="small"
332 >
333 <MultiSelectorTrigger
334 id="roles"
335 mode="inline-combobox"
336 label={
337 field.value.length === 0
338 ? 'Defaults to all (public) roles if none selected'
339 : 'Search for a role'
340 }
341 badgeLimit="wrap"
342 showIcon={false}
343 deletableBadge
344 className="w-full"
345 />
346 <MultiSelectorContent>
347 <MultiSelectorList>
348 {formattedRoles.map((role) => (
349 <MultiSelectorItem
350 key={role.id}
351 value={role.value}
352 disabled={role.disabled}
353 >
354 {role.name}
355 </MultiSelectorItem>
356 ))}
357 </MultiSelectorList>
358 </MultiSelectorContent>
359 </MultiSelector>
360 </FormControl>
361 <FormMessage />
362 </FormItem>
363 )}
364 />
365 </div>
366 </div>
367 </>
368 )
369}