diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 46a484d7c1..e31c892027 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -45,6 +45,7 @@ it('mock-api is only referenced in test files', () => { "test/e2e/profile.e2e.ts", "test/e2e/project-access.e2e.ts", "test/e2e/silo-access.e2e.ts", + "test/e2e/system-access.e2e.ts", "tsconfig.json", ] `) diff --git a/app/api/roles.ts b/app/api/roles.ts index 9c3ff07da8..d17c1d7df9 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -40,25 +40,35 @@ export const roleOrder: Record = { /** `roleOrder` record converted to a sorted array of roles. */ export const allRoles = flatRoles(roleOrder) +// Fleet roles don't include limited_collaborator +export const fleetRoles = allRoles.filter( + (r): r is FleetRole => r !== 'limited_collaborator' +) + /** Given a list of roles, get the most permissive one */ -export const getEffectiveRole = (roles: RoleKey[]): RoleKey | undefined => +export const getEffectiveRole = (roles: Role[]): Role | undefined => R.firstBy(roles, (role) => roleOrder[role]) //////////////////////////// // Policy helpers //////////////////////////// -type RoleAssignment = { +type RoleAssignment = { identityId: string identityType: IdentityType - roleName: RoleKey + roleName: Role +} +export type Policy = { + roleAssignments: RoleAssignment[] } -export type Policy = { roleAssignments: RoleAssignment[] } /** * Returns a new updated policy. Does not modify the passed-in policy. */ -export function updateRole(newAssignment: RoleAssignment, policy: Policy): Policy { +export function updateRole( + newAssignment: RoleAssignment, + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== newAssignment.identityId ) @@ -70,18 +80,21 @@ export function updateRole(newAssignment: RoleAssignment, policy: Policy): Polic * Delete any role assignments for user or group ID. Returns a new updated * policy. Does not modify the passed-in policy. */ -export function deleteRole(identityId: string, policy: Policy): Policy { +export function deleteRole( + identityId: string, + policy: Policy +): Policy { const roleAssignments = policy.roleAssignments.filter( (ra) => ra.identityId !== identityId ) return { roleAssignments } } -type UserAccessRow = { +type UserAccessRow = { id: string identityType: IdentityType name: string - roleName: RoleKey + roleName: Role roleSource: string } @@ -92,10 +105,10 @@ type UserAccessRow = { * of an API request for the list of users. It's a bit awkward, but the logic is * identical between projects and orgs so it is worth sharing. */ -export function useUserRows( - roleAssignments: RoleAssignment[], +export function useUserRows( + roleAssignments: RoleAssignment[], roleSource: string -): UserAccessRow[] { +): UserAccessRow[] { // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows const { data: users } = usePrefetchedQuery(q(api.userList, {})) @@ -136,7 +149,9 @@ export type Actor = { * Fetch lists of users and groups, filtering out the ones that are already in * the given policy. */ -export function useActorsNotInPolicy(policy: Policy): Actor[] { +export function useActorsNotInPolicy( + policy: Policy +): Actor[] { const { data: users } = usePrefetchedQuery(q(api.userList, {})) const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 1987be8408..5adcb0f48d 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -10,7 +10,9 @@ import * as R from 'remeda' import { allRoles, + fleetRoles, type Actor, + type FleetRole, type IdentityType, type Policy, type RoleKey, @@ -50,6 +52,13 @@ const siloRoleDescriptions: Record = { viewer: 'View resources within the silo', } +// Role descriptions for fleet-level roles +const fleetRoleDescriptions: Record = { + admin: 'Control all aspects of the fleet', + collaborator: 'Administer silos and fleet-level resources', + viewer: 'View fleet-level resources', +} + export const actorToItem = (actor: Actor): ListboxItem => ({ value: actor.id, label: ( @@ -65,16 +74,16 @@ export const actorToItem = (actor: Actor): ListboxItem => ({ selectedLabel: actor.displayName, }) -export type AddRoleModalProps = { +export type AddRoleModalProps = { onDismiss: () => void - policy: Policy + policy: Policy } -export type EditRoleModalProps = AddRoleModalProps & { +export type EditRoleModalProps = AddRoleModalProps & { name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: RoleKey } + defaultValues: { roleName: Role } } const AccessDocs = () => ( @@ -92,9 +101,15 @@ export function RoleRadioField< }: { name: TName control: Control - scope: 'Silo' | 'Project' + scope: 'Fleet' | 'Silo' | 'Project' }) { - const roleDescriptions = scope === 'Silo' ? siloRoleDescriptions : projectRoleDescriptions + const roles = R.reverse(scope === 'Fleet' ? fleetRoles : allRoles) + const roleDescriptions: Partial> = + scope === 'Fleet' + ? fleetRoleDescriptions + : scope === 'Silo' + ? siloRoleDescriptions + : projectRoleDescriptions return ( <> - {R.reverse(allRoles).map((role) => ( + {roles.map((role) => (
{capitalize(role).replace('_', ' ')} @@ -117,7 +132,13 @@ export function RoleRadioField< + Fleet roles grant access to fleet-level resources and administration. To + maintain tenancy separation between silos, fleet roles do not cascade into + silos. Learn more in the guide. + + ) : scope === 'Silo' ? ( <> Silo roles are inherited by all projects in the silo and override weaker roles. For example, a silo viewer is at least a viewer on all diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 7ccaeab087..6bc711230b 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -49,7 +49,10 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr resourceName="role" title="Add user or group" submitLabel="Assign role" - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} onSubmit={({ identityId, roleName }) => { // TODO: DRY logic // actor is guaranteed to be in the list because it came from there @@ -109,7 +112,10 @@ export function SiloAccessEditUserSideModal({ }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} > diff --git a/app/forms/system-access.tsx b/app/forms/system-access.tsx new file mode 100644 index 0000000000..fd93378a63 --- /dev/null +++ b/app/forms/system-access.tsx @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' + +import { + api, + queryClient, + updateRole, + useActorsNotInPolicy, + useApiMutation, + type FleetRole, +} from '@oxide/api' +import { Access16Icon } from '@oxide/design-system/icons/react' + +import { ListboxField } from '~/components/form/fields/ListboxField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { docLinks } from '~/util/links' + +import { + actorToItem, + RoleRadioField, + type AddRoleModalProps, + type EditRoleModalProps, +} from './access-util' + +export function SystemAccessAddUserSideModal({ + onDismiss, + policy, +}: AddRoleModalProps) { + const actors = useActorsNotInPolicy(policy) + + const updatePolicy = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + onDismiss() + }, + }) + + const form = useForm<{ identityId: string; roleName: FleetRole }>({ + defaultValues: { identityId: '', roleName: 'viewer' }, + }) + + return ( + { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + onSubmit={({ identityId, roleName }) => { + // actor is guaranteed to be in the list because it came from there + const identityType = actors.find((a) => a.id === identityId)!.identityType + + updatePolicy.mutate({ + body: updateRole({ identityId, identityType, roleName }, policy), + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + > + + + + + ) +} + +export function SystemAccessEditUserSideModal({ + onDismiss, + name, + identityId, + identityType, + policy, + defaultValues, +}: EditRoleModalProps) { + const updatePolicy = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + onDismiss() + }, + }) + const form = useForm({ defaultValues }) + + return ( + + {name} + + } + onSubmit={({ roleName }) => { + updatePolicy.mutate({ + body: updateRole({ identityId, identityType, roleName }, policy), + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + > + + + + ) +} diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 1e3a10c4d0..f324dd363c 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -10,6 +10,7 @@ import { useLocation, useNavigate } from 'react-router' import { api, q, queryClient } from '@oxide/api' import { + Access16Icon, Cloud16Icon, IpGlobal16Icon, Metrics16Icon, @@ -55,6 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'System Update', path: pb.systemUpdate() }, + { value: 'System Access', path: pb.systemAccess() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -101,6 +103,9 @@ export default function SystemLayout() { System Update + + System Access + diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index eb65e95359..06d972b9d1 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -12,7 +12,6 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, queryClient, useApiMutation, @@ -30,7 +29,9 @@ import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' +import { useCurrentUser } from '~/hooks/use-current-user' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -74,7 +75,6 @@ type UserRow = { identityType: IdentityType name: string siloRole: RoleKey | undefined - effectiveRole: RoleKey } const colHelper = createColumnHelper() @@ -83,6 +83,7 @@ export default function SiloAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) + const { me } = useCurrentUser() const { data: siloPolicy } = usePrefetchedQuery(policyView) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') @@ -91,8 +92,6 @@ export default function SiloAccessPage() { .map(([userId, userAssignments]) => { const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const roles = siloRole ? [siloRole] : [] - const { name, identityType } = userAssignments[0] const row: UserRow = { @@ -100,8 +99,6 @@ export default function SiloAccessPage() { identityType, name, siloRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, } return row @@ -110,7 +107,10 @@ export default function SiloAccessPage() { }, [siloRows]) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) @@ -152,12 +152,14 @@ export default function SiloAccessPage() { the {row.siloRole} role for {row.name} ), + extraContent: + row.id === me.id ? 'This will remove your own silo access.' : undefined, }), disabled: !row.siloRole && "You don't have permission to delete this user", }, ]), ], - [siloPolicy, updatePolicy] + [siloPolicy, updatePolicy, me] ) const tableInstance = useReactTable({ @@ -181,13 +183,13 @@ export default function SiloAccessPage() { setAddModalOpen(true)}>Add user or group - {siloPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {siloPolicy && editingUserRow?.siloRole && ( + {editingUserRow?.siloRole && ( setEditingUserRow(null)} policy={siloPolicy} diff --git a/app/pages/system/SystemAccessPage.tsx b/app/pages/system/SystemAccessPage.tsx new file mode 100644 index 0000000000..15cf5b3948 --- /dev/null +++ b/app/pages/system/SystemAccessPage.tsx @@ -0,0 +1,203 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type FleetRole, + type IdentityType, +} from '@oxide/api' +import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { + SystemAccessAddUserSideModal, + SystemAccessEditUserSideModal, +} from '~/forms/system-access' +import { useCurrentUser } from '~/hooks/use-current-user' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { identityTypeLabel, roleColor } from '~/util/access' +import { groupBy } from '~/util/array' +import { docLinks } from '~/util/links' + +const EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No authorized users" + body="Give permission to view, edit, or administer this fleet" + buttonText="Add user or group" + onClick={onClick} + /> + +) + +const systemPolicyView = q(api.systemPolicyView, {}) +const userList = q(api.userList, {}) +const groupList = q(api.groupList, {}) + +export async function clientLoader() { + await Promise.all([ + queryClient.prefetchQuery(systemPolicyView), + // used to resolve user names + queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(groupList), + ]) + return null +} + +export const handle = { crumb: 'System Access' } + +type UserRow = { + id: string + identityType: IdentityType + name: string + fleetRole: FleetRole +} + +const colHelper = createColumnHelper() + +export default function SystemAccessPage() { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingUserRow, setEditingUserRow] = useState(null) + + const { me } = useCurrentUser() + const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') + + const rows = useMemo(() => { + return groupBy(fleetRows, (u) => u.id) + .map(([userId, userAssignments]) => { + const { name, identityType } = userAssignments[0] + // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) + // getEffectiveRole needed because API allows multiple fleet role assignments for the same user, even though that's probably rare + const fleetRole = getEffectiveRole(userAssignments.map((a) => a.roleName))! + + const row: UserRow = { + id: userId, + identityType, + name, + fleetRole, + } + + return row + }) + .sort(byGroupThenName) + }, [fleetRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('systemPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + colHelper.accessor('fleetRole', { + header: 'Role', + cell: (info) => { + const role = info.getValue() + return fleet.{role} + }, + }), + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + // we know policy is there, otherwise there's no row to display + body: deleteRole(row.id, fleetPolicy), + }), + label: ( + + the {row.fleetRole} role for {row.name} + + ), + extraContent: + row.id === me.id ? 'This will remove your own fleet access.' : undefined, + }), + }, + ]), + ], + [fleetPolicy, updatePolicy, me] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + }>System Access + } + summary="Roles determine who can view, edit, or administer this fleet." + links={[docLinks.keyConceptsIam, docLinks.access]} + /> + + + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={fleetPolicy} + /> + )} + {editingUserRow && ( + setEditingUserRow(null)} + policy={fleetPolicy} + name={editingUserRow.name} + identityId={editingUserRow.id} + identityType={editingUserRow.identityType} + defaultValues={{ roleName: editingUserRow.fleetRole }} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( + + )} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..4cb4b2ec28 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -234,6 +234,10 @@ export const routes = createRoutesFromElements( path="update" lazy={() => import('./pages/system/UpdatePage').then(convert)} /> + import('./pages/system/SystemAccessPage').then(convert)} + /> redirect(pb.projects())} element={null} /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 3dcb8ddac3..5c4ca9cff6 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -789,6 +789,12 @@ exports[`breadcrumbs 2`] = ` "path": "/settings/ssh-keys", }, ], + "systemAccess (/system/access)": [ + { + "label": "System Access", + "path": "/system/access", + }, + ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 368731c03d..e20bf6d1eb 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -102,6 +102,7 @@ test('path builder', () => { "sshKeyEdit": "/settings/ssh-keys/ss/edit", "sshKeys": "/settings/ssh-keys", "sshKeysNew": "/settings/ssh-keys-new", + "systemAccess": "/system/access", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6d55092139..a49f038c5c 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -110,6 +110,7 @@ export const pb = { siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, + systemAccess: () => '/system/access', systemUtilization: () => '/system/utilization', ipPools: () => '/system/networking/ip-pools', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 1ad7369446..3e0cb03f0d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -14,6 +14,7 @@ import { validate as isUuid, v4 as uuid } from 'uuid' import { diskCan, + fleetRoles, FLEET_ID, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, @@ -1875,10 +1876,9 @@ export const handlers = makeHandlers({ systemPolicyView({ cookies }) { requireFleetViewer(cookies) - const fleetRoles: FleetRole[] = ['admin', 'collaborator', 'viewer'] const role_assignments = db.roleAssignments .filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID) - .filter((r) => fleetRoles.includes(r.role_name as FleetRole)) + .filter((r) => fleetRoles.some((role) => role === r.role_name)) .map((r) => ({ identity_id: r.identity_id, identity_type: r.identity_type, @@ -2319,7 +2319,25 @@ export const handlers = makeHandlers({ supportBundleUpdate: NotImplemented, supportBundleView: NotImplemented, switchView: NotImplemented, - systemPolicyUpdate: NotImplemented, + systemPolicyUpdate({ body, cookies }) { + requireFleetAdmin(cookies) + + const newAssignments = body.role_assignments + .filter((r) => fleetRoles.some((role) => role === r.role_name)) + .map((r) => ({ + resource_type: 'fleet' as const, + resource_id: FLEET_ID, + ...R.pick(r, ['identity_id', 'identity_type', 'role_name']), + })) + + const unrelatedAssignments = db.roleAssignments.filter( + (r) => !(r.resource_type === 'fleet' && r.resource_id === FLEET_ID) + ) + + db.roleAssignments = [...unrelatedAssignments, ...newAssignments] + + return body + }, systemQuotasList: NotImplemented, systemTimeseriesSchemaList: NotImplemented, systemUpdateRepositoryView: NotImplemented, diff --git a/test/e2e/system-access.e2e.ts b/test/e2e/system-access.e2e.ts new file mode 100644 index 0000000000..3572354a21 --- /dev/null +++ b/test/e2e/system-access.e2e.ts @@ -0,0 +1,95 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { user3 } from '@oxide/api-mocks' + +import { expect, expectRowVisible, getPageAsUser, test } from './utils' + +test('Click through system access page', async ({ page }) => { + await page.goto('/system/access') + + const table = page.locator('role=table') + + // initial fleet role assignments: Hannah Arendt (admin), Jane Austen (viewer) + await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() + await expectRowVisible(table, { + Name: 'Hannah Arendt', + Type: 'User', + Role: 'fleet.admin', + }) + await expectRowVisible(table, { + Name: 'Jane Austen', + Type: 'User', + Role: 'fleet.viewer', + }) + await expect(page.getByRole('cell', { name: user3.display_name })).toBeHidden() + + // Add user 3 as collaborator + await page.click('role=button[name="Add user or group"]') + await expect(page.getByRole('heading', { name: /Add user or group/ })).toBeVisible() + + await page.click('role=button[name*="User or group"]') + // users already assigned should not be in the list + await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() + await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Hans Jonas' })).toBeVisible() + await expect(page.getByRole('option', { name: 'Simone de Beauvoir' })).toBeVisible() + + await page.click('role=option[name="Jacob Klein"]') + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.click('role=button[name="Assign role"]') + + // user 3 shows up in the table + await expectRowVisible(table, { + Name: 'Jacob Klein', + Type: 'User', + Role: 'fleet.collaborator', + }) + + // change user 3's role from collaborator to viewer + await page + .locator('role=row', { hasText: user3.display_name }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') + + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Update role"]') + + await expectRowVisible(table, { Name: user3.display_name, Role: 'fleet.viewer' }) + + // delete user 3 + const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) + await expect(user3Row).toBeVisible() + await user3Row.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(user3Row).toBeHidden() +}) + +test('Fleet viewer cannot modify system access', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Jane Austen') + await page.goto('/system/access') + + const table = page.locator('role=table') + await expect(page.getByRole('heading', { name: /System Access/ })).toBeVisible() + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'fleet.admin' }) + + // attempt to add a user — the submit should fail with 403 + await page.click('role=button[name="Add user or group"]') + await page.click('role=button[name*="User or group"]') + await page.click('role=option[name="Jacob Klein"]') + await page.click('role=button[name="Assign role"]') + await expect(page.getByText('Action not authorized')).toBeVisible() + + // dismiss the modal and confirm the table is unchanged + await page.click('role=button[name="Cancel"]') + await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() +})