feat: surface role risks (#50410)

## TL;DR 

Bolds the consequences in the Owner and Administrator role descriptions,
and adds a confirmation step before an invite for either role is sent.


https://github.com/user-attachments/assets/c8fb53ae-66c3-4862-865e-f2ff9fb84a8e

## ref: 

- recurring in the community:
organizations losing access to their own projects after inviting someone
as owner

eg:
https://discord.com/channels/839993398554656828/1545087567706193970/1545102402871492759

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

## Summary by CodeRabbit

- **New Features**
- Added a confirmation step when inviting members as Owners or
Administrators.
- Invitations are sent only after the elevated-role warning is
confirmed.
- Confirmation behavior is consistent for form submissions and
keyboard-triggered invitations.

- **Updates**
- Refined role permission descriptions, including clearer details about
elevated access and project deletion capabilities.
- Improved role descriptions shown during member invitations and role
selection.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Vaibhav authored and GitHub committed 2026-09-15 15:25:46 +00:00
1 parent 24f89f3967
commit 254da82c3a
7 files changed
+95 -33

No files matched your search

@@ -31,6 +31,7 @@ import {
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 * as z from 'zod'
@@ -77,6 +78,7 @@ export const InviteMemberButton = () => {
])
const [isOpen, setIsOpen] = useState(false)
const [pendingInvite, setPendingInvite] = useState<z.infer<typeof FormSchema>>()
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false)
const { data: members } = useOrganizationMembersQuery({ slug })
@@ -157,6 +159,9 @@ export const InviteMemberButton = () => {
})
const emailCount = parseEmails(email ?? '').length
const getRoleName = (roleId?: string) =>
orgScopedRoles.find((role) => role.id.toString() === roleId)?.name
const pendingRoleName = getRoleName(pendingInvite?.role)
const onInviteMember = async (values: z.infer<typeof FormSchema>) => {
if (!slug) return console.error('Slug is required')
@@ -221,6 +226,18 @@ export const InviteMemberButton = () => {
}
}
const handleInviteSubmit = (values: z.infer<typeof FormSchema>) => {
const roleName = getRoleName(values.role)
const needsConfirmation = roleName === 'Owner' || roleName === 'Administrator'
if (needsConfirmation) setPendingInvite(values)
else onInviteMember(values)
}
const handleConfirmInvite = async () => {
if (pendingInvite) await onInviteMember(pendingInvite)
setPendingInvite(undefined)
}
useEffect(() => {
if (isSuccess && isOpen) {
const developerRoleId = orgScopedRoles
@@ -315,7 +332,7 @@ export const InviteMemberButton = () => {
<form
id="organization-invitation"
className="flex flex-col gap-y-4"
onSubmit={form.handleSubmit(onInviteMember)}
onSubmit={form.handleSubmit(handleInviteSubmit)}
>
<FormField
name="role"
@@ -353,13 +370,13 @@ export const InviteMemberButton = () => {
value={role.id.toString()}
disabled={disabled}
label={role.name}
description={[
ROLE_DESCRIPTIONS[role.name] ??
'Permissions are based on the configured organization role.',
disabledReason,
]
.filter(Boolean)
.join(' ')}
description={
<>
{ROLE_DESCRIPTIONS[role.name] ??
'Permissions are based on the configured organization role.'}
{disabledReason && ` ${disabledReason}`}
</>
}
/>
</FormControl>
</FormItem>
@@ -473,7 +490,7 @@ export const InviteMemberButton = () => {
<Button onClick={confirmOnClose}>Cancel</Button>
<Shortcut
id={SHORTCUT_IDS.ORG_TEAM_INVITE_SUBMIT}
onTrigger={() => form.handleSubmit(onInviteMember)()}
onTrigger={() => form.handleSubmit(handleInviteSubmit)()}
options={{ enabled: isOpen && !isInviting }}
side="top"
>
@@ -492,6 +509,17 @@ export const InviteMemberButton = () => {
{...discardChangesModalProps}
description="Are you sure you want to discard your changes? Your invitation will not be sent."
/>
<ConfirmationModal
variant="warning"
visible={pendingInvite !== undefined}
loading={isInviting}
title={`Invite as ${pendingRoleName}?`}
description={pendingRoleName && ROLE_DESCRIPTIONS[pendingRoleName]}
confirmLabel="Send invitation"
confirmLabelLoading="Sending invitation..."
onCancel={() => setPendingInvite(undefined)}
onConfirm={handleConfirmInvite}
/>
</Sheet>
)
}
@@ -1,9 +0,0 @@
export const ROLE_DESCRIPTIONS: Record<string, string> = {
Owner: 'Full access, including deleting the organization and transferring or deleting projects.',
Administrator:
'Manage members, billing, and project settings, including deleting projects. Cannot manage organization settings or owners.',
Developer:
'Manage project content, including deleting data, users, files, and Edge Functions. Cannot change settings or delete projects.',
'Read-only':
'View resources without modifying or deleting them. SQL Editor access is limited to SELECT queries.',
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
export const ROLE_DESCRIPTIONS: Record<string, ReactNode> = {
Owner: (
<>
Full access, including <strong>removing you or any other owner</strong>,{' '}
<strong>deleting the organization</strong>, and transferring or deleting projects.
</>
),
Administrator: (
<>
Manage members, billing, and project settings, including <strong>removing members</strong> and{' '}
<strong>deleting projects</strong>. Cannot manage organization settings or owners.
</>
),
Developer:
'Manage project content, including deleting data, users, files, and Edge Functions. Cannot change settings or delete projects.',
'Read-only':
'View resources without modifying or deleting them. SQL Editor access is limited to SELECT queries.',
}
@@ -299,13 +299,9 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP
<div className="flex flex-col gap-0.5 max-w-xs">
<span>{role.name}</span>
<span className="text-xs text-foreground-lighter">
{[
ROLE_DESCRIPTIONS[role.name] ??
'Permissions are based on the configured organization role.',
disabledReason,
]
.filter(Boolean)
.join(' ')}
{ROLE_DESCRIPTIONS[role.name] ??
'Permissions are based on the configured organization role.'}
{disabledReason && ` ${disabledReason}`}
</span>
</div>
</SelectItem>
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components, operations } from 'api-types'
import { HttpResponse } from 'msw'
@@ -191,7 +191,7 @@ describe('InviteMemberButton (network)', () => {
expect(screen.getByText('Read-only')).toBeInTheDocument()
// The key safety message from the ticket: Administrator can delete projects
expect(screen.getByText(/including deleting projects/i)).toBeInTheDocument()
expect(screen.getByText('deleting projects')).toBeInTheDocument()
// Roles documentation is one click away (the ticket's other complaint)
const docsLink = screen.getByRole('link', { name: /roles and permissions/i })
@@ -232,6 +232,10 @@ describe('InviteMemberButton (network)', () => {
})
fireEvent.click(screen.getByRole('button', { name: /send invitation/i }))
const confirmation = await screen.findByRole('dialog', { name: 'Invite as Administrator?' })
expect(invitePayloads).toHaveLength(0)
fireEvent.click(within(confirmation).getByRole('button', { name: /send invitation/i }))
await waitFor(() => expect(invitePayloads).toHaveLength(1))
expect(invitePayloads[0]).toEqual({
emails: ['admin@example.com'],
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from 'sonner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -102,6 +102,9 @@ vi.mock('@/hooks/ui/useConfirmOnClose', () => ({
}))
// Helpers
const getRoleDescription = (text: string) =>
screen.getByText((_, element) => element?.tagName === 'P' && element.textContent === text)
async function openDialog() {
await userEvent.click(screen.getByRole('button', { name: /invite members/i }))
return screen.findByRole('dialog')
@@ -143,13 +146,13 @@ describe('InviteMemberButton', () => {
'https://supabase.com/docs/guides/platform/access-control'
)
expect(
screen.getByText(
'Full access, including deleting the organization and transferring or deleting projects.'
getRoleDescription(
'Full access, including removing you or any other owner, deleting the organization, and transferring or deleting projects.'
)
).toBeInTheDocument()
expect(
screen.getByText(
'Manage members, billing, and project settings, including deleting projects. Cannot manage organization settings or owners.'
getRoleDescription(
'Manage members, billing, and project settings, including removing members and deleting projects. Cannot manage organization settings or owners.'
)
).toBeInTheDocument()
expect(
@@ -164,6 +167,26 @@ describe('InviteMemberButton', () => {
).toBeInTheDocument()
})
it('asks for confirmation before inviting as Owner', async () => {
customRender(<InviteMemberButton />)
await openDialog()
await userEvent.click(screen.getByRole('radio', { name: 'Owner' }))
fireEvent.change(screen.getByPlaceholderText(/name@example\.com/i), {
target: { value: 'new@example.com' },
})
fireEvent.click(screen.getByRole('button', { name: /send invitation/i }))
const confirmation = await screen.findByRole('dialog', { name: 'Invite as Owner?' })
expect(mockInvite).not.toHaveBeenCalled()
fireEvent.click(within(confirmation).getByRole('button', { name: /send invitation/i }))
await waitFor(() => {
expect(mockInvite).toHaveBeenCalledWith(
expect.objectContaining({ emails: ['new@example.com'], roleId: 4 })
)
})
})
it('calls the mutation with a single email in an array', async () => {
customRender(<InviteMemberButton />)
await submitForm('new@example.com')
@@ -172,7 +172,7 @@ describe('UpdateRolesPanel (network)', () => {
expect(screen.getByRole('option', { name: /Read-only/ })).toBeInTheDocument()
// The key safety message from the ticket: Administrator can delete projects
expect(screen.getByText(/including deleting projects/i)).toBeInTheDocument()
expect(screen.getByText('deleting projects')).toBeInTheDocument()
})
test('renders a role description for every known role', async () => {