fix(studio): clarify system role grouping and deletion actions (#51526)

## Problem

The Database Roles page groups `postgres` and the reserved
`supabase_privileged_role` alongside custom roles, while labeling the
other section as "Roles managed by Supabase." The `postgres` actions
menu also offers an enabled Delete action.

Fixes #51525.

## Solution

- Rename the sections to "Supabase service roles" and "Project database
roles," and explain that the latter includes the `postgres`
administrator and custom roles.
- Include `supabase_privileged_role` among the protected service roles.
- Disable Delete for the existing `SYSTEM_ROLES` list, using
`DropdownMenuItemTooltip` to explain the restriction. Keep the
`postgres` permission editor available.
- Add component regressions for grouping, system-role menus, permission
editing, and custom-role deletion confirmation.

## Validation

- Studio unit suite: 732 files passed; 8,343 tests passed and one
expected failure.
- `pnpm --filter studio typecheck` passed.
- `SKIP_ASSET_UPLOAD=1 pnpm build --filter=studio` passed.
- ESLint for changed files, the Studio lint ratchet, monorepo Prettier,
and Studio knip passed.
- Playwright smoke verification of the production Studio build with
synthetic API responses: grouping, disabled Delete and tooltip, keyboard
interaction, permission editing/cancellation, read-only service roles,
search, and custom-role deletion confirmation/cancellation.

## Review instructions

1. Open Database → Roles. Check the two section names and the
administrator description. If present, `supabase_privileged_role`
belongs to the protected service section.
2. Open the `postgres` actions menu. Delete is disabled, and hovering it
explains why.
3. Expand `postgres`. Its existing editable permission switches remain
enabled for a user who can edit roles.
4. Open a custom role's actions menu. Delete still opens the
confirmation dialog; cancel the dialog.
5. Run `pnpm --filter studio exec vitest run
tests/components/Database/Roles.test.tsx`.

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs
[style
guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)
— not applicable; this changes Studio UI and tests.
This commit is contained in:
Ikki Nishihara authored and GitHub committed 2026-10-14 13:57:01 -04:00
1 parent 4c7014745c
commit cba8bc0b30
4 files changed
+187 -10

No files matched your search

@@ -11,7 +11,6 @@ import {
CollapsibleTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Form,
FormControl,
@@ -24,7 +23,8 @@ import {
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { ROLE_PERMISSIONS } from './Roles.constants'
import { ROLE_PERMISSIONS, SYSTEM_ROLES } from './Roles.constants'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { useDatabaseRoleUpdateMutation } from '@/data/database-roles/database-role-update-mutation'
import type { PgRole } from '@/data/database-roles/database-roles-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -47,6 +47,7 @@ const formSchema = z.object(
)
export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => {
const isSystemRole = SYSTEM_ROLES.some((name) => name === role.name)
const { data: project } = useSelectedProjectQuery()
const [isExpanded, setIsExpanded] = useState(false)
const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
@@ -171,8 +172,15 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
<TooltipContent side="bottom">More options</TooltipContent>
</Tooltip>
<DropdownMenuContent side="bottom" align="end" className="w-[120px]">
<DropdownMenuItem
<DropdownMenuItemTooltip
className="space-x-2"
disabled={isSystemRole}
tooltip={{
content: {
side: 'left',
text: `The ${role.name} role is a system role and cannot be deleted`,
},
}}
onClick={(event) => {
event.stopPropagation()
onSelectDelete(role.id.toString())
@@ -180,7 +188,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
>
<TrashIcon className="text-red-800" size="14" strokeWidth={2} />
<p>Delete</p>
</DropdownMenuItem>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -5,6 +5,7 @@ export const SUPABASE_ROLES = [
'authenticator',
'dashboard_user',
'supabase_admin',
'supabase_privileged_role',
'supabase_auth_admin',
'supabase_functions_admin',
'supabase_read_only_user',
@@ -248,9 +248,11 @@ export const RolesList = () => {
</div>
<div className="space-y-4">
<div>
<section aria-labelledby="supabase-service-roles">
<div className="bg-surface-100 border border-default px-card py-3 rounded-t flex items-center space-x-4">
<p className="text-sm text-foreground-light">Roles managed by Supabase</p>
<h2 id="supabase-service-roles" className="text-sm text-foreground-light">
Supabase service roles
</h2>
<Badge variant="success">Protected</Badge>
</div>
@@ -264,11 +266,18 @@ export const RolesList = () => {
onSelectDelete={setSelectedRoleIdToDelete}
/>
))}
</div>
</section>
<div>
<section aria-labelledby="project-database-roles">
<div className="bg-surface-100 border border-default px-card py-3 rounded-t">
<p className="text-sm text-foreground-light">Other database roles</p>
<div className="space-y-1">
<h2 id="project-database-roles" className="text-sm text-foreground-light">
Project database roles
</h2>
<p className="text-xs text-foreground-lighter">
Includes the <code>postgres</code> administrator role and custom roles.
</p>
</div>
</div>
{isLoading
@@ -281,7 +290,7 @@ export const RolesList = () => {
onSelectDelete={setSelectedRoleIdToDelete}
/>
))}
</div>
</section>
</div>
{filterString.length > 0 && filteredRoles.length === 0 && (
@@ -0,0 +1,159 @@
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { RoleRow } from '@/components/interfaces/Database/Roles/RoleRow'
import { RolesList } from '@/components/interfaces/Database/Roles/RolesList'
import type { PgRole } from '@/data/database-roles/database-roles-query'
import type { ProjectDetail } from '@/data/projects/project-detail-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
mockAnimationsApi()
const createRole = (id: number, name: string): PgRole => ({
id,
name,
isSuperuser: false,
canCreateDb: false,
canCreateRole: false,
inheritRole: true,
canLogin: true,
isReplicationRole: false,
canBypassRls: false,
activeConnections: 0,
connectionLimit: -1,
validUntil: null,
config: {},
})
const roles = [
createRole(1, 'postgres'),
createRole(2, 'supabase_privileged_role'),
createRole(3, 'supabase_auth_admin'),
createRole(4, 'custom_role'),
]
beforeEach(() => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
response: () =>
HttpResponse.json<ProjectDetail>({
cloud_provider: 'AWS',
connectionString: null,
db_host: 'localhost',
high_availability: false,
id: 1,
inserted_at: '2026-01-01T00:00:00Z',
integration_source: null,
is_branch_enabled: false,
is_physical_backups_enabled: false,
name: 'Test project',
organization_id: 1,
ref: 'default',
region: 'us-east-1',
restUrl: 'http://localhost:54321',
status: 'ACTIVE_HEALTHY',
subscription_id: 'test-subscription',
updated_at: '2026-01-01T00:00:00Z',
}),
})
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: ({ request }) => {
const key = new URL(request.url).searchParams.get('key')
if (key === 'max-connections') {
return HttpResponse.json<{ max_connections: string }[]>([{ max_connections: '100' }])
}
if (key === 'database-roles') {
return HttpResponse.json<PgRole[]>(roles)
}
throw new Error(`Unexpected SQL request: ${key}`)
},
})
})
describe('RoleRow', () => {
test.each(['postgres', 'pg_database_owner', 'pg_read_all_data', 'pg_write_all_data'])(
'disables Delete for the system role %s',
async (name) => {
const onSelectDelete = vi.fn()
customRender(<RoleRow role={createRole(1, name)} onSelectDelete={onSelectDelete} />)
await userEvent.click(screen.getByRole('button', { name: `${name} actions` }))
const deleteItem = await screen.findByRole('menuitem', { name: 'Delete' })
expect(deleteItem).toBeAriaDisabled()
fireEvent.click(deleteItem)
expect(onSelectDelete).not.toHaveBeenCalled()
await userEvent.hover(deleteItem)
expect(
await screen.findByRole('tooltip', {
name: `The ${name} role is a system role and cannot be deleted`,
})
).toBeInTheDocument()
}
)
test('keeps the postgres permission editor available', async () => {
customRender(<RoleRow role={createRole(1, 'postgres')} onSelectDelete={vi.fn()} />)
await userEvent.click(screen.getByRole('button', { name: /postgres \(ID: 1\)/ }))
const loginSwitch = await screen.findByRole('switch', { name: 'User can login' })
expect(loginSwitch).not.toBeDisabled()
await userEvent.click(loginSwitch)
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
test('allows selecting a custom role for deletion', async () => {
const onSelectDelete = vi.fn()
customRender(<RoleRow role={createRole(4, 'custom_role')} onSelectDelete={onSelectDelete} />)
await userEvent.click(screen.getByRole('button', { name: 'custom_role actions' }))
const deleteItem = await screen.findByRole('menuitem', { name: 'Delete' })
expect(deleteItem).not.toBeAriaDisabled()
await userEvent.click(deleteItem)
expect(onSelectDelete).toHaveBeenCalledWith('4')
})
test('keeps disabled roles read-only', async () => {
customRender(<RoleRow disabled role={createRole(4, 'custom_role')} onSelectDelete={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'custom_role actions' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /custom_role \(ID: 4\)/ }))
expect(await screen.findByRole('switch', { name: 'User can login' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
})
})
describe('RolesList', () => {
test('separates protected service roles from the project administrator and custom roles', async () => {
customRender(<RolesList />)
const serviceRoles = within(screen.getByRole('region', { name: 'Supabase service roles' }))
const projectRoles = within(screen.getByRole('region', { name: 'Project database roles' }))
expect(await serviceRoles.findByText('supabase_privileged_role')).toBeInTheDocument()
expect(serviceRoles.getByText('supabase_auth_admin')).toBeInTheDocument()
expect(serviceRoles.queryByText('postgres')).not.toBeInTheDocument()
expect(projectRoles.getByRole('button', { name: /postgres \(ID: 1\)/ })).toBeInTheDocument()
expect(projectRoles.getByRole('button', { name: /custom_role \(ID: 4\)/ })).toBeInTheDocument()
expect(
serviceRoles.queryByRole('button', { name: 'supabase_privileged_role actions' })
).not.toBeInTheDocument()
})
test('opens the deletion confirmation for a custom role', async () => {
customRender(<RolesList />)
await userEvent.click(await screen.findByRole('button', { name: 'custom_role actions' }))
await userEvent.click(await screen.findByRole('menuitem', { name: 'Delete' }))
expect(await screen.findByRole('dialog')).toHaveTextContent(
'Confirm to delete role "custom_role"'
)
})
})