From f2ff4ec0e9ea5ad6f41426066dc5a30bc93f300b Mon Sep 17 00:00:00 2001 From: Sean Geoghegan Date: Mon, 28 Sep 2026 16:47:24 +0930 Subject: [PATCH] fix(realtime): display banner when realtime has been suspended by admin (#50497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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? If Realtime's admin_suspended_at is set we display a banner. ## What is the current behavior? REAL-1095 ## What is the new behavior? Screenshot 2026-09-17 at 12 06
30 pm ## Additional context Depends on https://github.com/supabase/platform/pull/38476 ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added a notice to Realtime settings when the service is suspended, with instructions to contact support. - **Bug Fixes** - Improved invitation resending and role updates for roles without a linked base role. - **Tests** - Added coverage to verify the suspension notice appears only when applicable. --------- Co-authored-by: Joshen Lim --- .../TeamSettings/MemberActions.tsx | 2 +- .../UpdateRolesPanel.utils.ts | 7 +- .../Realtime/Inspector/EmptyRealtime.tsx | 17 +++- .../Realtime/RealtimeSettings.test.tsx | 28 ++++++ .../interfaces/Realtime/RealtimeSettings.tsx | 8 +- .../interfaces/Settings/SuspensionNotice.tsx | 36 +++++++ .../data/realtime/realtime-config-query.ts | 1 + packages/api-types/types/api-v1.d.ts | 37 +++----- packages/api-types/types/api-v2.d.ts | 6 +- packages/api-types/types/platform.d.ts | 94 +++++++++++++++---- 10 files changed, 178 insertions(+), 58 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/SuspensionNotice.tsx diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index 6dc091d898f..80dc5bfe9cd 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -134,7 +134,7 @@ export const MemberActions = ({ member }: MemberActionsProps) => { const projectScopedRole = projectScopedRoles.find((role) => role.id === roleId) - if (projectScopedRole !== undefined) { + if (projectScopedRole !== undefined && projectScopedRole.base_role_id) { const projects = projectScopedRole.projects.map(({ ref }) => ref) inviteMember({ slug, diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts index 0cda4548fd8..53ab7d8dd89 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts @@ -143,14 +143,15 @@ export const deriveRoleChangeActions = ( return toRemove.push(role.id) } - const projectsToAddToRole = (groupByAddedRoles[role.base_role_id]?.map((r) => r.ref) ?? + const projectsToAddToRole = (groupByAddedRoles[role.base_role_id ?? '']?.map((r) => r.ref) ?? []) as string[] const projectsToRemoveFromRole = (groupByRemovedRoles[role.id]?.map((r) => r.ref) ?? []) as string[] const projectsUpdatingFromRole = (groupByUpdatingFromRoles[role.id]?.map((r) => r.ref) ?? []) as string[] - const projectsUpdatingToRole = (groupByUpdatingToRoles[role.base_role_id]?.map((r) => r.ref) ?? - []) as string[] + const projectsUpdatingToRole = (groupByUpdatingToRoles[role.base_role_id ?? '']?.map( + (r) => r.ref + ) ?? []) as string[] const projectRefsAppliedUpdated = projectRefsApplied .filter((x) => !projectsToRemoveFromRole.includes(x)) .filter((x) => !projectsUpdatingFromRole.includes(x)) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx index 743d6b9b493..aaf30701215 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx @@ -1,9 +1,11 @@ import Link from 'next/link' import { AiIconAnimation, Button, Card, cn } from 'ui' +import { SuspensionNotice } from '../../Settings/SuspensionNotice' import { AnimatedCursors } from './AnimatedCursors' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { DocsButton } from '@/components/ui/DocsButton' +import { useRealtimeConfigurationQuery } from '@/data/realtime/realtime-config-query' import { DOCS_URL } from '@/lib/constants' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' @@ -15,6 +17,9 @@ export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => { const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() + const { data } = useRealtimeConfigurationQuery({ projectRef }) + const isSuspended = Boolean(data?.admin_suspended_at) + const handleCreateTriggerWithAssistant = () => { openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) aiSnap.newChat({ @@ -26,17 +31,21 @@ export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => { return (
-
+

Create realtime experiences

Send your first realtime message from your database, application code or edge function

- + {!isSuspended && ( + + )}
+ {isSuspended && } +
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx index e817c0a0a0c..9d686bfcd63 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.test.tsx @@ -66,6 +66,7 @@ const REALTIME_CONFIG = { presence_enabled: true, private_only: false, suspend: false, + admin_suspended_at: null, } as const satisfies RealtimeConfigurationData const buildRealtimeEntitlements = ( @@ -269,4 +270,31 @@ describe('RealtimeSettings', () => { expect(await screen.findByText('Cannot exceed 50,000 concurrent clients')).toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + + test('shows a suspension banner when Realtime has been admin-suspended', async () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/config/realtime', + response: () => + HttpResponse.json({ + ...REALTIME_CONFIG, + admin_suspended_at: '2026-01-01T00:00:00+00:00', + }), + }) + + customRender() + + expect( + await screen.findByText('Supabase has suspended Realtime for this project') + ).toBeInTheDocument() + }) + + test('does not show a suspension banner when Realtime has not been admin-suspended', async () => { + customRender() + + await screen.findByLabelText('Postgres Changes connection pool size') + expect( + screen.queryByText('Supabase has suspended Realtime for this project') + ).not.toBeInTheDocument() + }) }) diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx index 8c45d457066..1409f32d548 100644 --- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx +++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx @@ -26,6 +26,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' +import { SuspensionNotice } from '../Settings/SuspensionNotice' import { AlertError } from '@/components/ui/AlertError' import { ToggleSpendCapButton } from '@/components/ui/ToggleSpendCapButton' import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' @@ -67,9 +68,7 @@ export const RealtimeSettings = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data, error, isError, isPending } = useRealtimeConfigurationQuery({ - projectRef, - }) + const { data, error, isError, isPending } = useRealtimeConfigurationQuery({ projectRef }) const { data: policies, isSuccess: isSuccessPolicies } = useDatabasePoliciesQuery({ projectRef, @@ -119,6 +118,7 @@ export const RealtimeSettings = () => { const isFreePlan = organization?.plan.id === 'free' const isUsageBillingEnabled = organization?.usage_billing_enabled const isRealtimeDisabled = data?.suspend ?? REALTIME_DEFAULT_CONFIG.suspend + const isAdminSuspended = Boolean(data?.admin_suspended_at) // Check if RLS policies exist for realtime.messages table const realtimeMessagesPolicies = policies?.filter( (policy) => policy.schema === 'realtime' && policy.table === 'messages' @@ -325,6 +325,8 @@ export const RealtimeSettings = () => { return ( <> + {isAdminSuspended && } +
{isError ? ( diff --git a/apps/studio/components/interfaces/Settings/SuspensionNotice.tsx b/apps/studio/components/interfaces/Settings/SuspensionNotice.tsx new file mode 100644 index 00000000000..5510c82943d --- /dev/null +++ b/apps/studio/components/interfaces/Settings/SuspensionNotice.tsx @@ -0,0 +1,36 @@ +import { useParams } from 'common' +import { Admonition } from 'ui-patterns/Admonition' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +import { ContactSupportButton } from '@/components/ui/AlertError' + +export const SuspensionNotice = ({ suspendedAt }: { suspendedAt?: string | null }) => { + const { ref } = useParams() + + return ( + + Suspended since{' '} + {' '} + due to suspected unusual or excessive usage.
+ Contact support for details or to restore access. + + } + actions={ + + } + /> + ) +} diff --git a/apps/studio/data/realtime/realtime-config-query.ts b/apps/studio/data/realtime/realtime-config-query.ts index d9f2fa52915..9021a6e8fce 100644 --- a/apps/studio/data/realtime/realtime-config-query.ts +++ b/apps/studio/data/realtime/realtime-config-query.ts @@ -25,6 +25,7 @@ export const REALTIME_DEFAULT_CONFIG = { max_payload_size_in_kb: 100, suspend: false, presence_enabled: true, + admin_suspended_at: null, } as const satisfies RealtimeConfigResponse export async function getRealtimeConfiguration( diff --git a/packages/api-types/types/api-v1.d.ts b/packages/api-types/types/api-v1.d.ts index fb3fb12d8bb..3384e1afb06 100644 --- a/packages/api-types/types/api-v1.d.ts +++ b/packages/api-types/types/api-v1.d.ts @@ -572,14 +572,7 @@ export interface paths { /** * Gets project's logs * @deprecated - * @description Executes a SQL query on the project's logs. - * - * Either the `iso_timestamp_start` and `iso_timestamp_end` parameters must be provided. - * If both are not provided, only the last 1 minute of logs will be queried. - * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. - * - * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#logs-explorer) for all available sources. - * + * @description This endpoint has been removed and always responds with `410 Gone`. Use `GET /v1/projects/{ref}/analytics/endpoints/logs` instead. See the [migration guide](https://supabase.com/changelog/48235-migration-of-supabase-management-api-logs-all-analytics-endpoint-to-logs-endpoint). */ get: operations['v1-get-project-logs-all'] put?: never @@ -4226,6 +4219,11 @@ export interface components { override_enabled: boolean } RealtimeConfigResponse_Output: { + /** + * Format: date-time + * @description If set, the Realtime service has been suspended by an admin. + */ + admin_suspended_at: string | null /** @description Sets connection pool size for Realtime Authorization */ connection_pool: number | null /** @description Sets maximum number of bytes per second rate per channel limit */ @@ -7622,12 +7620,7 @@ export interface operations { } 'v1-get-project-logs-all': { parameters: { - query?: { - iso_timestamp_end?: string - iso_timestamp_start?: string - /** @description Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. */ - sql?: string - } + query?: never header?: never path: { /** @description Project ref */ @@ -7637,14 +7630,6 @@ export interface operations { } requestBody?: never responses: { - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['AnalyticsResponse_Output'] - } - } /** @description Unauthorized */ 401: { headers: { @@ -7652,15 +7637,15 @@ export interface operations { } content?: never } - /** @description Usage exceeded. Enable additional usage to continue querying */ - 402: { + /** @description Forbidden action */ + 403: { headers: { [name: string]: unknown } content?: never } - /** @description Forbidden action */ - 403: { + /** @description This endpoint has been removed */ + 410: { headers: { [name: string]: unknown } diff --git a/packages/api-types/types/api-v2.d.ts b/packages/api-types/types/api-v2.d.ts index 7d6281ba0e5..3172dd1b30a 100644 --- a/packages/api-types/types/api-v2.d.ts +++ b/packages/api-types/types/api-v2.d.ts @@ -1235,7 +1235,7 @@ export interface components { * @example developer * @enum {string} */ - role: 'owner' | 'administrator' | 'developer' | 'read-only' + role: 'owner' | 'administrator' | 'developer' | 'read-only' | 'no-access' } /** * @description Resource type. @@ -1428,11 +1428,11 @@ export interface components { }[] require_sso?: boolean /** - * @description Role name to assign. Must be on a Team or Enterprise plan to use the read-only role. + * @description Role name to assign. Must be on an Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately. * @example developer * @enum {string} */ - role: 'owner' | 'administrator' | 'developer' | 'read-only' + role: 'owner' | 'administrator' | 'developer' | 'read-only' | 'no-access' } /** * @description Resource type. diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index ade636caa04..3f82b1fe435 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -1795,7 +1795,7 @@ export interface paths { get: operations['OAuthAppClientSecretsController_listClientSecrets'] put?: never /** Create oauth app client secret */ - post: operations['OAuthAppClientSecretsController_CreateClientSecret'] + post: operations['OAuthAppClientSecretsController_createClientSecret'] delete?: never options?: never head?: never @@ -1813,7 +1813,7 @@ export interface paths { put?: never post?: never /** Remove oauth app client secret */ - delete: operations['OAuthAppClientSecretsController_RemoveClientSecret'] + delete: operations['OAuthAppClientSecretsController_removeClientSecret'] options?: never head?: never patch?: never @@ -6360,11 +6360,11 @@ export interface components { }[] require_sso?: boolean /** - * @description Role name to assign. Must be on a Team or Enterprise plan to use the read-only role. + * @description Role name to assign. Must be on a Team, Platform, or Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately. * @example developer * @enum {string} */ - role?: 'owner' | 'administrator' | 'developer' | 'read-only' + role?: 'owner' | 'administrator' | 'developer' | 'read-only' | 'no-access' role_id?: number } }[] @@ -6820,6 +6820,8 @@ export interface components { | 'm8g.medium' | 'm9g.medium' | 'c6g.medium' + | 'c7g.medium' + | 'c8g.medium' | 'm6g.large' | 'm6a.large' | 'm6i.large' @@ -6827,6 +6829,7 @@ export interface components { | 'm8i.large' | 'm7a.large' | 'm8a.large' + | 'c6a.large' | 'm6g.xlarge' | 'm6a.xlarge' | 'm6i.xlarge' @@ -10405,7 +10408,7 @@ export interface components { } OrganizationRoleResponse_Output: { org_scoped_roles: { - base_role_id: number + base_role_id: number | null description: string | null id: number name: string @@ -10419,7 +10422,7 @@ export interface components { }[] }[] project_scoped_roles: { - base_role_id: number + base_role_id: number | null description: string | null id: number name: string @@ -12290,6 +12293,11 @@ export interface components { }[] } RealtimeConfigResponse_Output: { + /** + * Format: date-time + * @description If set, the Realtime service has been suspended by an admin. + */ + admin_suspended_at?: string | null /** @description Sets connection pool size for Realtime Authorization */ connection_pool?: number | null /** @description Sets maximum number of bytes per second rate per channel limit */ @@ -22392,7 +22400,7 @@ export interface operations { } } } - OAuthAppClientSecretsController_CreateClientSecret: { + OAuthAppClientSecretsController_createClientSecret: { parameters: { query?: never header?: never @@ -22436,7 +22444,7 @@ export interface operations { } } } - OAuthAppClientSecretsController_RemoveClientSecret: { + OAuthAppClientSecretsController_removeClientSecret: { parameters: { query?: never header?: never @@ -23028,7 +23036,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file: string /** Format: uri */ @@ -23044,7 +23057,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file?: string metadata_xml_url: string @@ -23097,7 +23115,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file: string /** Format: uri */ @@ -23113,7 +23136,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file?: string metadata_xml_url: string @@ -23137,7 +23165,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file: string /** Format: uri */ @@ -23153,7 +23186,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file?: string metadata_xml_url: string @@ -23206,7 +23244,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file: string /** Format: uri */ @@ -23222,7 +23265,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file?: string metadata_xml_url: string @@ -23246,7 +23294,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file: string /** Format: uri */ @@ -23262,7 +23315,12 @@ export interface operations { idjag_issuer_url?: string | null join_org_on_signup_enabled: boolean /** @enum {string} */ - join_org_on_signup_role?: 'Administrator' | 'Developer' | 'Owner' | 'Read-only' + join_org_on_signup_role?: + | 'Administrator' + | 'Developer' + | 'Owner' + | 'Read-only' + | 'None' last_name_mapping?: string[] metadata_xml_file?: string metadata_xml_url: string