diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx index 127157cd7a1..dbf645bfb8e 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx @@ -28,6 +28,7 @@ import { WarningIcon, } from 'ui' +import { ROLE_DESCRIPTIONS } from '../Roles.constants' import { useGetRolesManagementPermissions } from '../TeamSettings.utils' import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal' import { @@ -295,13 +296,17 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP className="text-sm hover:bg-selection cursor-pointer" disabled={disabled} > -
+
{role.name} - {disabledReason && ( - - {disabledReason} - - )} + + {[ + ROLE_DESCRIPTIONS[role.name] ?? + 'Permissions are based on the configured organization role.', + disabledReason, + ] + .filter(Boolean) + .join(' ')} +
) diff --git a/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx new file mode 100644 index 00000000000..c8c4ca7bbab --- /dev/null +++ b/apps/studio/tests/components/Organization/TeamSettings/UpdateRolesPanel.network.test.tsx @@ -0,0 +1,196 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { platformComponents as components } from 'api-types' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { UpdateRolesPanel } from '@/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel' +import type { OrganizationMember } from '@/data/organizations/organization-members-query' +import type { ProfileContextType } from '@/lib/profile' +import { createMockOrganizationResponse } from '@/tests/helpers' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +type OrganizationResponse = components['schemas']['OrganizationResponse'] +type OrganizationRoleResponse = components['schemas']['OrganizationRoleResponse'] +type OrganizationProjectsResponse = components['schemas']['OrganizationProjectsResponse'] +type ListEntitlementsResponse = components['schemas']['ListEntitlementsResponse'] +type AccessControlPermission = components['schemas']['AccessControlPermission'] + +const ORG_SLUG = 'test-org' + +const ROLE_IDS = { owner: 1, administrator: 2, developer: 3, readOnly: 4 } as const + +// Sheet + Select are built on Radix, which relies on the Web Animations API +mockAnimationsApi() +// Radix Select scrolls the active option into view when the dropdown opens +window.HTMLElement.prototype.scrollIntoView = vi.fn() + +vi.mock('common', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('common') + return { + ...actual, + useParams: () => ({ slug: ORG_SLUG }), + useIsLoggedIn: () => true, + } +}) + +vi.mock('@/lib/constants', async (importOriginal) => { + const actual = await importOriginal>() + return { ...actual, IS_PLATFORM: true } +}) + +const PROFILE_CONTEXT: ProfileContextType = { + profile: { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'testuser', + primary_email: 'me@example.com', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, + }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, +} + +const buildRole = (id: number, name: string): OrganizationRoleResponse['org_scoped_roles'][0] => ({ + id, + name, + base_role_id: id, + description: null, + projects: [], +}) + +const buildPermission = (resource: string): AccessControlPermission => ({ + actions: ['write:Create', 'write:Delete'], + condition: null, + organization_id: 1, + organization_slug: ORG_SLUG, + project_ids: [], + project_refs: [], + resources: [resource], + restrictive: false, +}) + +const MEMBER: OrganizationMember = { + gotrue_id: 'gotrue-member', + is_sso_user: false, + metadata: {}, + mfa_enabled: false, + primary_email: 'member@example.com', + role_ids: [ROLE_IDS.developer], + username: 'member', +} + +function setupMocks() { + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: () => + HttpResponse.json([ + createMockOrganizationResponse({ id: 1, slug: ORG_SLUG, name: 'Test Org' }), + ]), + }) + + addAPIMock({ + method: 'get', + path: '/platform/profile/permissions', + response: () => + HttpResponse.json([buildPermission('auth.subject_roles')]), + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/roles', + response: () => + HttpResponse.json({ + org_scoped_roles: [ + buildRole(ROLE_IDS.owner, 'Owner'), + buildRole(ROLE_IDS.administrator, 'Administrator'), + buildRole(ROLE_IDS.developer, 'Developer'), + buildRole(ROLE_IDS.readOnly, 'Read-only'), + ], + project_scoped_roles: [], + }), + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: () => + HttpResponse.json({ + pagination: { count: 0, limit: 96, offset: 0 }, + projects: [], + }), + }) + + // Empty entitlements => project-level permissions are off, so the panel + // renders the org-scoped role flow (a single Select). + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/entitlements', + response: () => HttpResponse.json({ entitlements: [] }), + }) +} + +describe('UpdateRolesPanel (network)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('shows each role with its permission description and a roles docs link', async () => { + setupMocks() + customRender(, { + profileContext: PROFILE_CONTEXT, + }) + + // The current (Developer) role is reflected in the trigger + const trigger = await screen.findByRole('combobox') + expect(trigger).toHaveTextContent('Developer') + + // Roles documentation is one click away (the ticket's other complaint). + // Asserted before opening the dropdown, which aria-hides the rest of the sheet. + const docsLink = screen.getByRole('link', { name: /docs/i }) + expect(docsLink).toHaveAttribute( + 'href', + expect.stringContaining('/guides/platform/access-control') + ) + + // Open the role dropdown to reveal all the options + await userEvent.click(trigger) + + // Every role is listed as an option + expect(await screen.findByRole('option', { name: /Owner/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Administrator/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Developer/ })).toBeInTheDocument() + 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() + }) + + test('renders a role description for every known role', async () => { + setupMocks() + customRender(, { + profileContext: PROFILE_CONTEXT, + }) + + await userEvent.click(await screen.findByRole('combobox')) + + await waitFor(() => { + expect(screen.getByText(/deleting the organization/i)).toBeInTheDocument() + }) + expect(screen.getByText(/Manage members, billing/i)).toBeInTheDocument() + expect(screen.getByText(/Manage project content/i)).toBeInTheDocument() + expect(screen.getByText(/limited to SELECT queries/i)).toBeInTheDocument() + }) +})