diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx index 0edb730208b8b..2e12c8352068b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx @@ -10,6 +10,7 @@ import CopyButton from 'components/ui/CopyButton' import NoSearchResults from 'components/ui/NoSearchResults' import { getGeneralPolicyTemplates, + getQueuePolicyTemplates, getRealtimePolicyTemplates, } from '../PolicyEditorModal/PolicyEditorModal.constants' @@ -33,7 +34,9 @@ export const PolicyTemplates = ({ const templates = schema === 'realtime' ? getRealtimePolicyTemplates() - : getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') + : schema === 'pgmq' + ? getQueuePolicyTemplates() + : getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name') const baseTemplates = selectedPolicy !== undefined diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts index f941cf82d19aa..2c0c280534c18 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts @@ -326,3 +326,21 @@ with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'ch ] as PolicyTemplate[] return results } + +export const getQueuePolicyTemplates = (): PolicyTemplate[] => { + return [ + { + id: 'policy-queues-1', + preview: false, + templateName: 'Allow access to queue', + statement: ``.trim(), + name: 'Allow anon and authenticated to access messages from queue', + description: + 'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly', + definition: 'true', + check: 'true', + command: 'ALL', + roles: ['anon', 'authenticated'], + }, + ] +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index c8d7abb4295db..f0c174e11e756 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -62,37 +62,43 @@ const PolicyRow = ({ 'w-full last:border-0 space-x-4 border-b py-4 lg:items-center' )} > -
-
-

{policy.command}

-

{policy.name}

+
+
+

+ {policy.command} +

+ +
+

{policy.name}

+
+
+ Applied to: + {policy.roles.slice(0, 3).map((role, i) => ( + + {role} + + ))}{' '} + role +
+ {policy.roles.length > 3 && ( + + + + + {policy.roles.length - 3} more roles + + + + {policy.roles.slice(3).join(', ')} + + + )} +
+
+ {appliesToAnonymousUsers ? ( Applies to anonymous users ) : null}
-
-
- Applied to: - {policy.roles.slice(0, 3).map((role, i) => ( - - {role} - - ))}{' '} - role -
- {policy.roles.length > 3 && ( - - - - + {policy.roles.length - 3} more roles - - - - {policy.roles.slice(3).join(', ')} - - - )} -
{!isLocked && ( diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 752b9a9ae8a8e..b6b7481c3ebad 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -13,7 +13,7 @@ import { useEnumeratedTypesQuery, } from 'data/enumerated-types/enumerated-types-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, DropdownMenu, @@ -53,7 +53,7 @@ const EnumeratedTypes = () => { : enumeratedTypes.filter((x) => x.schema === selectedSchema) const protectedSchemas = (schemas ?? []).filter((schema) => - EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const schema = schemas?.find((schema) => schema.name === selectedSchema) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 8d440869f36b6..54f5317c97c55 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -13,7 +13,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useDatabaseFunctionCreateMutation } from 'data/database-functions/database-functions-create-mutation' import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import type { FormSchema } from 'types' import { Button, @@ -203,7 +203,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { field.onChange(name)} /> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 5b0e7b1de750f..609998fd789dc 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -17,7 +17,7 @@ import { useDatabaseFunctionsQuery } from 'data/database-functions/database-func import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' import { AiIconAnimation, Input } from 'ui' import ProtectedSchemaWarning from '../../ProtectedSchemaWarning' @@ -63,7 +63,7 @@ const FunctionsList = ({ connectionString: project?.connectionString, }) const [protectedSchemas] = partition(schemas ?? [], (schema) => - EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const foundSchema = schemas?.find((schema) => schema.name === selectedSchema) const isLocked = protectedSchemas.some((s) => s.id === foundSchema?.id) diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 2a82a60486047..5d4b4b0333673 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -13,7 +13,7 @@ import { DatabaseIndex, useIndexesQuery } from 'data/database/indexes-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { AlertCircle, Search, Trash } from 'lucide-react' import { Button, Input, SidePanel } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -64,7 +64,7 @@ const Indexes = () => { }) const [protectedSchemas] = partition(schemas ?? [], (schema) => - EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const schema = schemas?.find((schema) => schema.name === selectedSchema) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) diff --git a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx index fae537f223ec3..c933295ddbb1c 100644 --- a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx +++ b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Modal } from 'ui' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { AlertCircle } from 'lucide-react' export const ProtectedSchemaModal = ({ @@ -31,7 +31,7 @@ export const ProtectedSchemaModal = ({ access through the dashboard.

- {EXCLUDED_SCHEMAS.map((schema) => ( + {PROTECTED_SCHEMAS.map((schema) => ( {schema} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index dff11cc7ba06c..fe0f306660f1f 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -10,7 +10,7 @@ import InformationBox from 'components/ui/InformationBox' import { Loading } from 'components/ui/Loading' import { useTablesQuery } from 'data/tables/tables-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, Input } from 'ui' import PublicationsTableItem from './PublicationsTableItem' import { ChevronLeft, Search, AlertCircle } from 'lucide-react' @@ -44,8 +44,8 @@ const PublicationsTables = ({ selectedPublication, onSelectBack }: PublicationsT select(tables) { return tables.filter((table) => filterString.length === 0 - ? !EXCLUDED_SCHEMAS.includes(table.schema) - : !EXCLUDED_SCHEMAS.includes(table.schema) && table.name.includes(filterString) + ? !PROTECTED_SCHEMAS.includes(table.schema) + : !PROTECTED_SCHEMAS.includes(table.schema) && table.name.includes(filterString) ) }, } diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index e23b406b17c73..c9589f0e2fd5e 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -15,7 +15,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, DropdownMenu, @@ -61,7 +61,7 @@ const ColumnList = ({ ? selectedTable?.columns ?? [] : selectedTable?.columns?.filter((column: any) => column.name.includes(filterString))) ?? [] - const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '') + const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '') const canUpdateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') return ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 60da3feaed6aa..53e4fea6048ac 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -36,7 +36,7 @@ import { useTablesQuery } from 'data/tables/tables-query' import { useViewsQuery } from 'data/views/views-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, Checkbox_Shadcn_, @@ -185,7 +185,7 @@ const TableList = ({ (x) => visibleTypes.includes(x.type) ) - const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema) + const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema) const error = tablesError || viewsError || materializedViewsError || foreignTablesError const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables diff --git a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx index eb96c6c18773a..8cf605d89f1b4 100644 --- a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx @@ -19,7 +19,7 @@ import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/databas import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation' import { useTablesQuery } from 'data/tables/tables-query' import { BASE_PATH } from 'lib/constants' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { PauseCircle, PlayCircle, Terminal } from 'lucide-react' import type { Dictionary } from 'types' import ChooseFunctionForm from './ChooseFunctionForm' @@ -148,7 +148,7 @@ class CreateTriggerStore implements ICreateTriggerStore { setTables = (value: any[]) => { this.tables = value .sort((a, b) => a.schema.localeCompare(b.schema)) - .filter((a) => !EXCLUDED_SCHEMAS.includes(a.schema)) as any + .filter((a) => !PROTECTED_SCHEMAS.includes(a.schema)) as any this.setDefaultSelectedTable() } diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index 3d745a7b792bd..8ca577424c5eb 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -17,7 +17,11 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import Table from 'components/to-be-cleaned/Table' import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Check, X, MoreVertical, Edit3, Trash } from 'lucide-react' +import { Check, X, MoreVertical, Edit3, Trash, Edit, Edit2 } from 'lucide-react' +import { useAppStateSnapshot } from 'state/app-state' +import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { cn } from 'ui' +import { generateTriggerCreateSQL } from './TriggerList.utils' interface TriggerListProps { schema: string @@ -35,6 +39,8 @@ const TriggerList = ({ deleteTrigger, }: TriggerListProps) => { const { project } = useProjectContext() + const { setAiAssistantPanel } = useAppStateSnapshot() + const isAssistantV2Enabled = useIsAssistantV2Enabled() const { data: triggers } = useDatabaseTriggersQuery({ projectRef: project?.ref, @@ -135,13 +141,42 @@ const TriggerList = ({
) : (
-
- - } - value={filterString} - className="w-52" - onChange={(e) => setFilterString(e.target.value)} - /> +
+
+ + } + value={filterString} + className="w-52" + onChange={(e) => setFilterString(e.target.value)} + /> +
{!isLocked && ( - - - - - {!canCreateTriggers && ( - - - -
- - You need additional permissions to create triggers - -
-
-
+ className="px-1 pointer-events-auto" + icon={ + + } + onClick={() => + setAiAssistantPanel({ + open: true, + initialInput: `Create a new trigger for the schema ${selectedSchema} that does ...`, + suggestions: { + title: + 'I can help you create a new trigger, here are a few example prompts to get you started:', + prompts: [ + 'Create a trigger that logs changes to the users table', + 'Create a trigger that updates updated_at timestamp', + 'Create a trigger that validates email format before insert', + ], + }, + }) + } + tooltip={{ + content: { + side: 'bottom', + text: !canCreateTriggers + ? 'You need additional permissions to create triggers' + : 'Create with Supabase Assistant', + }, + }} + /> )} -
+
)}
diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index ab1c663830f95..2080541b90b58 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -140,7 +140,7 @@ export function DiskManagementForm() { /** * Handle default values */ - // @ts-ignore [Joshen TODO] check whats happening here + // @ts-ignore const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } const defaultValues = { storageType: type ?? DiskType.GP3, diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index d499559049064..b898d8e009ca8 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -118,7 +118,7 @@ export function DiskManagementPanelForm() { }, } ) - // @ts-ignore [Joshen TODO] check whats happening here + // @ts-ignore const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } const isRequestingChanges = data?.requested_modification !== undefined diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index ff58555f2c329..1bcaf7b433112 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -76,6 +76,10 @@ const supabaseIntegrations: IntegrationDefinition[] = [ ), }, + { + route: 'settings', + label: 'Settings', + }, ], navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { if (childId) { @@ -87,17 +91,20 @@ const supabaseIntegrations: IntegrationDefinition[] = [ case 'overview': return dynamic( () => - import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then( - (mod) => mod.IntegrationOverviewTab + import('components/interfaces/Integrations/Queues/OverviewTab').then( + (mod) => mod.QueuesOverviewTab ), - { - loading: Loading, - } + { loading: Loading } ) case 'queues': return dynamic(() => import('../Queues/QueuesTab').then((mod) => mod.QueuesTab), { loading: Loading, }) + case 'settings': + return dynamic( + () => import('../Queues/QueuesSettings').then((mod) => mod.QueuesSettings), + { loading: Loading } + ) } return null }, diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx index 9cefe7df0520a..69c6e132b2cac 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -3,12 +3,15 @@ import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' +import { Markdown } from 'components/interfaces/Markdown' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation' +import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' import { Badge, Button, + Checkbox_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -22,9 +25,11 @@ import { SheetSection, SheetTitle, } from 'ui' +import { Admonition } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { QUEUE_TYPES } from './Queues.constants' +import { useRouter } from 'next/router' export interface CreateQueueSheetProps { isClosing: boolean @@ -52,6 +57,7 @@ const FormSchema = z.object({ .trim() .min(1, 'Please provide a name for your queue') .max(47, "The name can't be longer than 47 characters"), + enableRls: z.boolean(), values: z.discriminatedUnion('type', [ normalQueueSchema, partitionedQueueSchema, @@ -67,30 +73,33 @@ const FORM_ID = 'create-queue-sidepanel' export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => { // This is for enabling pg_partman extension which will be used for partitioned queues (3rd kind of queue) // const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) - const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation() - // const canToggleExtensions = useCheckPermissions( // PermissionAction.TENANT_SQL_ADMIN_WRITE, // 'extensions' // ) + const router = useRouter() + const { project } = useProjectContext() + + const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation() const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: '', - values: { - type: 'basic', - }, + enableRls: true, + values: { type: 'basic' }, }, }) - const { project } = useProjectContext() const isEdited = form.formState.isDirty // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet - if (!isEdited && isClosing) { - onClose() - } + if (!isEdited && isClosing) onClose() const onClosePanel = () => { if (isEdited) { @@ -100,24 +109,26 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue } } - const onSubmit: SubmitHandler = async ({ name, values }) => { - let query = `SELECT pgmq.create('${name}');` - if (values.type === 'partitioned') { - query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');` - } - if (values.type === 'unlogged') { - query = `SELECT pgmq.create_unlogged('${name}');` - } - + const onSubmit: SubmitHandler = async ({ name, enableRls, values }) => { createQueue( { projectRef: project!.ref, connectionString: project?.connectionString, - query, + name, + enableRls, + type: values.type, + configuration: + values.type === 'partitioned' + ? { + partitionInterval: values.partitionInterval, + retentionInterval: values.retentionInterval, + } + : undefined, }, { onSuccess: () => { toast.success(`Successfully created queue ${name}`) + router.push(`/project/${project?.ref}/integrations/queues/queues/${name}`) onClose() }, } @@ -187,22 +198,20 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue } showIndicator={false} > -
+
{definition.icon}
-
-
-

{definition.label}

+
+
+

{definition.label}

+ {definition.value === 'partitioned' && ( + Coming soon + )}
-

+

{definition.description}

- {definition.value === 'partitioned' ? ( -
- COMING SOON -
- ) : null} {/* {!pgPartmanExtensionInstalled && definition.value === 'partitioned' ? (
@@ -287,8 +296,53 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue )} /> + )} + + ( + +

Enable Row Level Security (RLS)

+ Recommended +
+ } + description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role." + > + + + + + )} + /> + {!isExposed ? ( + + + + ) : ( + + )} +
diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx new file mode 100644 index 0000000000000..bd7598b1a9ea1 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx @@ -0,0 +1,40 @@ +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' +import { Admonition } from 'ui-patterns' +import { Button } from 'ui' +import Link from 'next/link' +import { useParams } from 'common' + +export const QueuesOverviewTab = () => { + const { ref } = useParams() + const project = useSelectedProject() + + const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + return ( + +

+ You may choose to toggle the exposure of Queues through PostgREST via the queues + settings +

+ + + ) : null + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx index 0fa0c08fd91dc..56c19e5344eee 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx @@ -1,5 +1,7 @@ -import { Paintbrush, Trash2 } from 'lucide-react' +import { Lock, Paintbrush, PlusCircle, Trash2 } from 'lucide-react' +import Link from 'next/link' import { useMemo, useState } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue' @@ -7,20 +9,60 @@ import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/Pu import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils' import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid' import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters' +import { QueueSettings } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueSettings' import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal' +import { Markdown } from 'components/interfaces/Markdown' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query' -import { Button, LoadingLine, Separator } from 'ui' +import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' +import { useTableUpdateMutation } from 'data/tables/table-update-mutation' +import { useTablesQuery } from 'data/tables/tables-query' +import { + Button, + cn, + LoadingLine, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Separator, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' export const QueueTab = () => { - const { childId: queueName } = useParams() + const { childId: queueName, ref } = useParams() const { project } = useProjectContext() + + const [openRlsPopover, setOpenRlsPopover] = useState(false) + const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false) const [sendMessageModalShown, setSendMessageModalShown] = useState(false) const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false) const [deleteQueueModalShown, setDeleteQueueModalShown] = useState(false) const [selectedTypes, setSelectedTypes] = useState([]) - const { data, isLoading, isError, fetchNextPage, isFetching } = useQueueMessagesInfiniteQuery( + const { data: tables, isLoading: isLoadingTables } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'pgmq', + }) + const queueTable = tables?.find((x) => x.name === `q_${queueName}`) + const isRlsEnabled = queueTable?.rls_enabled ?? false + + const { data: policies } = useDatabasePoliciesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'pgmq', + }) + const queuePolicies = (policies ?? []).filter((policy) => policy.table === `q_${queueName}`) + + const { data: isExposed } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { data, error, isLoading, fetchNextPage, isFetching } = useQueueMessagesInfiniteQuery( { projectRef: project?.ref, connectionString: project?.connectionString, @@ -32,27 +74,161 @@ export const QueueTab = () => { ) const messages = useMemo(() => data?.pages.flatMap((p) => p), [data?.pages]) - if (isError) { - return null + const { mutate: updateTable, isLoading: isUpdatingTable } = useTableUpdateMutation({ + onSettled: () => { + toast.success(`Successfully enabled RLS for ${queueName}`) + setRlsConfirmModalOpen(false) + }, + }) + + const onToggleRLS = async () => { + if (!project) return console.error('Project is required') + if (!queueTable) return toast.error('Unable to toggle RLS: Queue table not found') + const payload = { + id: queueTable.id, + rls_enabled: true, + } + updateTable({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: payload.id, + schema: 'pgmq', + payload: payload, + }) } return (
-
+ } + > + + Auth {queuePolicies.length > 1 ? 'policies' : 'policy'} + + + )} + + ) : ( + setOpenRlsPopover(!openRlsPopover)} + > + + + + +

+ Row Level Security (RLS) +

+
+ {isExposed ? ( + <> +

+ You can restrict and control who can manage this queue using Row Level + Security. +

+

With RLS enabled, anonymous users will not have access to this queue.

+ + + ) : ( + <> + + + + )} +
+
+
+ )} + @@ -64,6 +240,7 @@ export const QueueTab = () => { setSendMessageModalShown(true)} @@ -83,6 +260,20 @@ export const QueueTab = () => { visible={purgeQueueModalShown} onClose={() => setPurgeQueueModalShown(false)} /> + + setRlsConfirmModalOpen(false)} + onConfirm={() => onToggleRLS()} + > +

+ Are you sure you want to enable Row Level Security for the queue "{queueName}"? +

+
) } diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx index ca79a1a42d56d..852512632b54a 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx @@ -1,12 +1,13 @@ import dayjs from 'dayjs' import { includes, sortBy } from 'lodash' -import { ChevronRight, Loader2 } from 'lucide-react' +import { Check, ChevronRight, Loader2, X } from 'lucide-react' import { useRouter } from 'next/router' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import Table from 'components/to-be-cleaned/Table' import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query' import { PostgresQueue } from 'data/database-queues/database-queues-query' +import { useTablesQuery } from 'data/tables/tables-query' import { DATETIME_FORMAT } from 'lib/constants' interface QueuesRowsProps { @@ -18,6 +19,14 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => { const router = useRouter() const { project: selectedProject } = useProjectContext() + const { data: queueTables } = useTablesQuery({ + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + schema: 'pgmq', + }) + const queueTable = queueTables?.find((x) => x.name === `q_${queue.queue_name}`) + const isRlsEnabled = !!queueTable?.rls_enabled + const { data: metrics, isLoading } = useQueuesMetricsQuery( { queueName: queue.queue_name, @@ -48,6 +57,11 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => { {type}

+ +
+ {isRlsEnabled ? : } +
+

{dayjs(queue.created_at).format(DATETIME_FORMAT)}

diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx new file mode 100644 index 0000000000000..5181053db5933 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -0,0 +1,342 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +import { DocsButton } from 'components/ui/DocsButton' +import { FormHeader } from 'components/ui/Forms/FormHeader' +import { + FormPanelContainer, + FormPanelContent, + FormPanelFooter, +} from 'components/ui/Forms/FormPanel' +import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query' +import { + QUEUES_SCHEMA, + useDatabaseQueueToggleExposeMutation, +} from 'data/database-queues/database-queues-toggle-postgrest-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { + Button, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + Switch, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useTablesQuery } from 'data/tables/tables-query' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { useTableUpdateMutation } from 'data/tables/table-update-mutation' + +// [Joshen] Not convinced with the UI and layout but getting the functionality out first + +export const QueuesSettings = () => { + const project = useSelectedProject() + const canUpdatePostgrestConfig = useCheckPermissions( + PermissionAction.UPDATE, + 'custom_config_postgrest' + ) + const [isToggling, setIsToggling] = useState(false) + const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false) + const [isUpdatingRls, setIsUpdatingRls] = useState(false) + + const formSchema = z.object({ enable: z.boolean() }) + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { enable: false }, + }) + const { formState } = form + const { enable } = form.watch() + + const { data: queueTables } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'pgmq', + }) + const tablesWithoutRLS = + queueTables?.filter((x) => x.name.startsWith('q_') && !x.rls_enabled) ?? [] + + const { data: config, error: configError } = useProjectPostgrestConfigQuery({ + projectRef: project?.ref, + }) + + const { + data: isExposed, + isSuccess, + isLoading, + } = useQueuesExposePostgrestStatusQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const schemas = config?.db_schema.replace(/ /g, '').split(',') ?? [] + + const { mutateAsync: updateTable } = useTableUpdateMutation() + + const { mutate: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({ + onSuccess: () => { + if (enable) { + toast.success('Queues can now be managed through client libraries or PostgREST endpoints!') + } else { + toast.success( + 'Queues can no longer be managed through client libraries or PostgREST endpoints' + ) + } + setIsToggling(false) + form.reset({ enable }) + }, + onError: (error) => { + setIsToggling(false) + toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`) + }, + }) + + const { mutate: toggleExposeQueuePostgrest } = useDatabaseQueueToggleExposeMutation({ + onSuccess: (_, values) => { + if (project && config) { + if (values.enable) { + const updatedSchemas = schemas.concat([QUEUES_SCHEMA]) + updatePostgrestConfig({ + projectRef: project?.ref, + dbSchema: updatedSchemas.join(', '), + maxRows: config.max_rows, + dbExtraSearchPath: config.db_extra_search_path, + dbPool: config.db_pool, + }) + } else { + const updatedSchemas = schemas.filter((x) => x !== QUEUES_SCHEMA) + updatePostgrestConfig({ + projectRef: project?.ref, + dbSchema: updatedSchemas.join(', '), + maxRows: config.max_rows, + dbExtraSearchPath: config.db_extra_search_path, + dbPool: config.db_pool, + }) + } + } + }, + onError: (error) => { + setIsToggling(false) + toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`) + }, + }) + + const onToggleRLS = async () => { + if (!project) return console.error('Project is required') + setIsUpdatingRls(true) + try { + await Promise.all( + tablesWithoutRLS.map((x) => + updateTable({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: x.id, + schema: x.schema, + payload: { id: x.id, rls_enabled: true }, + }) + ) + ) + toast.success( + `Successfully enabled RLS on ${tablesWithoutRLS.length === 1 ? tablesWithoutRLS[0].name : `${tablesWithoutRLS.length} queue${tablesWithoutRLS.length > 1 ? 's' : ''}`} ` + ) + setRlsConfirmModalOpen(false) + } catch (error: any) { + setIsUpdatingRls(false) + toast.error(`Failed to enable RLS on queues: ${error.message}`) + } + } + + const onSubmit = async (values: z.infer) => { + if (!project) return console.error('Project is required') + if (configError) { + return toast.error( + `Failed to toggle queue exposure via PostgREST: Unable to retrieve PostgREST configuration (${configError.message})` + ) + } + + setIsToggling(true) + toggleExposeQueuePostgrest({ + projectRef: project.ref, + connectionString: project.connectionString, + enable: values.enable, + }) + } + + useEffect(() => { + if (isSuccess) form.reset({ enable: isExposed }) + }, [isSuccess]) + + return ( + <> +
+ + +
+ + + ( + + +

+ When enabled, you will be able to use the following functions from the{' '} + {QUEUES_SCHEMA} schema to manage your + queues via any Supabase client library or PostgREST endpoints: +

+

+ queue_send,{' '} + queue_send_batch,{' '} + queue_read,{' '} + queue_pop, + queue_archive, and + queue_delete +

+ + } + > + + 0 || !canUpdatePostgrestConfig + } + checked={field.value} + onCheckedChange={(value) => field.onChange(value)} + /> + +
+ {tablesWithoutRLS.length > 0 && ( + +

+ Please ensure that the following {tablesWithoutRLS.length} queue + {tablesWithoutRLS.length > 1 ? 's' : ''} have RLS enabled in order to + prevent anonymous access. +

+
    + {tablesWithoutRLS.map((x) => { + return ( +
  • + {x.name.slice(2)} +
  • + ) + })} +
+ + +
+ )} + {formState.dirtyFields.enable && field.value === true && ( + +

+ Queues will be exposed and managed through the{' '} + {QUEUES_SCHEMA} schema +

+

+ Database functions will be created in the{' '} + {QUEUES_SCHEMA} schema upon enabling. + Call these functions via any Supabase client library or PostgREST + endpoint to manage your queues. Permissions on individual queues can + also be further managed through privileges and row level security (RLS). +

+
+ )} + {formState.dirtyFields.enable && field.value === false && ( + +

+ The {QUEUES_SCHEMA} schema will be + removed once disabled +

+

+ Ensure that the database functions from the{' '} + {QUEUES_SCHEMA} schema are not in use + within your client applications before disabling. +

+
+ )} +
+ )} + /> +
+ + + +
+ + +
+
+
+
+
+
+ + setRlsConfirmModalOpen(false)} + onConfirm={() => onToggleRLS()} + > +

+ Are you sure you want to enable Row Level Security for the following queues: +

+
    + {tablesWithoutRLS.map((x) => { + return ( +
  • + {x.name.slice(2)} +
  • + ) + })} +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index fa1db6f9bb834..5cb68eff63cef 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -78,6 +78,9 @@ export const QueuesTab = () => { Type + +
RLS enabled
+
Created at @@ -94,7 +97,7 @@ export const QueuesTab = () => {
setIsClosingCreateQueueSheet(true)}> - + { setIsClosingCreateQueueSheet(false) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx index 20c9e6812f872..109dd4835b0ca 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx @@ -18,7 +18,7 @@ const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => { const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({ onSuccess: () => { toast.success(`Successfully removed queue ${queueName}`) - router.push(`/project/${project?.ref}/integrations/queues`) + router.push(`/project/${project?.ref}/integrations/queues/queues`) onClose() }, }) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx index f9012f5c60c43..cb57532557c00 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx @@ -9,8 +9,11 @@ import { PostgresQueueMessage } from 'data/database-queues/database-queue-messag import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel' +import { ResponseError } from 'types' +import AlertError from 'components/ui/AlertError' interface QueueDataGridProps { + error?: ResponseError | null isLoading: boolean messages: PostgresQueueMessage[] showMessageModal: () => void @@ -122,6 +125,7 @@ const columns = messagesCols.map((col) => { }) export const QueueMessagesDataGrid = ({ + error, isLoading, messages, showMessageModal, @@ -182,6 +186,10 @@ export const QueueMessagesDataGrid = ({
+ ) : !!error ? ( +
+ +
) : (
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx new file mode 100644 index 0000000000000..fd475eb5acb85 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -0,0 +1,302 @@ +import { isEqual } from 'lodash' +import { Settings } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' +import { + TablePrivilegesGrant, + useTablePrivilegesGrantMutation, +} from 'data/privileges/table-privileges-grant-mutation' +import { useTablePrivilegesQuery } from 'data/privileges/table-privileges-query' +import { + TablePrivilegesRevoke, + useTablePrivilegesRevokeMutation, +} from 'data/privileges/table-privileges-revoke-mutation' +import { useTablesQuery } from 'data/tables/tables-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { + Button, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + SheetTrigger, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +const ACTIONS = ['select', 'insert', 'update', 'delete'] +type Privileges = { select?: boolean; insert?: boolean; update?: boolean; delete?: boolean } + +interface QueueSettingsProps {} + +export const QueueSettings = ({}: QueueSettingsProps) => { + const { childId: name } = useParams() + const project = useSelectedProject() + + const [open, setOpen] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [privileges, setPrivileges] = useState<{ [key: string]: Privileges }>({}) + + const { data, error, isLoading, isSuccess, isError } = useDatabaseRolesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const roles = (data ?? []).sort((a, b) => a.name.localeCompare(b.name)) + + const { data: queueTables } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'pgmq', + }) + const queueTable = queueTables?.find((x) => x.name === `q_${name}`) + const archiveTable = queueTables?.find((x) => x.name === `a_${name}`) + + const { data: allTablePrivileges, isSuccess: isSuccessPrivileges } = useTablePrivilegesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const queuePrivileges = allTablePrivileges?.find( + (x) => x.schema === 'pgmq' && x.name === `q_${name}` + ) + + const { mutateAsync: grantPrivilege } = useTablePrivilegesGrantMutation() + const { mutateAsync: revokePrivilege } = useTablePrivilegesRevokeMutation() + + const onTogglePrivilege = (role: string, action: string, value: boolean) => { + const updatedPrivileges = { ...privileges, [role]: { ...privileges[role], [action]: value } } + setPrivileges(updatedPrivileges) + } + + const onSaveConfiguration = async () => { + if (!project) return console.error('Project is required') + if (!queueTable) return console.error('Unable to find queue table') + if (!archiveTable) return console.error('Unable to find archive table') + + setIsSaving(true) + const revoke: { role: string; action: string }[] = [] + const grant: { role: string; action: string }[] = [] + + Object.entries(privileges).forEach(([role, p]) => { + const originalRolePrivileges = queuePrivileges?.privileges.filter((x) => x.grantee === role) + Object.entries(p).forEach(([action, value]) => { + const originalValue = !!originalRolePrivileges?.find( + (x) => x.privilege_type.toLowerCase() === action + ) + if (value !== originalValue) { + if (value) grant.push({ role, action }) + else revoke.push({ role, action }) + } + }) + }) + + const rolesBeingGrantedPerms = [...new Set(grant.map((x) => x.role))] + const rolesBeingRevokedPerms = [...new Set(revoke.map((x) => x.role))] + + const rolesNoLongerHavingPerms = rolesBeingRevokedPerms.filter((x) => { + const existingPrivileges = queuePrivileges?.privileges + .filter((y) => x === y.grantee) + .map((y) => y.privilege_type) + const privilegesGettingRevoked = revoke + .filter((y) => y.role === x) + .map((y) => y.action.toUpperCase()) + const privilegesGettingGranted = grant.filter((y) => y.role === x) + return ( + privilegesGettingGranted.length === 0 && + isEqual(existingPrivileges, privilegesGettingRevoked) + ) + }) + + try { + await Promise.all([ + ...(revoke.length > 0 + ? [ + revokePrivilege({ + projectRef: project.ref, + connectionString: project.connectionString, + revokes: revoke.map((x) => ({ + grantee: x.role, + privilege_type: x.action.toUpperCase(), + relation_id: queueTable.id, + })) as TablePrivilegesRevoke[], + }), + ] + : []), + // Revoke select + insert on archive table only if role no longer has ANY perms on the queue table + ...(rolesNoLongerHavingPerms.length > 0 + ? [ + revokePrivilege({ + projectRef: project.ref, + connectionString: project.connectionString, + revokes: [ + ...rolesNoLongerHavingPerms.map((x) => ({ + grantee: x, + privilege_type: 'INSERT' as 'INSERT', + relation_id: archiveTable.id, + })), + ...rolesNoLongerHavingPerms.map((x) => ({ + grantee: x, + privilege_type: 'SELECT' as 'SELECT', + relation_id: archiveTable.id, + })), + ], + }), + ] + : []), + ...(grant.length > 0 + ? [ + grantPrivilege({ + projectRef: project.ref, + connectionString: project.connectionString, + grants: grant.map((x) => ({ + grantee: x.role, + privilege_type: x.action.toUpperCase(), + relation_id: queueTable.id, + })) as TablePrivilegesGrant[], + }), + // Just grant select + insert on archive table as long as we're granting any perms to the queue table for the role + grantPrivilege({ + projectRef: project.ref, + connectionString: project.connectionString, + grants: [ + ...rolesBeingGrantedPerms.map((x) => ({ + grantee: x, + privilege_type: 'INSERT' as 'INSERT', + relation_id: archiveTable.id, + })), + ...rolesBeingGrantedPerms.map((x) => ({ + grantee: x, + privilege_type: 'SELECT' as 'SELECT', + relation_id: archiveTable.id, + })), + ], + }), + ] + : []), + ]) + toast.success('Successfully updated permissions') + setOpen(false) + } catch (error: any) { + toast.error(`Failed to update permissions: ${error.message}`) + } finally { + setIsSaving(false) + } + } + + useEffect(() => { + if (open && isSuccessPrivileges && queuePrivileges) { + const initialState = queuePrivileges.privileges.reduce((a, b) => { + return { + ...a, + [b.grantee]: { ...(a as any)[b.grantee], [b.privilege_type.toLowerCase()]: true }, + } + }, {}) + setPrivileges(initialState) + } + }, [open, isSuccessPrivileges]) + + return ( + + + } + title="Settings" + tooltip={{ content: { side: 'bottom', text: 'Queue settings' } }} + /> + + + + Manage queue permissions on {name} + + Configure permissions for each role to grant access to the relevant actions on the queue + + + + + + + + Role + {ACTIONS.map((x) => ( + + {x} + + ))} + + + + {isLoading && ( + <> + + + + + + + + + + + + + + + + + )} + {isError && ( + + + + + + )} + {isSuccess && + (roles ?? []).map((role) => { + return ( + + {role.name} + {ACTIONS.map((x) => ( + + onTogglePrivilege(role.name, x, value)} + /> + + ))} + + ) + })} + +
+
+ + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index 07a77e633d8fb..0fbf052477c17 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -6,7 +6,7 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' export interface EmptyStateProps {} @@ -14,7 +14,7 @@ export interface EmptyStateProps {} const EmptyState = ({}: EmptyStateProps) => { const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() - const isProtectedSchema = EXCLUDED_SCHEMAS.includes(selectedSchema) + const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema) const canCreateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 3562f5a8f2e8a..eaad8f754cf50 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,11 +1,10 @@ -import type { PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' +import { useParams } from 'common' import { useTrackedState } from 'components/grid/store/Store' import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -25,7 +24,7 @@ import { import { useTableUpdateMutation } from 'data/tables/table-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { Button, PopoverContent_Shadcn_, @@ -60,7 +59,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => { const isMaterializedView = isTableLikeMaterializedView(table) const realtimeEnabled = useIsFeatureEnabled('realtime:all') - const isLocked = EXCLUDED_SCHEMAS.includes(table.schema) + const isLocked = PROTECTED_SCHEMAS.includes(table.schema) const { mutate: updateTable } = useTableUpdateMutation({ onError: (error) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index d4a9cab8f335b..408f3ecce1f5e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -17,7 +17,7 @@ import { useForeignKeyConstraintsQuery, } from 'data/database/foreign-key-constraints-query' import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' -import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' import type { Dictionary } from 'types' import { Button, Checkbox, Input, SidePanel, Toggle } from 'ui' import ActionBar from '../ActionBar' @@ -84,7 +84,7 @@ const ColumnEditor = ({ connectionString: project?.connectionString, }) const enumTypes = (types ?? []).filter( - (type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) + (type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) ) const { data: constraints } = useTableConstraintsQuery({ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index cc792afd0310e..d4058972d45cb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -21,7 +21,7 @@ import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types- import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useUrlState } from 'hooks/ui/useUrlState' -import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Admonition } from 'ui-patterns' import ActionBar from '../ActionBar' @@ -97,7 +97,7 @@ const TableEditor = ({ connectionString: project?.connectionString, }) const enumTypes = (types ?? []).filter( - (type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) + (type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema) ) const { data: publications } = useDatabasePublicationsQuery({ diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index ff4ef5d7127ef..288e602563568 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -21,7 +21,7 @@ import { TableRowsData } from 'data/table-rows/table-rows-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import useLatest from 'hooks/misc/useLatest' import { useUrlState } from 'hooks/ui/useUrlState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' @@ -125,7 +125,7 @@ const TableGridEditor = ({ const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable) const isTableSelected = isTableLike(selectedTable) - const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '') + const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '') const canEditViaTableEditor = isTableSelected && !isLocked const gridTable = parseSupaTable(selectedTable) diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx index 1d53f7c856740..bc9c475d4a95a 100644 --- a/apps/studio/components/layouts/Integrations/tabs.tsx +++ b/apps/studio/components/layouts/Integrations/tabs.tsx @@ -71,7 +71,7 @@ export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => { const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}` return (
- + {tab.label} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx index 1366ec37662ab..b9496ba2ba6f1 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx @@ -30,7 +30,6 @@ const ProjectIntegrationsMenu = () => { const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined - // TODO: Change this to true for local development to work const pgmqExtensionExists = (data ?? []).find((ext) => ext.name === 'pgmq') !== undefined return ( diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 204958a3349af..acfd0705ee736 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -198,7 +198,11 @@ const ProjectLayout = forwardRef diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index 56761c03108ae..7e2a8253966a7 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -35,7 +35,7 @@ import { useSetPage, } from 'ui-patterns/CommandMenu' import { usePrefetchTables, useTablesQuery, type TablesData } from 'data/tables/tables-query' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useEffect, useRef } from 'react' export function useSqlEditorGotoCommands(options?: CommandOptions) { @@ -356,7 +356,7 @@ from ${formatTableIdentifier(table)} } function excludeSupabaseControlledSchemas(tables: TablesData) { - return tables.filter((table) => !EXCLUDED_SCHEMAS.includes(table.schema)) + return tables.filter((table) => !PROTECTED_SCHEMAS.includes(table.schema)) } // Not a perfectly spec-compliant regex , since Postgres also allows non-Latin diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index eef43fb93f9e8..cbbf0cdad3e85 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -16,7 +16,7 @@ import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { AlertDescription_Shadcn_, @@ -93,7 +93,7 @@ const TableEditorMenu = () => { const [protectedSchemas] = partition( (schemas ?? []).sort((a, b) => a.name.localeCompare(b.name)), - (schema) => EXCLUDED_SCHEMAS.includes(schema?.name ?? '') + (schema) => PROTECTED_SCHEMAS.includes(schema?.name ?? '') ) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 74c2d00d3014e..050323869e2b1 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -394,8 +394,9 @@ export const AIAssistant = ({ {suggestions.title &&

{suggestions.title}

}
- {suggestions?.prompts?.map((prompt) => ( + {suggestions?.prompts?.map((prompt, idx) => (