Files
Sean GeogheganandJoshen Lim f2ff4ec0e9 fix(realtime): display banner when realtime has been suspended by admin (#50497)
## 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?

<img width="1160" height="442" alt="Screenshot 2026-09-17 at 12 06
30 pm"
src="https://github.com/user-attachments/assets/f5abe900-a163-48c9-ab55-945fc62df0d1"
/>


## Additional context

Depends on https://github.com/supabase/platform/pull/38476


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-09-28 15:17:24 +08:00

307 lines
11 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useIsLoggedIn, useParams } from 'common'
import { MoreVertical, Redo2, Trash } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { LeaveTeamButton } from './LeaveTeamButton'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { useTeamSettingsData } from './TeamSettingsDataContext'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useOrganizationCreateInvitationMutation } from '@/data/organization-members/organization-invitation-create-mutation'
import { useOrganizationDeleteInvitationMutation } from '@/data/organization-members/organization-invitation-delete-mutation'
import { useOrganizationMemberDeleteMutation } from '@/data/organizations/organization-member-delete-mutation'
import type { OrganizationMember } from '@/data/organizations/organization-members-query'
import { doPermissionsCheck } from '@/hooks/misc/useCheckPermissions'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
interface MemberActionsProps {
member: OrganizationMember
}
export const MemberActions = ({ member }: MemberActionsProps) => {
const { slug } = useParams()
const { profile } = useProfile()
const isLoggedIn = useIsLoggedIn()
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const {
members,
roles: allRoles,
permissions,
selectedOrganization,
organizationMembersDeletionEnabled,
onManageAccess,
} = useTeamSettingsData()
const memberIsUser = member.gotrue_id == profile?.gotrue_id
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
const projectScopedRoles = allRoles?.project_scoped_roles ?? []
const isPendingInviteAcceptance = !!member.invited_id
const userMemberData = members?.find((m) => m.gotrue_id === profile?.gotrue_id)
const hasOrgRole =
(userMemberData?.role_ids ?? []).length === 1 &&
orgScopedRoles.some((r) => r.id === userMemberData?.role_ids[0])
const { rolesRemovable } = useGetRolesManagementPermissions(
selectedOrganization?.slug,
orgScopedRoles.concat(projectScopedRoles),
permissions ?? []
)
const roleId = member.role_ids?.[0] ?? -1
const canRemoveMember = member.role_ids.every((id) => rolesRemovable.includes(id))
const canCreateUserInvites = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isLoggedIn) return false
if (!permissions) return false
return doPermissionsCheck(
permissions,
PermissionAction.CREATE,
'user_invites',
{ resource: { role_id: roleId } },
selectedOrganization?.slug
)
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canResendInvite = canCreateUserInvites && hasOrgRole
const canDeleteUserInvites = useMemo(() => {
if (!IS_PLATFORM) return true
if (!isLoggedIn) return false
if (!permissions) return false
return doPermissionsCheck(
permissions,
PermissionAction.DELETE,
'user_invites',
{ resource: { role_id: roleId } },
selectedOrganization?.slug
)
}, [isLoggedIn, permissions, roleId, selectedOrganization?.slug])
const canRevokeInvite = canDeleteUserInvites && hasOrgRole
const { mutate: deleteOrganizationMember, isPending: isDeletingMember } =
useOrganizationMemberDeleteMutation({
onSuccess: () => {
toast.success(`Successfully removed ${member.primary_email}`)
setIsDeleteModalOpen(false)
},
})
const { mutate: inviteMember, isPending: isCreatingInvite } =
useOrganizationCreateInvitationMutation({
onSuccess: () => {
toast.success('Resent the invitation.')
},
onError: (error) => {
toast.error(`Failed to resend invitation: ${error.message}`)
},
})
const { mutate: deleteInvitation, isPending: isDeletingInvite } =
useOrganizationDeleteInvitationMutation()
const isLoading = isDeletingMember || isDeletingInvite || isCreatingInvite
const handleMemberDelete = () => {
if (!slug) return console.error('slug is required')
if (!member.gotrue_id) return console.error('gotrue_id is required')
deleteOrganizationMember({ slug, gotrueId: member.gotrue_id })
}
const handleResendInvite = (member: OrganizationMember) => {
const roleId = (member?.role_ids ?? [])[0]
const invitedId = member.invited_id
if (!slug) return console.error('Slug is required')
if (!invitedId) return console.error('Member invited ID is required')
deleteInvitation(
{ slug, id: invitedId, skipInvalidation: true },
{
onSuccess: () => {
if (!member.primary_email) return toast.error('Email is required')
const projectScopedRole = projectScopedRoles.find((role) => role.id === roleId)
if (projectScopedRole !== undefined && projectScopedRole.base_role_id) {
const projects = projectScopedRole.projects.map(({ ref }) => ref)
inviteMember({
slug,
emails: [member.primary_email],
roleId: projectScopedRole.base_role_id,
projects,
})
} else {
inviteMember({ slug, emails: [member.primary_email], roleId })
}
},
}
)
}
const handleRevokeInvitation = (member: OrganizationMember) => {
const invitedId = member.invited_id
if (!slug) return console.error('Slug is required')
if (!invitedId) return console.error('Member invited ID is required')
deleteInvitation(
{ slug, id: invitedId },
{ onSuccess: () => toast.success('Successfully canceled the invitation') }
)
}
if (memberIsUser) {
return (
<div className="flex items-center justify-end">
<LeaveTeamButton />
</div>
)
}
return (
<>
<div className="flex items-center justify-end gap-x-2">
<ButtonTooltip
disabled={isPendingInviteAcceptance || !canRemoveMember}
onClick={() => onManageAccess(member)}
tooltip={{
content: {
side: 'bottom',
text: isPendingInviteAcceptance
? 'Access can be managed after the invite is accepted'
: !canRemoveMember
? 'You need additional permissions to manage this team member'
: undefined,
},
}}
>
Manage access
</ButtonTooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More options"
variant="text"
className="px-1.5"
disabled={isLoading}
loading={isLoading}
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<>
{isPendingInviteAcceptance ? (
<>
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canResendInvite}
onClick={() => handleResendInvite(member)}
tooltip={{
content: {
side: 'left',
text: 'Additional permissions required to resend invitation',
},
}}
>
<Redo2 size={14} />
<p>Resend invitation</p>
</DropdownMenuItemTooltip>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canRevokeInvite}
onClick={() => handleRevokeInvitation(member)}
tooltip={{
content: {
side: 'left',
text: 'Additional permissions required to cancel invitation',
},
}}
>
<Trash size={14} />
<p>Cancel invitation</p>
</DropdownMenuItemTooltip>
</>
) : (
organizationMembersDeletionEnabled && (
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={!canRemoveMember}
onClick={() => setIsDeleteModalOpen(true)}
tooltip={{
content: {
side: 'left',
text: 'Additional permissions required to remove member',
},
}}
>
<Trash size={12} />
<p>Remove member</p>
</DropdownMenuItemTooltip>
)
)}
</>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ConfirmationModal
size="large"
visible={isDeleteModalOpen}
loading={isDeletingMember}
title="Confirm to remove member"
confirmLabel="Remove"
variant="warning"
alert={{
title: 'All user content from this member will be permanently removed.',
description: (
<div>
Removing a member will delete all of the user's saved content in all projects of this
organization, which includes:
<ul className="list-disc pl-4 my-2">
<li>
SQL snippets{' '}
<span className="text-foreground">
(both <span className="underline">private</span> and{' '}
<span className="underline">shared</span> snippets)
</span>
</li>
<li>Custom reports</li>
<li>Log Explorer queries</li>
</ul>
<p className="mt-4 text-foreground-lighter">
If you'd like to retain the member's shared SQL snippets, right click on them and
"Duplicate query" in the SQL Editor before removing this member.
</p>
</div>
),
}}
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => {
handleMemberDelete()
}}
>
<p className="text-sm text-foreground-light">
Are you sure you want to remove{' '}
<span className="text-foreground">{member.primary_email}</span> from{' '}
<span className="text-foreground">{selectedOrganization?.name}</span>?
</p>
</ConfirmationModal>
</>
)
}