QueuesSettings.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { QUEUES_SCHEMA } from '@supabase/pg-meta'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { useEffect, useState } from 'react'
  5. import { useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import { Button, Form, FormControl, FormField, FormItem, Switch } from 'ui'
  8. import { Admonition } from 'ui-patterns'
  9. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  10. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  11. import { z } from 'zod'
  12. import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
  13. import { DocsButton } from '@/components/ui/DocsButton'
  14. import { FormHeader } from '@/components/ui/Forms/FormHeader'
  15. import {
  16. FormPanelContainer,
  17. FormPanelContent,
  18. FormPanelFooter,
  19. } from '@/components/ui/Forms/FormPanel'
  20. import { InlineLink } from '@/components/ui/InlineLink'
  21. import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
  22. import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
  23. import { useQueuesExposePostgrestStatusQuery } from '@/data/database-queues/database-queues-expose-postgrest-status-query'
  24. import { useQueuesQuery } from '@/data/database-queues/database-queues-query'
  25. import { useDatabaseQueueToggleExposeMutation } from '@/data/database-queues/database-queues-toggle-postgrest-mutation'
  26. import { useDatabaseQueuesVersionQuery } from '@/data/database-queues/database-queues-version-query'
  27. import { useTableUpdateMutation } from '@/data/tables/table-update-mutation'
  28. import { useTablesQuery } from '@/data/tables/tables-query'
  29. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  30. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  31. import { DOCS_URL, IS_PLATFORM } from '@/lib/constants'
  32. export const QueuesSettings = () => {
  33. const { data: project } = useSelectedProjectQuery()
  34. const { can: canUpdatePostgrestConfig } = useAsyncCheckPermissions(
  35. PermissionAction.UPDATE,
  36. 'custom_config_postgrest'
  37. )
  38. const [isToggling, setIsToggling] = useState(false)
  39. const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
  40. const [isUpdatingRls, setIsUpdatingRls] = useState(false)
  41. const formSchema = z.object({ enable: z.boolean() })
  42. const form = useForm<z.infer<typeof formSchema>>({
  43. resolver: zodResolver(formSchema as any),
  44. mode: 'onChange',
  45. defaultValues: { enable: false },
  46. })
  47. const { formState } = form
  48. const { enable } = form.watch()
  49. const { data: queueTables } = useTablesQuery({
  50. projectRef: project?.ref,
  51. connectionString: project?.connectionString,
  52. schema: 'pgmq',
  53. })
  54. const tablesWithoutRLS =
  55. queueTables?.filter((x) => x.name.startsWith('q_') && !x.rls_enabled) ?? []
  56. // pgmq lowercases queue names when building q_/a_ relations, but pgmq.meta keeps
  57. // the original casing. Look up each relname in list_queues() so we render the
  58. // user-provided name rather than the lowercased relname slice.
  59. const { data: queues } = useQueuesQuery({
  60. projectRef: project?.ref,
  61. connectionString: project?.connectionString,
  62. })
  63. const queueDisplayName = (relname: string) => {
  64. const stripped = relname.slice(2)
  65. return queues?.find((q) => q.queue_name.toLowerCase() === stripped)?.queue_name ?? stripped
  66. }
  67. const { data: config, error: configError } = useProjectPostgrestConfigQuery({
  68. projectRef: project?.ref,
  69. })
  70. const {
  71. data: isExposed,
  72. isSuccess,
  73. isPending: isLoading,
  74. } = useQueuesExposePostgrestStatusQuery({
  75. projectRef: project?.ref,
  76. connectionString: project?.connectionString,
  77. })
  78. const schemas = config?.db_schema.replace(/ /g, '').split(',') ?? []
  79. const { data: pgmqVersion } = useDatabaseQueuesVersionQuery({
  80. projectRef: project?.ref,
  81. connectionString: project?.connectionString,
  82. })
  83. const { mutateAsync: updateTable } = useTableUpdateMutation()
  84. const onPostgrestConfigUpdateSuccess = () => {
  85. if (enable) {
  86. toast.success('Queues can now be managed through client libraries or PostgREST endpoints!')
  87. } else {
  88. toast.success(
  89. 'Queues can no longer be managed through client libraries or PostgREST endpoints'
  90. )
  91. }
  92. setIsToggling(false)
  93. form.reset({ enable })
  94. }
  95. const { mutate: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
  96. onSuccess: onPostgrestConfigUpdateSuccess,
  97. onError: (error) => {
  98. setIsToggling(false)
  99. toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`)
  100. },
  101. })
  102. const { mutate: toggleExposeQueuePostgrest } = useDatabaseQueueToggleExposeMutation({
  103. onSuccess: (_, values) => {
  104. if (!IS_PLATFORM) return onPostgrestConfigUpdateSuccess()
  105. if (project && config) {
  106. if (values.enable) {
  107. const updatedSchemas = schemas.concat([QUEUES_SCHEMA])
  108. updatePostgrestConfig({
  109. projectRef: project?.ref,
  110. dbSchema: updatedSchemas.join(', '),
  111. maxRows: config.max_rows,
  112. dbExtraSearchPath: config.db_extra_search_path,
  113. dbPool: config.db_pool,
  114. })
  115. } else {
  116. const updatedSchemas = schemas.filter((x) => x !== QUEUES_SCHEMA)
  117. updatePostgrestConfig({
  118. projectRef: project?.ref,
  119. dbSchema: updatedSchemas.join(', '),
  120. maxRows: config.max_rows,
  121. dbExtraSearchPath: config.db_extra_search_path,
  122. dbPool: config.db_pool,
  123. })
  124. }
  125. }
  126. },
  127. onError: (error) => {
  128. setIsToggling(false)
  129. toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`)
  130. },
  131. })
  132. const onToggleRLS = async () => {
  133. if (!project) return console.error('Project is required')
  134. setIsUpdatingRls(true)
  135. try {
  136. await Promise.all(
  137. tablesWithoutRLS.map((x) =>
  138. updateTable({
  139. projectRef: project?.ref,
  140. connectionString: project?.connectionString,
  141. id: x.id,
  142. name: x.name,
  143. schema: x.schema,
  144. payload: { id: x.id, rls_enabled: true },
  145. })
  146. )
  147. )
  148. toast.success(
  149. `Successfully enabled RLS on ${tablesWithoutRLS.length === 1 ? tablesWithoutRLS[0].name : `${tablesWithoutRLS.length} queue${tablesWithoutRLS.length > 1 ? 's' : ''}`} `
  150. )
  151. setRlsConfirmModalOpen(false)
  152. } catch (error: any) {
  153. setIsUpdatingRls(false)
  154. toast.error(`Failed to enable RLS on queues: ${error.message}`)
  155. }
  156. }
  157. const onSubmit = async (values: z.infer<typeof formSchema>) => {
  158. if (!project) return console.error('Project is required')
  159. if (configError) {
  160. return toast.error(
  161. `Failed to toggle queue exposure via PostgREST: Unable to retrieve PostgREST configuration (${configError.message})`
  162. )
  163. }
  164. if (!pgmqVersion) {
  165. return toast.error('Unable to retrieve PGMQ version. Please try again later.')
  166. }
  167. setIsToggling(true)
  168. toggleExposeQueuePostgrest({
  169. projectRef: project.ref,
  170. connectionString: project.connectionString,
  171. enable: values.enable,
  172. pgmqVersion,
  173. })
  174. }
  175. useEffect(() => {
  176. if (isSuccess) form.reset({ enable: isExposed })
  177. }, [isSuccess])
  178. return (
  179. <>
  180. <ConstrainedIntegrationTabScaffold className="flex flex-col gap-y-4">
  181. <FormHeader
  182. className="mb-0"
  183. title="Settings"
  184. description="Manage your queues via any client library or Data APIs endpoints"
  185. />
  186. <Form {...form}>
  187. <form id="pgmq-postgrest" onSubmit={form.handleSubmit(onSubmit)}>
  188. <FormPanelContainer>
  189. <FormPanelContent className="px-8 py-8">
  190. <FormField
  191. control={form.control}
  192. name="enable"
  193. render={({ field }) => (
  194. <FormItem className="w-full">
  195. <FormItemLayout
  196. className="w-full"
  197. layout="flex"
  198. label="Expose Queues via PostgREST"
  199. description={
  200. <>
  201. <p className="max-w-2xl">
  202. When enabled, you will be able to use the following functions from the{' '}
  203. <code className="text-code-inline">{QUEUES_SCHEMA}</code> schema to
  204. manage your queues via any Briven client library or PostgREST
  205. endpoints:
  206. </p>
  207. <p className="mt-2">
  208. <code className="text-code-inline">send</code>,{' '}
  209. <code className="text-code-inline">send_batch</code>,{' '}
  210. <code className="text-code-inline">read</code>,{' '}
  211. <code className="text-code-inline">pop</code>,
  212. <code className="text-code-inline">archive</code>, and{' '}
  213. <code className="text-code-inline">delete</code>
  214. </p>
  215. {!IS_PLATFORM ? (
  216. <div className="mt-6 max-w-2xl">
  217. When running Briven locally with the CLI or self-hosting using
  218. Docker Compose, you also need to update your configuration to expose
  219. the <code className="text-code-inline">{QUEUES_SCHEMA}</code>{' '}
  220. schema.
  221. <br />
  222. <InlineLink
  223. href={`${DOCS_URL}/guides/queues/expose-self-hosted-queues`}
  224. >
  225. Learn more
  226. </InlineLink>
  227. </div>
  228. ) : null}
  229. </>
  230. }
  231. >
  232. <FormControl>
  233. <Switch
  234. name="enable"
  235. size="large"
  236. disabled={
  237. isLoading || tablesWithoutRLS.length > 0 || !canUpdatePostgrestConfig
  238. }
  239. checked={field.value}
  240. onCheckedChange={(value) => field.onChange(value)}
  241. />
  242. </FormControl>
  243. </FormItemLayout>
  244. {tablesWithoutRLS.length > 0 && (
  245. <Admonition
  246. type="default"
  247. title="Existing Queues must have RLS enabled first before exposing via PostgREST"
  248. className="mt-2"
  249. >
  250. <p className="m-0!">
  251. Please ensure that the following {tablesWithoutRLS.length} queue
  252. {tablesWithoutRLS.length > 1 ? 's' : ''} have RLS enabled in order to
  253. prevent anonymous access.
  254. </p>
  255. <ul className="list-disc pl-6">
  256. {tablesWithoutRLS.map((x) => {
  257. return (
  258. <li key={x.name}>
  259. <code className="text-code-inline">
  260. {queueDisplayName(x.name)}
  261. </code>
  262. </li>
  263. )
  264. })}
  265. </ul>
  266. <Button
  267. type="default"
  268. className="mt-3"
  269. onClick={() => setRlsConfirmModalOpen(true)}
  270. >
  271. Enable RLS on{' '}
  272. {tablesWithoutRLS.length === 1
  273. ? queueDisplayName(tablesWithoutRLS[0].name)
  274. : `${tablesWithoutRLS.length} queues`}
  275. </Button>
  276. </Admonition>
  277. )}
  278. {formState.dirtyFields.enable && field.value === true && (
  279. <Admonition type="warning" className="mt-2">
  280. <p>
  281. Queues will be exposed and managed through the{' '}
  282. <code className="text-code-inline">{QUEUES_SCHEMA}</code> schema
  283. </p>
  284. <p className="text-foreground-light">
  285. Database functions will be created in the{' '}
  286. <code className="text-code-inline">{QUEUES_SCHEMA}</code> schema upon
  287. enabling. Call these functions via any Briven client library or
  288. PostgREST endpoint to manage your queues. Permissions on individual
  289. queues can also be further managed through privileges and row level
  290. security (RLS).
  291. </p>
  292. </Admonition>
  293. )}
  294. {formState.dirtyFields.enable && field.value === false && (
  295. <Admonition type="warning" className="mt-2">
  296. <p>
  297. The <code className="text-code-inline">{QUEUES_SCHEMA}</code> schema
  298. will be removed once disabled
  299. </p>
  300. <p className="text-foreground-light">
  301. Ensure that the database functions from the{' '}
  302. <code className="text-code-inline">{QUEUES_SCHEMA}</code> schema are not
  303. in use within your client applications before disabling.
  304. </p>
  305. </Admonition>
  306. )}
  307. </FormItem>
  308. )}
  309. />
  310. </FormPanelContent>
  311. <FormPanelFooter className="flex px-8 py-4 flex items-center justify-between">
  312. <DocsButton
  313. href={`${DOCS_URL}/guides/queues/quickstart#expose-queues-to-client-side-consumers`}
  314. />
  315. <div className="flex items-center gap-x-2">
  316. <Button
  317. type="default"
  318. disabled={Object.keys(formState.dirtyFields).length === 0 || isToggling}
  319. onClick={() => form.reset({ enable: false })}
  320. >
  321. Cancel
  322. </Button>
  323. <Button
  324. type="primary"
  325. htmlType="submit"
  326. disabled={Object.keys(formState.dirtyFields).length === 0}
  327. loading={isToggling}
  328. >
  329. Save changes
  330. </Button>
  331. </div>
  332. </FormPanelFooter>
  333. </FormPanelContainer>
  334. </form>
  335. </Form>
  336. </ConstrainedIntegrationTabScaffold>
  337. <ConfirmationModal
  338. visible={rlsConfirmModalOpen}
  339. title="Enable Row Level Security"
  340. confirmLabel="Enable RLS"
  341. confirmLabelLoading="Enabling RLS"
  342. loading={isUpdatingRls}
  343. onCancel={() => setRlsConfirmModalOpen(false)}
  344. onConfirm={() => onToggleRLS()}
  345. >
  346. <p className="text-sm text-foreground-light">
  347. Are you sure you want to enable Row Level Security for the following queues:
  348. </p>
  349. <ul className="list-disc pl-6">
  350. {tablesWithoutRLS.map((x) => {
  351. return (
  352. <li key={x.id}>
  353. <code className="text-code-inline">{queueDisplayName(x.name)}</code>
  354. </li>
  355. )
  356. })}
  357. </ul>
  358. </ConfirmationModal>
  359. </>
  360. )
  361. }