Files
Filipe CabaçoandIvan Vasilov 29e47821f5 fix(realtime): add pg changes pool to realtime settings (#49256)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — adds a new Realtime setting to configure the Postgres Changes
connection pool size.

## What is the current behavior?

The Realtime settings page only exposes the connection pool used for
Realtime Authorization (`connection_pool`). The pool that Realtime uses
for Postgres Changes is not surfaced anywhere in the dashboard, so
projects that need to tune it have no self-serve way to do so — the only
option is to contact support.

## What is the new behavior?

The Realtime settings page now includes a **Postgres Changes connection
pool size** field:

- Reads `postgres_changes_pool` from the project's Realtime config,
falling back to a default of `2` when no override is stored.
- Validates input from `1` through `20` (`MAX_POSTGRES_CHANGES_POOL`),
and submits the value as a number in the config `PATCH` payload.
- Docs (`apps/docs/content/guides/realtime/settings.mdx`) are expanded
with sizing guidance for both connection pools, plus limits,
resource-usage notes, and the operational error codes to look for.

<img width="1160" height="166" alt="Screenshot 2026-08-19 at 13 59 04"
src="https://github.com/user-attachments/assets/fd3ee29e-e9bf-438b-970f-8008ec57020f"
/>

## Additional context

The named `RealtimeConfigResponse` / `UpdateRealtimeConfigBody` schemas
in the generated `api-types` package do not carry
`postgres_changes_pool` yet, so both the query and mutation types extend
the generated schema locally — the same pattern already used elsewhere
in `apps/studio/data/`. Once the platform OpenAPI spec ships the field
and `api-types` is regenerated, those two local intersections can be
dropped.

Covered by component tests in `RealtimeSettings.test.tsx` for both the
fetch and save paths.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a Realtime setting to configure the Postgres Changes connection
pool size.
  * Connection pools support 1–20 connections, with a default of 2.
  * Saving the setting now applies the configured value correctly.

* **Documentation**
* Expanded Realtime Settings guidance with configuration limits,
resource usage, channel access, payload and presence limits, plan
ceilings, spend-cap restrictions, and operational error codes.
* Added guidance for sizing authorization and Postgres Changes
connection pools.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
2026-08-21 09:08:28 +01:00

762 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Link from 'next/link'
import { useState } from 'react'
import { SubmitHandler, useForm, useWatch } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
FormInputGroupInput,
FormMessage,
InputGroup,
InputGroupAddon,
InputGroupText,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import { AlertError } from '@/components/ui/AlertError'
import { ToggleSpendCapButton } from '@/components/ui/ToggleSpendCapButton'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
import { useRealtimeConfigurationUpdateMutation } from '@/data/realtime/realtime-config-mutation'
import {
REALTIME_DEFAULT_CONFIG,
useRealtimeConfigurationQuery,
} from '@/data/realtime/realtime-config-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const formId = 'realtime-configuration-form'
const REALTIME_SOFT_LIMITS = {
max_concurrent_users: 50_000,
max_events_per_second: 50_000,
max_presence_events_per_second: 5_000,
max_payload_size_in_kb: 3_000,
}
const MAX_POSTGRES_CHANGES_POOL = 20
export const RealtimeSettings = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization, isSuccess: isSuccessOrganization } = useSelectedOrganizationQuery()
const { can: canUpdateConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.REALTIME_ADMIN_READ,
'*'
)
const [isConfirmNextModalOpen, setIsConfirmNextModalOpen] = useState(false)
const { data: maxConn } = useMaxConnectionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data, error, isError, isPending } = useRealtimeConfigurationQuery({
projectRef,
})
const { data: policies, isSuccess: isSuccessPolicies } = useDatabasePoliciesQuery({
projectRef,
connectionString: project?.connectionString,
schemas: ['realtime'],
})
// Per-plan realtime ceilings come from the org's entitlements (plan + any overrides).
// The effective limit is the lower of the entitlement and the soft cap, so an unlimited
// plan is still bounded and self-hosted / loading falls back to the soft cap.
const { getEntitlementMax: getMaxConcurrentUsers, isSuccess: isSuccessMaxConcurrentUsers } =
useCheckEntitlements('realtime.max_concurrent_users')
const { getEntitlementMax: getMaxEventsPerSecond, isSuccess: isSuccessMaxEventsPerSecond } =
useCheckEntitlements('realtime.max_events_per_second')
const {
getEntitlementMax: getMaxPresenceEventsPerSecond,
isSuccess: isSuccessMaxPresenceEventsPerSecond,
} = useCheckEntitlements('realtime.max_presence_events_per_second')
const { getEntitlementMax: getMaxPayloadSizeInKb, isSuccess: isSuccessMaxPayloadSizeInKb } =
useCheckEntitlements('realtime.max_payload_size_in_kb')
const isRealtimeEntitlementsLoaded =
isSuccessMaxConcurrentUsers &&
isSuccessMaxEventsPerSecond &&
isSuccessMaxPresenceEventsPerSecond &&
isSuccessMaxPayloadSizeInKb
const isLoading = isPending || !isRealtimeEntitlementsLoaded
const maxConcurrentUsersLimit = Math.min(
getMaxConcurrentUsers() ?? Infinity,
REALTIME_SOFT_LIMITS.max_concurrent_users
)
const maxEventsPerSecondLimit = Math.min(
getMaxEventsPerSecond() ?? Infinity,
REALTIME_SOFT_LIMITS.max_events_per_second
)
const maxPresenceEventsPerSecondLimit = Math.min(
getMaxPresenceEventsPerSecond() ?? Infinity,
REALTIME_SOFT_LIMITS.max_presence_events_per_second
)
const maxPayloadSizeInKbLimit = Math.min(
getMaxPayloadSizeInKb() ?? Infinity,
REALTIME_SOFT_LIMITS.max_payload_size_in_kb
)
const isFreePlan = organization?.plan.id === 'free'
const isUsageBillingEnabled = organization?.usage_billing_enabled
const isRealtimeDisabled = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend
// Check if RLS policies exist for realtime.messages table
const realtimeMessagesPolicies = policies?.filter(
(policy) => policy.schema === 'realtime' && policy.table === 'messages'
)
const hasRealtimeMessagesPolicies =
realtimeMessagesPolicies && realtimeMessagesPolicies.length > 0
const { mutate: updateRealtimeConfig, isPending: isUpdatingConfig } =
useRealtimeConfigurationUpdateMutation({
onSuccess: () => {
form.reset(form.getValues())
toast.success('Successfully updated realtime settings')
setIsConfirmNextModalOpen(false)
},
})
const FormSchema = z.discriminatedUnion('suspend', [
z.object({
suspend: z.literal(true),
connection_pool: z.coerce
.number()
.min(1)
.max(maxConn?.maxConnections ?? 100)
.optional(),
postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL).optional(),
max_concurrent_users: z.coerce
.number()
.min(1)
.max(
maxConcurrentUsersLimit,
`Cannot exceed ${maxConcurrentUsersLimit.toLocaleString()} concurrent clients`
)
.optional(),
max_events_per_second: z.coerce
.number()
.min(1)
.max(
maxEventsPerSecondLimit,
`Cannot exceed ${maxEventsPerSecondLimit.toLocaleString()} events per second`
)
.optional(),
max_presence_events_per_second: z.coerce
.number()
.min(1)
.max(
maxPresenceEventsPerSecondLimit,
`Cannot exceed ${maxPresenceEventsPerSecondLimit.toLocaleString()} presence events per second`
)
.optional(),
max_payload_size_in_kb: z.coerce
.number()
.min(1)
.max(
maxPayloadSizeInKbLimit,
`Cannot exceed ${maxPayloadSizeInKbLimit.toLocaleString()} KB`
)
.optional(),
// [Joshen] These fields are temporarily hidden from the UI
// max_bytes_per_second: z.coerce.number().min(1).max(10000000).optional(),
// max_channels_per_client: z.coerce.number().min(1).max(10000).optional(),
// max_joins_per_second: z.coerce.number().min(1).max(5000).optional(),
allow_public: z.boolean().optional(),
}),
z.object({
suspend: z.literal(false),
connection_pool: z.coerce
.number()
.min(1)
.max(maxConn?.maxConnections ?? 100),
postgres_changes_pool: z.coerce.number().min(1).max(MAX_POSTGRES_CHANGES_POOL),
max_concurrent_users: z.coerce
.number()
.min(1)
.max(
maxConcurrentUsersLimit,
`Cannot exceed ${maxConcurrentUsersLimit.toLocaleString()} concurrent clients`
),
max_events_per_second: z.coerce
.number()
.min(1)
.max(
maxEventsPerSecondLimit,
`Cannot exceed ${maxEventsPerSecondLimit.toLocaleString()} events per second`
),
max_presence_events_per_second: z.coerce
.number()
.min(1)
.max(
maxPresenceEventsPerSecondLimit,
`Cannot exceed ${maxPresenceEventsPerSecondLimit.toLocaleString()} presence events per second`
),
max_payload_size_in_kb: z.coerce
.number()
.min(1)
.max(
maxPayloadSizeInKbLimit,
`Cannot exceed ${maxPayloadSizeInKbLimit.toLocaleString()} KB`
),
// [Joshen] These fields are temporarily hidden from the UI
// max_bytes_per_second: z.coerce.number().min(1).max(10000000),
// max_channels_per_client: z.coerce.number().min(1).max(10000),
// max_joins_per_second: z.coerce.number().min(1).max(5000),
allow_public: z.boolean(),
}),
])
const configValues = data ?? REALTIME_DEFAULT_CONFIG
const sharedFormValues = {
connection_pool: configValues.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool,
postgres_changes_pool:
configValues.postgres_changes_pool ?? REALTIME_DEFAULT_CONFIG.postgres_changes_pool,
max_concurrent_users:
configValues.max_concurrent_users ?? REALTIME_DEFAULT_CONFIG.max_concurrent_users,
max_events_per_second:
configValues.max_events_per_second ?? REALTIME_DEFAULT_CONFIG.max_events_per_second,
max_presence_events_per_second:
configValues.max_presence_events_per_second ??
REALTIME_DEFAULT_CONFIG.max_presence_events_per_second,
max_payload_size_in_kb:
configValues.max_payload_size_in_kb ?? REALTIME_DEFAULT_CONFIG.max_payload_size_in_kb,
allow_public: !(configValues.private_only ?? REALTIME_DEFAULT_CONFIG.private_only),
}
const formValues: z.infer<typeof FormSchema> = {
suspend: configValues.suspend ?? REALTIME_DEFAULT_CONFIG.suspend,
...sharedFormValues,
}
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
...REALTIME_DEFAULT_CONFIG,
allow_public: !REALTIME_DEFAULT_CONFIG.private_only,
},
values: formValues,
})
const [allow_public, suspend] = useWatch({
control: form.control,
name: ['allow_public', 'suspend'],
})
const isSettingToPrivate = !data?.private_only && !allow_public
const isDisablingRealtime = !isRealtimeDisabled && suspend
const isEnablingRealtime = isRealtimeDisabled && !suspend
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (_data) => {
if (!projectRef) return console.error('Project ref is required')
if (!isRealtimeEntitlementsLoaded) return
setIsConfirmNextModalOpen(true)
}
const onConfirmSave = () => {
if (!projectRef) return console.error('Project ref is required')
if (!isRealtimeEntitlementsLoaded) return
const values = form.getValues()
// [Joshen] Casting to `Number` here as the values are being set as string when edited in the form
// and returned in form.getValues() - I might be missing some easy util function from RHF though
updateRealtimeConfig({
ref: projectRef,
private_only: !values.allow_public,
connection_pool: Number(
values.connection_pool ?? data?.connection_pool ?? REALTIME_DEFAULT_CONFIG.connection_pool
),
postgres_changes_pool: Number(
values.postgres_changes_pool ??
data?.postgres_changes_pool ??
REALTIME_DEFAULT_CONFIG.postgres_changes_pool
),
max_concurrent_users: Number(
values.max_concurrent_users ??
data?.max_concurrent_users ??
REALTIME_DEFAULT_CONFIG.max_concurrent_users
),
max_events_per_second: Number(
values.max_events_per_second ??
data?.max_events_per_second ??
REALTIME_DEFAULT_CONFIG.max_events_per_second
),
max_presence_events_per_second: Number(
values.max_presence_events_per_second ??
data?.max_presence_events_per_second ??
REALTIME_DEFAULT_CONFIG.max_presence_events_per_second
),
max_payload_size_in_kb: Number(
values.max_payload_size_in_kb ??
data?.max_payload_size_in_kb ??
REALTIME_DEFAULT_CONFIG.max_payload_size_in_kb
),
suspend: values.suspend,
})
}
if (isLoading) {
return (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
)
}
return (
<>
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
{isError ? (
<AlertError error={error} subject="Failed to retrieve realtime settings" />
) : (
<Card>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="suspend"
render={({ field }) => (
<>
<FormItemLayout
id="suspend"
layout="flex-row-reverse"
label="Enable Realtime service"
description="If disabled, no clients will be able to connect and new connections will be rejected"
>
<FormControl>
<Switch
id="suspend"
aria-label="Toggle enabling of realtime service"
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
<FormMessage />
</>
)}
/>
{(isRealtimeDisabled || isDisablingRealtime || isEnablingRealtime) && (
<Admonition
showIcon={false}
type={isDisablingRealtime || isEnablingRealtime ? 'warning' : 'default'}
title={
isDisablingRealtime
? 'Realtime service will be disabled'
: isEnablingRealtime
? 'Realtime service will be re-enabled'
: isRealtimeDisabled
? 'Realtime service is disabled'
: ''
}
description={
isDisablingRealtime
? 'Clients will no longer be able to connect to your project’s realtime service once saved'
: isEnablingRealtime
? "Clients will be able to connect to your project's realtime service again once saved"
: isRealtimeDisabled
? 'You will need to enable it to continue using Realtime'
: null
}
/>
)}
</CardContent>
{!suspend && (
<>
<CardContent>
<FormField
control={form.control}
name="allow_public"
render={({ field }) => (
<>
<FormItemLayout
id="allow_public"
layout="flex-row-reverse"
label="Allow public access to channels"
description="If disabled, only private channels will be allowed"
>
<FormControl>
<Switch
id="allow_public"
aria-label="Toggle allow public access"
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl>
</FormItemLayout>
{isSuccessPolicies &&
!hasRealtimeMessagesPolicies &&
!allow_public &&
!isRealtimeDisabled && (
<Admonition
showIcon={false}
className="mt-2"
type="warning"
title="No Realtime RLS policies found"
description={
<p className="prose max-w-full text-sm">
Private mode is {isSettingToPrivate ? 'being ' : ''}
enabled, but no RLS policies exist on the{' '}
<code className="text-code-inline">realtime.messages</code>{' '}
table. No messages will be received by users.
</p>
}
actions={
<Button asChild variant="default">
<Link href={`/project/${projectRef}/realtime/policies`}>
Create policy
</Link>
</Button>
}
/>
)}
</>
)}
/>
</CardContent>
<CardContent>
<FormField
control={form.control}
name="connection_pool"
render={({ field }) => (
<>
<FormItemLayout
id="connection_pool"
layout="flex-row-reverse"
label="Database connection pool size"
description="Realtime Authorization uses this database pool to check client access"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="connection_pool"
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>connections</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
{!!maxConn &&
field.value &&
field.value > maxConn.maxConnections * 0.5 && (
<Admonition
showIcon={false}
type="warning"
title={`Pool size is greater than 50% of the max connections (${maxConn.maxConnections}) on your database`}
description="This may result in instability and unreliability with your database connections."
/>
)}
</>
)}
/>
</CardContent>
<CardContent>
<FormField
control={form.control}
name="postgres_changes_pool"
render={({ field }) => (
<FormItemLayout
id="postgres_changes_pool"
layout="flex-row-reverse"
label="Postgres Changes connection pool size"
description="Postgres Changes uses this database pool to create subscriptions when clients subscribe"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="postgres_changes_pool"
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>connections</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField
control={form.control}
name="max_concurrent_users"
render={({ field }) => (
<FormItemLayout
id="max_concurrent_users"
layout="flex-row-reverse"
label="Max concurrent clients"
description="Sets maximum number of concurrent clients that can connect to your Realtime service"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="max_concurrent_users"
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>clients</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent className="space-y-2">
<FormField
control={form.control}
name="max_events_per_second"
render={({ field }) => (
<FormItemLayout
id="max_events_per_second"
layout="flex-row-reverse"
label="Max events per second"
description="Sets maximum number of events per second that can be sent to your Realtime service"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="max_events_per_second"
type="number"
disabled={!isUsageBillingEnabled || !canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>events/s</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
{isSuccessOrganization && !isUsageBillingEnabled && (
<Admonition showIcon={false} type="default">
<div className="flex items-center gap-x-2">
<div>
<h5 className="text-foreground mb-1">
Spend cap needs to be disabled to configure this value
</h5>
<p className="text-foreground-light">
{isFreePlan
? 'Upgrade to the Pro plan first to disable spend cap'
: 'You may adjust this setting in the organization billing settings'}
</p>
</div>
<div className="grow flex items-center justify-end">
{isFreePlan ? (
<UpgradePlanButton
source="realtimeSettings"
addon="spendCap"
featureProposition="configure the max events per second parameter of realtime settings"
/>
) : (
<ToggleSpendCapButton />
)}
</div>
</div>
</Admonition>
)}
</CardContent>
<CardContent className="space-y-2">
<FormField
control={form.control}
name="max_presence_events_per_second"
render={({ field }) => (
<FormItemLayout
id="max_presence_events_per_second"
layout="flex-row-reverse"
label="Max presence events per second"
description="Sets maximum number of presence events per second that can be sent to your Realtime service"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="max_presence_events_per_second"
type="number"
disabled={!isUsageBillingEnabled || !canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>events/s</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
{isSuccessOrganization && !isUsageBillingEnabled && (
<Admonition showIcon={false} type="default">
<div className="flex items-center gap-x-2">
<div>
<h5 className="text-foreground mb-1">
Spend cap needs to be disabled to configure this value
</h5>
<p className="text-foreground-light">
{isFreePlan
? 'Upgrade to the Pro plan first to disable spend cap'
: 'You may adjust this setting in the organization billing settings'}
</p>
</div>
<div className="grow flex items-center justify-end">
{isFreePlan ? (
<UpgradePlanButton
source="realtimeSettings"
addon="spendCap"
featureProposition="configure the max presence events per second parameter of realtime settings"
/>
) : (
<ToggleSpendCapButton />
)}
</div>
</div>
</Admonition>
)}
</CardContent>
<CardContent className="space-y-2">
<FormField
control={form.control}
name="max_payload_size_in_kb"
render={({ field }) => (
<FormItemLayout
id="max_payload_size_in_kb"
layout="flex-row-reverse"
label="Max payload size in KB"
description="Sets maximum number of payload size in KB that can be sent to your Realtime service"
>
<FormControl>
<InputGroup>
<FormInputGroupInput
{...field}
id="max_payload_size_in_kb"
type="number"
disabled={!isUsageBillingEnabled || !canUpdateConfig}
value={field.value || ''}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>KB</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
)}
/>
{isSuccessOrganization && !isUsageBillingEnabled && (
<Admonition showIcon={false} type="default">
<div className="flex items-center gap-x-2">
<div>
<h5 className="text-foreground mb-1">
Spend cap needs to be disabled to configure this value
</h5>
<p className="text-foreground-light">
{isFreePlan
? 'Upgrade to the Pro plan first to disable spend cap'
: 'You may adjust this setting in the organization billing settings'}
</p>
</div>
<div className="grow flex items-center justify-end">
{isFreePlan ? (
<UpgradePlanButton
addon="spendCap"
source="realtimeSettings"
featureProposition="configure the max payload size parameter of realtime settings"
/>
) : (
<ToggleSpendCapButton />
)}
</div>
</div>
</Admonition>
)}
</CardContent>
</>
)}
<CardFooter className="justify-between">
<div>
{isPermissionsLoaded && !canUpdateConfig && (
<p className="text-sm text-foreground-light">
You need additional permissions to update realtime settings
</p>
)}
</div>
<div className="flex items-center gap-x-2">
{form.formState.isDirty && (
<Button variant="default" onClick={() => form.reset(formValues)}>
Cancel
</Button>
)}
<Button
variant="primary"
type="submit"
form={formId}
disabled={
!canUpdateConfig ||
isUpdatingConfig ||
!form.formState.isDirty ||
!isRealtimeEntitlementsLoaded
}
loading={isUpdatingConfig}
>
Save changes
</Button>
</div>
</CardFooter>
</Card>
)}
</form>
</Form>
<ConfirmationModal
visible={isConfirmNextModalOpen}
title="Confirm saving changes"
confirmLabel="Save changes"
loading={isUpdatingConfig}
onCancel={() => setIsConfirmNextModalOpen(false)}
onConfirm={() => onConfirmSave()}
>
<p className="text-sm text-foreground-light">
Saving the changes will disconnect all the clients connected to your project. Are you sure
you want to continue?
</p>
</ConfirmationModal>
</>
)
}