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