mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 22:29:11 +03:00
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:
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"'
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user