PolicyDetailsV2.tsx369 lines · main
| 1 | import { |
| 2 | ident, |
| 3 | joinSqlFragments, |
| 4 | safeSql, |
| 5 | type SafeSqlFragment, |
| 6 | } from '@supabase/pg-meta/src/pg-format' |
| 7 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 8 | import { Check, ChevronsUpDown } from 'lucide-react' |
| 9 | import { useEffect, useState } from 'react' |
| 10 | import { UseFormReturn } from 'react-hook-form' |
| 11 | import { |
| 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' |
| 38 | import { |
| 39 | MultiSelector, |
| 40 | MultiSelectorContent, |
| 41 | MultiSelectorItem, |
| 42 | MultiSelectorList, |
| 43 | MultiSelectorTrigger, |
| 44 | } from 'ui-patterns/multi-select' |
| 45 | |
| 46 | import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query' |
| 47 | import { useTablesQuery } from '@/data/tables/tables-query' |
| 48 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 49 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 50 | |
| 51 | interface 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 | |
| 68 | export 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 | } |