PolicyDetailsV2.tsx 13 KB

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