mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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.',
|
||||
}
|
||||
+3
-7
@@ -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>
|
||||
|
||||
+6
-2
@@ -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')
|
||||
|
||||
+1
-1
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user