From 254da82c3af352f19dd0c3006e0deb2530b0b35d Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:55:46 +0530 Subject: [PATCH] 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 ## 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. --- .../TeamSettings/InviteMemberButton.tsx | 46 +++++++++++++++---- .../TeamSettings/Roles.constants.ts | 9 ---- .../TeamSettings/Roles.constants.tsx | 20 ++++++++ .../UpdateRolesPanel/UpdateRolesPanel.tsx | 10 ++-- .../InviteMemberButton.network.test.tsx | 8 +++- .../TeamSettings/InviteMemberButton.test.tsx | 33 +++++++++++-- .../UpdateRolesPanel.network.test.tsx | 2 +- 7 files changed, 95 insertions(+), 33 deletions(-) delete mode 100644 apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.ts create mode 100644 apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index a9035ff8e5c..a53bd1fb260 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -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>() 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) => { if (!slug) return console.error('Slug is required') @@ -221,6 +226,18 @@ export const InviteMemberButton = () => { } } + const handleInviteSubmit = (values: z.infer) => { + 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 = () => {
{ 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}`} + + } /> @@ -473,7 +490,7 @@ export const InviteMemberButton = () => { 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." /> + setPendingInvite(undefined)} + onConfirm={handleConfirmInvite} + /> ) } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.ts b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.ts deleted file mode 100644 index 56d9cef555c..00000000000 --- a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const ROLE_DESCRIPTIONS: Record = { - 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.', -} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx new file mode 100644 index 00000000000..a3759ad9ca9 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' + +export const ROLE_DESCRIPTIONS: Record = { + Owner: ( + <> + Full access, including removing you or any other owner,{' '} + deleting the organization, and transferring or deleting projects. + + ), + Administrator: ( + <> + Manage members, billing, and project settings, including removing members and{' '} + 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.', +} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx index 698c9faed2f..c4470ea1bac 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx @@ -299,13 +299,9 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP
{role.name} - {[ - 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}`}
diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx index 21d69f98f43..3757891f86d 100644 --- a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.network.test.tsx @@ -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'], diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx index 43fc7198c8f..8aafc5b1259 100644 --- a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx @@ -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() + 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() await submitForm('new@example.com') diff --git a/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx index 9ead9852447..6e6ddd9276a 100644 --- a/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx +++ b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx @@ -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 () => {