diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index 273e7a7d3..3e99faca8 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -59,7 +59,6 @@ it('mock-api is only referenced in test files', () => { "test/e2e/ip-pool-silo-config.e2e.ts", "test/e2e/profile.e2e.ts", "test/e2e/project-access.e2e.ts", - "test/e2e/silo-access.e2e.ts", "tsconfig.json", ] `) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index e8b44c638..fa0efbe68 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -11,11 +11,13 @@ import { allRoles, byGroupThenName, deleteRole, + effectiveScopedRole, getEffectiveRole, roleOrder, updateRole, - userRoleFromPolicies, + userScopedRoleEntries, type Policy, + type ScopedRoleEntry, } from './roles' describe('getEffectiveRole', () => { @@ -75,74 +77,41 @@ describe('deleteRole', () => { }) }) -const user1 = { - id: 'user1', -} - -const groups = [{ id: 'group1' }, { id: 'group2' }] - -describe('getEffectiveRole', () => { - it('returns null when there are no policies', () => { - expect(userRoleFromPolicies(user1, groups, [])).toBe(null) +describe('effectiveScopedRole', () => { + const direct = { type: 'direct' } as const + const viaGroup = { type: 'group', group: { id: 'g', displayName: 'g' } } as const + const entry = ( + roleName: ScopedRoleEntry['roleName'], + scope: ScopedRoleEntry['scope'], + source: ScopedRoleEntry['source'] = direct + ): ScopedRoleEntry => ({ roleName, scope, source }) + + it('returns null when there are no entries', () => { + expect(effectiveScopedRole([])).toBeNull() }) - it('returns null when there are no roles', () => { - expect(userRoleFromPolicies(user1, groups, [{ roleAssignments: [] }])).toBe(null) - }) - - it('returns role if user matches directly', () => { + it('picks the strongest role regardless of scope', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'admin' }, - ], - }, - ]) - ).toEqual('admin') + effectiveScopedRole([entry('viewer', 'silo'), entry('admin', 'project')]) + ).toEqual({ role: 'admin', scope: 'project' }) }) - it('returns strongest role if both group and user match', () => { + it('gives ties to silo scope, since silo roles cascade into projects', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' }, - ], - }, - ]) - ).toEqual('collaborator') + effectiveScopedRole([entry('collaborator', 'project'), entry('collaborator', 'silo')]) + ).toEqual({ role: 'collaborator', scope: 'silo' }) }) - it('ignores groups and users that do not match', () => { + it('gives ties to silo even if permission comes via a group', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'other', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' }, - ], - }, - ]) - ).toEqual(null) + effectiveScopedRole([entry('admin', 'project'), entry('admin', 'silo', viaGroup)]) + ).toEqual({ role: 'admin', scope: 'silo' }) }) - it('resolves multiple policies', () => { + it('keeps project scope when the project role is strictly stronger', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, - ], - }, - { - roleAssignments: [ - { identityId: 'group1', identityType: 'silo_group', roleName: 'admin' }, - ], - }, - ]) - ).toEqual('admin') + effectiveScopedRole([entry('admin', 'project'), entry('viewer', 'silo')]) + ).toEqual({ role: 'admin', scope: 'project' }) }) }) @@ -156,6 +125,46 @@ test('byGroupThenName sorts as expected', () => { expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) }) +describe('userScopedRoleEntries', () => { + it('collapses multiple assignments for the same identity to the strongest role', () => { + // API permits multiple assignments for one identity in a single policy + const policy: Policy = { + roleAssignments: [ + { identityId: 'u', identityType: 'silo_user', roleName: 'viewer' }, + { identityId: 'u', identityType: 'silo_user', roleName: 'admin' }, + ], + } + expect(userScopedRoleEntries('u', [], [{ scope: 'silo', policy }])).toEqual([ + { roleName: 'admin', scope: 'silo', source: { type: 'direct' } }, + ]) + }) + + it('emits one entry per direct assignment and per group, tagged by scope', () => { + const group = { id: 'g', displayName: 'g' } + const silo: Policy = { + roleAssignments: [ + { identityId: 'g', identityType: 'silo_group', roleName: 'viewer' }, + ], + } + const project: Policy = { + roleAssignments: [{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' }], + } + expect( + userScopedRoleEntries( + 'u', + [group], + [ + { scope: 'silo', policy: silo }, + { scope: 'project', policy: project }, + ] + ) + ).toEqual([ + { roleName: 'viewer', scope: 'silo', source: { type: 'group', group } }, + { roleName: 'admin', scope: 'project', source: { type: 'direct' } }, + ]) + }) +}) + test('allRoles', () => { expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer']) }) diff --git a/app/api/roles.ts b/app/api/roles.ts index 70c12ecaa..83f6e240b 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -11,10 +11,19 @@ * layer and not in app/ because we are experimenting with it to decide whether * it belongs in the API proper. */ +import { useQueries } from '@tanstack/react-query' import { useMemo } from 'react' import * as R from 'remeda' -import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' +import { ALL_ISH } from '~/util/consts' + +import type { + FleetRole, + Group, + IdentityType, + ProjectRole, + SiloRole, +} from './__generated__/Api' import { api, q, usePrefetchedQuery } from './client' /** @@ -76,6 +85,13 @@ export function updateRole( return { roleAssignments } } +/** Map from identity ID to role name for quick lookup. */ +export function rolesByIdFromPolicy( + policy: Policy +): Map { + return new Map(policy.roleAssignments.map((a) => [a.identityId, a.roleName])) +} + /** * Delete any role assignments for user or group ID. Returns a new updated * policy. Does not modify the passed-in policy. @@ -90,47 +106,6 @@ export function deleteRole( return { roleAssignments } } -type UserAccessRow = { - id: string - identityType: IdentityType - name: string - roleName: Role - roleSource: string -} - -/** - * Role assignments come from the API in (user, role) pairs without display - * names and without info about which resource the role came from. This tags - * each row with that info. It has to be a hook because it depends on the result - * 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[], - roleSource: string -): 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, {})) - const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) - return useMemo(() => { - const userItems = users?.items || [] - const groupItems = groups?.items || [] - const usersDict = Object.fromEntries(userItems.concat(groupItems).map((u) => [u.id, u])) - return roleAssignments.map((ra) => ({ - id: ra.identityId, - identityType: ra.identityType, - // A user might not appear here if they are not in the current user's - // silo. This could happen in a fleet policy, which might have users from - // different silos. Hence the ID fallback. The code that displays this - // detects when we've fallen back and includes an explanatory tooltip. - name: usersDict[ra.identityId]?.displayName || ra.identityId, - roleName: ra.roleName, - roleSource, - })) - }, [roleAssignments, roleSource, users, groups]) -} - type SortableUserRow = { identityType: IdentityType; name: string } /** @@ -156,33 +131,120 @@ export type Actor = { export function useActorsNotInPolicy( policy: Policy ): Actor[] { - const { data: users } = usePrefetchedQuery(q(api.userList, {})) - const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) + const { data: users } = usePrefetchedQuery(q(api.userList, { query: { limit: ALL_ISH } })) + const { data: groups } = usePrefetchedQuery( + q(api.groupList, { query: { limit: ALL_ISH } }) + ) return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) - const allGroups = groups.items.map((g) => ({ - ...g, - identityType: 'silo_group' as IdentityType, - })) - const allUsers = users.items.map((u) => ({ - ...u, - identityType: 'silo_user' as IdentityType, - })) - // groups go before users - return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || [] + // groups first, then users; each sorted alphabetically by display name + const allGroups = R.sortBy( + groups.items.map((g) => ({ ...g, identityType: 'silo_group' as IdentityType })), + (g) => g.displayName.toLowerCase() + ) + const allUsers = R.sortBy( + users.items.map((u) => ({ ...u, identityType: 'silo_user' as IdentityType })), + (u) => u.displayName.toLowerCase() + ) + return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) }, [users, groups, policy]) } -export function userRoleFromPolicies( - user: { id: string }, - groups: { id: string }[], - policies: Policy[] -): RoleKey | null { - const myIds = new Set([user.id, ...groups.map((g) => g.id)]) - const myRoles = policies - .flatMap((p) => p.roleAssignments) // concat all the role assignments together - .filter((ra) => myIds.has(ra.identityId)) - .map((ra) => ra.roleName) - return getEffectiveRole(myRoles) || null +export type AccessScope = 'silo' | 'project' +export type ScopedPolicy = { scope: AccessScope; policy: Policy } + +export type ScopedRoleEntry = { + roleName: RoleKey + scope: AccessScope + source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } } +} + +/** Strongest role assigned to an identity in a policy, if any. */ +const roleForId = (policy: Policy, id: string) => + getEffectiveRole( + policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) + ) + +/** + * Enumerate all role assignments relevant to a user — one entry per direct + * assignment and one per group assignment — across the given policies. Each + * entry is tagged with the scope of the policy it came from. Since the API + * permits multiple assignments for the same identity in one policy, each entry + * collapses those to the strongest role (see `getEffectiveRole`). + * Callers are responsible for sorting and any display-layer merging. + */ +export function userScopedRoleEntries( + userId: string, + userGroups: { id: string; displayName: string }[], + scopedPolicies: ScopedPolicy[] +): ScopedRoleEntry[] { + const entries: ScopedRoleEntry[] = [] + for (const { scope, policy } of scopedPolicies) { + const direct = roleForId(policy, userId) + if (direct) { + entries.push({ roleName: direct, scope, source: { type: 'direct' } }) + } + for (const group of userGroups) { + const via = roleForId(policy, group.id) + if (via) { + entries.push({ roleName: via, scope, source: { type: 'group', group } }) + } + } + } + return entries +} + +/** + * Pick the strongest role across entries. Ties go to silo scope, since silo + * roles cascade into projects. + */ +export function effectiveScopedRole( + entries: ScopedRoleEntry[] +): { role: RoleKey; scope: AccessScope } | null { + if (entries.length === 0) return null + // strongest role overall + const strongest = R.firstBy(entries, (e) => roleOrder[e.roleName])! + const role = strongest.roleName + // prefer silo scope when silo has a role at least as strong + const siloDominates = entries.some( + (e) => e.scope === 'silo' && roleOrder[e.roleName] <= roleOrder[role] + ) + return { role, scope: siloDominates ? 'silo' : 'project' } +} + +/** + * Builds a map from user ID to the list of groups that user belongs to, + * firing one query per group to fetch members. Shared between user tabs. + * + * The returned Map is referentially stable between data updates, which keeps + * downstream useMemos (column definitions) from invalidating every render. + * `useQueries` returns a new array reference each render, so we can't put it in + * a useMemo deps array directly — instead we encode the relevant inputs (group + * IDs and per-query updated-at timestamps) into a single version string and + * memoize on that. + */ +export function useGroupsByUserId(groups: Group[]): Map { + const groupMemberQueries = useQueries({ + queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })), + }) + + const version = [ + groups.map((g) => g.id).join(','), + ...groupMemberQueries.map((query) => query.dataUpdatedAt), + ].join('|') + + return useMemo(() => { + const map = new Map() + groups.forEach((group, i) => { + const members = groupMemberQueries[i]?.data?.items ?? [] + members.forEach((member) => { + const existing = map.get(member.id) + if (existing) existing.push(group) + else map.set(member.id, [group]) + }) + }) + return map + // eslint-disable-next-line react-hooks/exhaustive-deps -- groups and queries are encoded in version + }, [version]) } diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 16d1b92c5..bb23274ca 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -102,6 +102,9 @@ type NavLinkProps = { disabled?: boolean // Only for cases where we want to spoof the path and pretend 'isActive' activePrefix?: string + // Override the computed active state. Needed when one nav item represents a + // section spanning multiple sibling paths (e.g. Users & Groups → /users, /groups) + isActive?: boolean } export const NavLinkItem = ({ @@ -110,12 +113,14 @@ export const NavLinkItem = ({ end, disabled, activePrefix, + isActive: isActiveOverride, }: NavLinkProps) => { // If the current page is the create form for this NavLinkItem's resource, highlight the NavLink in the sidebar const currentPathIsCreateForm = useLocation().pathname.startsWith(`${to}-new`) // We aren't using NavLink, as we need to occasionally use an activePrefix to create an active state for matching root paths // so we also recreate the isActive logic here - const isActive = useIsActivePath({ to: activePrefix || to, end }) + const computedActive = useIsActivePath({ to: activePrefix || to, end }) + const isActive = isActiveOverride ?? computedActive return (
  • () + +const GroupEmptyState = () => ( + + } + title="No groups" + body="No groups have been added to this silo" + /> + +) + +type EditingState = { group: Group; defaultRole: RoleKey | undefined } + +type Props = { + /** Policies that contribute to a group's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this tab — its direct roles are assignable/removable. */ + managedScope: AccessScope + /** Modal for assigning/editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise +} + +export function AccessGroupsTab({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, +}: Props) { + const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) + + const { data: groups } = usePrefetchedQuery(groupListAll) + const sortedGroups = useMemo( + () => R.sortBy(groups.items, (g) => g.displayName.toLowerCase()), + [groups] + ) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + + const roleCol = useMemo( + () => + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + // groups never inherit, so passing no groups yields direct roles only + const entries = userScopedRoleEntries(row.original.id, [], scopedPolicies) + const effective = effectiveScopedRole(entries) + if (!effective) return + return ( + + {effective.scope}.{effective.role} + + ) + }, + }), + [scopedPolicies] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + roleCol, + colHelper.display({ + id: 'memberCount', + header: 'Users', + cell: ({ row }) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [roleCol] + ) + + const makeActions = useCallback( + (group: Group): MenuAction[] => { + const directManagedRole = managedRoleById.get(group.id) + const entries = userScopedRoleEntries(group.id, [], scopedPolicies) + const effective = effectiveScopedRole(entries) + return buildRoleActions({ + name: group.displayName, + managedScope, + directManagedRole, + effective, + inheritedReason: 'Role is inherited from another scope; modify it there to revoke', + openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), + doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), + }) + }, + [managedRoleById, managedPolicy, updateManagedPolicy, scopedPolicies, managedScope] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const table = useReactTable({ + columns, + data: sortedGroups, + getRowId: (group) => group.id, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + {sortedGroups.length === 0 ? : } + {editingGroup && ( + setEditingGroup(null)} + policy={managedPolicy} + name={editingGroup.group.displayName} + identityId={editingGroup.group.id} + identityType="silo_group" + defaultValues={{ roleName: editingGroup.defaultRole }} + /> + )} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={scopedPolicies} + /> + )} + + ) +} diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx new file mode 100644 index 000000000..6069ff8b3 --- /dev/null +++ b/app/components/access/AccessRolesTable.tsx @@ -0,0 +1,307 @@ +/* + * 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, type ComponentType } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + roleOrder, + useGroupsByUserId, + usePrefetchedQuery, + type AccessScope, + type Group, + type IdentityType, + type Policy, + type RoleKey, + type ScopedPolicy, + type User, +} from '@oxide/api' +import { Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { type EditRoleModalProps } from '~/forms/access-util' +import { useCurrentUser } from '~/hooks/use-current-user' +import { confirmDelete } from '~/stores/confirm-delete' +import { ButtonCell } from '~/table/cells/LinkCell' +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 { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { identityTypeLabel, roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +import { GroupMembersSideModal } from './GroupMembersSideModal' +import { UserDetailsSideModal } from './UserDetailsSideModal' + +// full lists to resolve names and back the detail side modals; the API only +// sorts by id +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +type AccessRow = { + id: string + identityType: IdentityType + name: string + /** Direct role in the managed scope, if any (drives edit/remove). */ + managedRole: RoleKey | undefined + /** One badge per scope where the identity has a direct role, strongest first. */ + roleBadges: { scope: AccessScope; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +type Props = { + /** Policies that contribute to an identity's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this page — its direct roles are editable/removable. */ + managedScope: AccessScope + /** Modal for editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise + /** Open the add-user-or-group modal (from the empty state and header). */ + onAddClick: () => void +} + +/** + * Table of identities with a direct role in one of the given scopes. Clicking a + * name opens a read-only detail side modal; row actions edit or remove the role + * in the managed scope. Shared by the Silo Access and Project Access pages. + */ +export function AccessRolesTable({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, + onAddClick, +}: Props) { + const [editing, setEditing] = useState<{ + row: AccessRow + defaultRole: RoleKey | undefined + } | null>(null) + const [selectedUser, setSelectedUser] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) + + const { me } = useCurrentUser() + const { data: users } = usePrefetchedQuery(userListAll) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const groupsByUserId = useGroupsByUserId(groups.items) + const usersById = useMemo(() => new Map(users.items.map((u) => [u.id, u])), [users]) + const groupsById = useMemo(() => new Map(groups.items.map((g) => [g.id, g])), [groups]) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const rows = useMemo(() => { + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + const roleIn = (policy: Policy, id: string) => + getEffectiveRole( + policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) + ) + + // an identity appears if it has a direct role in any of the scoped policies + const identities = new Map() + for (const { policy } of scopedPolicies) { + for (const ra of policy.roleAssignments) + identities.set(ra.identityId, ra.identityType) + } + + return [...identities.entries()] + .map(([id, identityType]) => { + const roleBadges = scopedPolicies + .map(({ scope, policy }) => { + const roleName = roleIn(policy, id) + return roleName ? { scope, roleName } : undefined + }) + .filter((b) => !!b) + .sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) // strongest first + + return { + id, + identityType, + name: nameById.get(id) ?? id, + managedRole: roleIn(managedPolicy, id), + roleBadges, + } satisfies AccessRow + }) + .sort(byGroupThenName) + }, [scopedPolicies, managedPolicy, users, groups]) + + const multiScope = scopedPolicies.length > 1 + + const columns = useMemo( + () => [ + colHelper.accessor('name', { + header: 'Name', + cell: (info) => { + const row = info.row.original + const user = row.identityType === 'silo_user' ? usersById.get(row.id) : undefined + const group = + row.identityType === 'silo_group' ? groupsById.get(row.id) : undefined + if (user) { + return ( + setSelectedUser(user)}> + {info.getValue()} + + ) + } + if (group) { + return ( + setSelectedGroup(group)}> + {info.getValue()} + + ) + } + // identity isn't in this silo's user/group list (e.g. cross-silo), so + // there's no detail to show + return info.getValue() + }, + }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + colHelper.accessor('roleBadges', { + header: () => + multiScope ? ( + + Role + + A user or group's effective role is the strongest role across the silo + and this project + + + ) : ( + 'Role' + ), + cell: (info) => ( + + {info.getValue().map(({ scope, roleName }) => ( + + {scope}.{roleName} + + ))} + + ), + }), + getActionsCol((row: AccessRow) => { + // A row can appear here because of a role in another scope (silo roles + // show on the project page) without a direct role in the managed scope. + // There's nothing to change or remove here, but a managed-scope role can + // still be assigned. + if (!row.managedRole) { + return [ + { + label: managedScope === 'project' ? 'Assign project role' : 'Assign role', + onActivate: () => setEditing({ row, defaultRole: undefined }), + }, + { + label: 'Delete', + onActivate: () => {}, + disabled: 'Role is inherited from another scope; modify it there to revoke', + }, + ] + } + return [ + { + label: 'Change role', + onActivate: () => setEditing({ row, defaultRole: row.managedRole }), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), + label: ( + + the {row.managedRole} role for {row.name} + + ), + resourceKind: 'role assignment', + extraContent: + row.id === me.id + ? `This will remove your own ${managedScope} access.` + : undefined, + }), + }, + ] + }), + ], + [ + managedPolicy, + managedScope, + updateManagedPolicy, + me, + usersById, + groupsById, + multiScope, + ] + ) + + const table = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + Add user or group + + {editing && ( + setEditing(null)} + policy={managedPolicy} + name={editing.row.name} + identityId={editing.row.id} + identityType={editing.row.identityType} + defaultValues={{ roleName: editing.defaultRole }} + /> + )} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={scopedPolicies} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={scopedPolicies} + /> + )} + {rows.length === 0 ? ( + + } + title="No authorized users" + body={`Give permission to view, edit, or administer this ${managedScope}`} + buttonText="Add user or group" + onClick={onAddClick} + /> + + ) : ( +
    + )} + + ) +} diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx new file mode 100644 index 000000000..3883e0ecb --- /dev/null +++ b/app/components/access/AccessUsersTab.tsx @@ -0,0 +1,256 @@ +/* + * 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 { useCallback, useMemo, useState, type ComponentType } from 'react' +import * as R from 'remeda' + +import { + api, + deleteRole, + effectiveScopedRole, + q, + roleOrder, + rolesByIdFromPolicy, + useGroupsByUserId, + usePrefetchedQuery, + userScopedRoleEntries, + type AccessScope, + type Policy, + type RoleKey, + type ScopedPolicy, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ListPlusCell } from '~/components/ListPlusCell' +import { type EditRoleModalProps } from '~/forms/access-util' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +import { buildRoleActions } from './roleActions' +import { UserDetailsSideModal } from './UserDetailsSideModal' + +// The API only sorts users by id, so fetch the full set and sort by name +// client-side. ALL_ISH is the practical ceiling; a silo with more users than +// that would have its tail dropped in (arbitrary) id order. +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +const colHelper = createColumnHelper() + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + + } + title="No users" + body="No users have been added to this silo" + /> + +) + +type EditingState = { user: User; defaultRole: RoleKey | undefined } + +type Props = { + /** Policies that contribute to a user's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this tab — its direct roles are assignable/removable. */ + managedScope: AccessScope + /** Modal for assigning/editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise +} + +export function AccessUsersTab({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, +}: Props) { + const [selectedUser, setSelectedUser] = useState(null) + const [editingUser, setEditingUser] = useState(null) + + const { data: users } = usePrefetchedQuery(userListAll) + const sortedUsers = useMemo( + () => R.sortBy(users.items, (u) => u.displayName.toLowerCase()), + [users] + ) + + const { data: groups } = usePrefetchedQuery(groupListAll) + const groupsByUserId = useGroupsByUserId(groups.items) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + + const roleCol = useMemo( + () => + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + const entries = userScopedRoleEntries(row.original.id, userGroups, scopedPolicies) + const effective = effectiveScopedRole(entries) + if (!effective) return + // show "via groups" tooltip when the displayed role+scope isn't + // covered by a direct assignment in that scope. (a direct project + // role doesn't suppress the tooltip if the displayed badge is the + // silo scope coming via a group, since silo wins ties.) + const displayedScopeHasDirect = entries.some( + (e) => + e.source.type === 'direct' && + e.scope === effective.scope && + roleOrder[e.roleName] <= roleOrder[effective.role] + ) + const viaGroupsMap = new Map() + if (!displayedScopeHasDirect) { + for (const e of entries) { + if ( + e.source.type === 'group' && + e.scope === effective.scope && + roleOrder[e.roleName] <= roleOrder[effective.role] + ) { + viaGroupsMap.set(e.source.group.id, e.source.group) + } + } + } + const viaGroups = [...viaGroupsMap.values()] + return ( +
    + + {effective.scope}.{effective.role} + + {viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} +
    + ) + }, + }), + [groupsByUserId, scopedPolicies] + ) + + const groupsCol = useMemo( + () => + colHelper.display({ + id: 'groups', + header: 'Groups', + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + return ( + + {userGroups.map((g) => ( + {g.displayName} + ))} + + ) + }, + }), + [groupsByUserId] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + roleCol, + groupsCol, + timeCreatedCol, + ], + [roleCol, groupsCol] + ) + + const makeActions = useCallback( + (user: User): MenuAction[] => { + const directManagedRole = managedRoleById.get(user.id) + const userGroups = groupsByUserId.get(user.id) ?? [] + const entries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) + const effective = effectiveScopedRole(entries) + const inheritedReason = `Role is inherited; modify the source ${ + entries.find((e) => e.source.type === 'group') ? 'group' : 'silo assignment' + } to revoke` + return buildRoleActions({ + name: user.displayName, + managedScope, + directManagedRole, + effective, + inheritedReason, + openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), + doRemove: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), + }) + }, + [ + managedRoleById, + managedPolicy, + updateManagedPolicy, + groupsByUserId, + scopedPolicies, + managedScope, + ] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const table = useReactTable({ + columns, + data: sortedUsers, + getRowId: (user) => user.id, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + {sortedUsers.length === 0 ? :
    } + {editingUser && ( + setEditingUser(null)} + policy={managedPolicy} + name={editingUser.user.displayName} + identityId={editingUser.user.id} + identityType="silo_user" + defaultValues={{ roleName: editingUser.defaultRole }} + /> + )} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={scopedPolicies} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + + ) +} diff --git a/app/components/access/DetailTables.tsx b/app/components/access/DetailTables.tsx new file mode 100644 index 000000000..ee3d42ea8 --- /dev/null +++ b/app/components/access/DetailTables.tsx @@ -0,0 +1,100 @@ +/* + * 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 * as R from 'remeda' + +import { roleOrder, type ScopedRoleEntry } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { RowActions } from '~/table/columns/action-col' +import { Table } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' + +/** + * Role/Source table shared by the user and group detail side modals. Entries + * come from `userScopedRoleEntries` (groups pass an empty groups list, so their + * entries are all direct). Sorted strongest-first. + */ +export function RoleAssignmentsTable({ entries }: { entries: ScopedRoleEntry[] }) { + const sorted = R.sortBy(entries, (e) => roleOrder[e.roleName]) + return ( +
    + + + Role + Source + + + + {sorted.length === 0 ? ( + + + No roles assigned + + + ) : ( + sorted.map(({ roleName, scope, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' && 'Assigned'} + {source.type === 'group' && `via ${source.group.displayName}`} + + + )) + )} + +
    + ) +} + +/** + * List of related identities (a group's members, or a user's groups) with a + * Copy ID row action. Shared by the user and group detail side modals. + */ +export function IdentityListTable({ + label, + items, + emptyText, +}: { + label: string + items: { id: string; displayName: string }[] + emptyText: string +}) { + return ( + + + + {label} + + + + + {items.length === 0 ? ( + + + {emptyText} + + + ) : ( + items.map((item) => ( + + {item.displayName} + + + + + )) + )} + +
    + ) +} diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx new file mode 100644 index 000000000..7db1c2d17 --- /dev/null +++ b/app/components/access/GroupMembersSideModal.tsx @@ -0,0 +1,60 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' + +import { api, q, userScopedRoleEntries, type Group, type ScopedPolicy } from '@oxide/api' +import { PersonGroup16Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { ALL_ISH } from '~/util/consts' + +import { IdentityListTable, RoleAssignmentsTable } from './DetailTables' + +type Props = { + group: Group + onDismiss: () => void + scopedPolicies: ScopedPolicy[] +} + +export function GroupMembersSideModal({ group, onDismiss, scopedPolicies }: Props) { + const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) + const members = data?.items ?? [] + + // groups never inherit, so passing no groups yields the group's direct roles + const roleEntries = userScopedRoleEntries(group.id, [], scopedPolicies) + + return ( + + {group.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + +
    + +
    +
    + +
    +
    + ) +} diff --git a/app/components/access/UserDetailsSideModal.tsx b/app/components/access/UserDetailsSideModal.tsx new file mode 100644 index 000000000..72a5738b5 --- /dev/null +++ b/app/components/access/UserDetailsSideModal.tsx @@ -0,0 +1,59 @@ +/* + * 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 { userScopedRoleEntries, type Group, type ScopedPolicy, type User } from '@oxide/api' +import { Person16Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' + +import { IdentityListTable, RoleAssignmentsTable } from './DetailTables' + +type Props = { + user: User + onDismiss: () => void + userGroups: Group[] + scopedPolicies: ScopedPolicy[] +} + +export function UserDetailsSideModal({ + user, + onDismiss, + userGroups, + scopedPolicies, +}: Props) { + const roleEntries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) + + return ( + + {user.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + +
    + +
    +
    + +
    +
    + ) +} diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx new file mode 100644 index 000000000..d587f5a61 --- /dev/null +++ b/app/components/access/roleActions.tsx @@ -0,0 +1,92 @@ +/* + * 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 { type AccessScope, type RoleKey } from '@oxide/api' + +import { HL } from '~/components/HL' +import { confirmDelete } from '~/stores/confirm-delete' +import { type MenuAction } from '~/table/columns/action-col' + +/** Verb labels for the row actions, scoped so the project tab reads "project role". */ +function roleActionLabels(managedScope: AccessScope) { + const isProject = managedScope === 'project' + return { + assign: isProject ? 'Assign project role' : 'Assign role', + change: isProject ? 'Change project role' : 'Change role', + remove: isProject ? 'Remove project role' : 'Remove role', + } +} + +type BuildRoleActionsArgs = { + /** Display name of the user or group, used in the confirm-delete copy. */ + name: string + /** Scope managed by this tab; determines labels and project-specific framing. */ + managedScope: AccessScope + /** Direct role on the managed policy, if any. Required to remove a role. */ + directManagedRole: RoleKey | undefined + /** Effective role across all scopes, or null if the identity has no role. */ + effective: { role: RoleKey } | null + /** Disabled reason shown when there's no direct managed role to remove. */ + inheritedReason: string + /** Open the edit modal, pre-filled with the given role (undefined = assign). */ + openEditModal: (defaultRole: RoleKey | undefined) => void + /** Remove the direct managed role. */ + doRemove: () => Promise +} + +/** + * Row-action menu for a user or group in the access tabs. Identical logic for + * both; callers supply how the effective role was computed and the + * inherited-role message (which differs because a user can inherit via a group + * or the silo, while a group only inherits from another scope). + */ +export function buildRoleActions({ + name, + managedScope, + directManagedRole, + effective, + inheritedReason, + openEditModal, + doRemove, +}: BuildRoleActionsArgs): MenuAction[] { + const labels = roleActionLabels(managedScope) + const removeAction: MenuAction = { + label: directManagedRole ? labels.remove : 'Remove role', + onActivate: confirmDelete({ + doDelete: doRemove, + label: ( + + the {directManagedRole} role for {name} + + ), + resourceKind: 'role assignment', + }), + // a direct role on the managed policy is required to remove anything + disabled: !directManagedRole && inheritedReason, + } + // No role at all — direct or inherited. + if (!effective) { + return [{ label: labels.assign, onActivate: () => openEditModal(undefined) }] + } + // For the project tab, an inherited silo role doesn't give us anything to + // "change" on the project policy — frame it as assigning a project role. For + // the silo tab, an inherited (via group) role can be promoted to a direct + // silo assignment via "Change role" pre-filled with the effective role. + if (managedScope === 'project' && !directManagedRole) { + return [ + { label: labels.assign, onActivate: () => openEditModal(undefined) }, + removeAction, + ] + } + // Pre-fill with the direct managed role if any; otherwise the effective role + // so the modal opens in 'edit' mode showing the role currently in effect. + const defaultRole = directManagedRole ?? effective.role + return [ + { label: labels.change, onActivate: () => openEditModal(defaultRole) }, + removeAction, + ] +} diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index de4a93b94..0f0c91f43 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -83,7 +83,7 @@ export type EditRoleModalProps = AddRoleModalPro name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: Role } + defaultValues: { roleName?: Role } } const AccessDocs = () => ( diff --git a/app/forms/fleet-access.tsx b/app/forms/fleet-access.tsx index 018097f55..ce2d91488 100644 --- a/app/forms/fleet-access.tsx +++ b/app/forms/fleet-access.tsx @@ -110,6 +110,7 @@ export function FleetAccessEditUserSideModal({ } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), }) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 15566bc56..9ce524754 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -40,7 +40,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') - // We don't have the name of the user or group, so we'll just have a generic message + // We don't have the name of the user or group, so use a generic message addToast({ content: 'Role assigned' }) onDismiss() }, @@ -66,7 +66,10 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} > { queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Role updated' }) onDismiss() }, }) @@ -104,15 +107,16 @@ export function ProjectAccessEditUserSideModal({ return ( {name} } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ path: { project }, body: updateRole({ identityId, identityType, roleName }, policy), @@ -120,7 +124,10 @@ export function ProjectAccessEditUserSideModal({ }} 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/silo-access.tsx b/app/forms/silo-access.tsx index 6bc711230..e0c786534 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -54,7 +54,6 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr onDismiss() }} onSubmit={({ identityId, roleName }) => { - // TODO: DRY logic // actor is guaranteed to be in the list because it came from there const identityType = actors.find((a) => a.id === identityId)!.identityType @@ -86,6 +85,7 @@ export function SiloAccessEditUserSideModal({ policy, defaultValues, }: EditRoleModalProps) { + const isAssigning = !defaultValues.roleName const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -97,15 +97,16 @@ export function SiloAccessEditUserSideModal({ return ( {name} } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), }) diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 13d1e49dc..faacb4419 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,6 +12,7 @@ import { Folder16Icon, Images16Icon, Metrics16Icon, + PersonGroup16Icon, } from '@oxide/design-system/icons/react' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' @@ -34,6 +35,7 @@ export default function SiloLayout() { { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, { value: 'Silo Access', path: pb.siloAccess() }, + { value: 'Users & Groups', path: pb.siloUsers() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -45,6 +47,11 @@ export default function SiloLayout() { [pathname, me.siloName] ) + // Users & Groups spans two sibling routes, so highlight the nav item on both + const inUsersGroups = [pb.siloUsers(), pb.siloGroups()].some( + (p) => pathname === p || pathname.startsWith(`${p}/`) + ) + return ( @@ -63,9 +70,12 @@ export default function SiloLayout() { Utilization - + Silo Access + + Users & Groups + diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 66a7f3aab..670e8e2e5 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,165 +5,68 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' import { api, - byGroupThenName, - deleteRole, q, queryClient, useApiMutation, usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, + type ScopedPolicy, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' +import { AccessRolesTable } from '~/components/access/AccessRolesTable' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' -import { useCurrentUser } from '~/hooks/use-current-user' +import { makeCrumb } from '~/hooks/use-crumbs' import { useQuickActions } from '~/hooks/use-quick-actions' -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 { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this silo" - buttonText="Add user or group" - onClick={onClick} - /> - -) +import { pb } from '~/util/path-builder' const policyView = q(api.policyView, {}) -const userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupList) + // Fire per-group member prefetches but don't await them: they back the user + // details modal's group list and fill in as they resolve. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) await Promise.all([ queryClient.prefetchQuery(policyView), - // used to resolve user names queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), ]) return null } -export const handle = { crumb: 'Silo Access' } - -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined -} - -const colHelper = createColumnHelper() +export const handle = makeCrumb('Silo Access', pb.siloAccess()) 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') - const rows = useMemo(() => { - return groupBy(siloRows, (u) => u.id) - .map(([userId, userAssignments]) => { - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - - const { name, identityType } = userAssignments[0] - - const row: UserRow = { - id: userId, - identityType, - name, - siloRole, - } - - return row - }) - .sort(byGroupThenName) - }, [siloRows]) + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') addToast({ content: 'Access removed' }) }, - // TODO: handle 403 - }) - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('siloRole', { - header: 'Role', - cell: (info) => { - const role = info.getValue() - return role ? silo.{role} : null - }, - }), - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: !row.siloRole && "You don't have permission to change this user's role", - }, - // TODO: only show if you have permission to do this - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - resourceKind: 'role assignment', - 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, me] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), }) useQuickActions( @@ -185,34 +88,23 @@ export default function SiloAccessPage() { heading="access" icon={} summary="Roles determine who can view, edit, or administer this silo and the projects within it. If a user or group has both a silo and project role, the stronger role takes precedence." - links={[docLinks.keyConceptsIam, docLinks.access]} + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - setAddModalOpen(true)}>Add user or group - {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {editingUserRow?.siloRole && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( - - )} + updatePolicy({ body })} + onAddClick={() => setAddModalOpen(true)} + /> ) } diff --git a/app/pages/SiloGroupsTab.tsx b/app/pages/SiloGroupsTab.tsx new file mode 100644 index 000000000..215ed7f38 --- /dev/null +++ b/app/pages/SiloGroupsTab.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useMemo } from 'react' + +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Policy, + type ScopedPolicy, +} from '@oxide/api' + +import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { addToast } from '~/stores/toast' + +const policyView = q(api.policyView, {}) + +export const handle = titleCrumb('Groups') + +export default function SiloGroupsTab() { + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) + + return ( + updatePolicy({ body })} + /> + ) +} diff --git a/app/pages/SiloUsersGroupsPage.tsx b/app/pages/SiloUsersGroupsPage.tsx new file mode 100644 index 000000000..6df5614f7 --- /dev/null +++ b/app/pages/SiloUsersGroupsPage.tsx @@ -0,0 +1,65 @@ +/* + * 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 { api, q, queryClient } from '@oxide/api' +import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { makeCrumb } from '~/hooks/use-crumbs' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +// Parent prefetches everything both tabs need so switching between Users and +// Groups doesn't trigger a fetch. This loader runs once on entry; react-router +// won't re-run it when navigating between sibling tab routes. Both tabs fetch +// the full user/group lists so they can be sorted by name client-side (the API +// only sorts by id). +const policyView = q(api.policyView, {}) +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupListAll) + // Fire per-group member prefetches but don't await them: the tabs read these + // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the + // per-user group lists fill in as they resolve instead of blocking the page + // on one request per group. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(userListAll), + ]) + return null +} + +export const handle = makeCrumb('Users & Groups', pb.siloUsers()) + +export default function SiloUsersGroupsPage() { + return ( + <> + + }>Users & Groups + } + summary="Users and groups come from the silo's identity provider. Assign silo and project roles to control what they can view, edit, or administer." + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} + /> + + + Users + Groups + + + ) +} diff --git a/app/pages/SiloUsersTab.tsx b/app/pages/SiloUsersTab.tsx new file mode 100644 index 000000000..00c0de4c9 --- /dev/null +++ b/app/pages/SiloUsersTab.tsx @@ -0,0 +1,52 @@ +/* + * 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 { useMemo } from 'react' + +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Policy, + type ScopedPolicy, +} from '@oxide/api' + +import { AccessUsersTab } from '~/components/access/AccessUsersTab' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { addToast } from '~/stores/toast' + +const policyView = q(api.policyView, {}) + +export const handle = titleCrumb('Users') + +export default function SiloUsersTab() { + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) + + return ( + updatePolicy({ body })} + /> + ) +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index bcf10c78b..c5780828d 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,206 +5,80 @@ * * Copyright Oxide Computer Company */ - -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' -import * as R from 'remeda' import { api, - byGroupThenName, - deleteRole, q, queryClient, - roleOrder, useApiMutation, usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, + type Policy, + type ScopedPolicy, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' +import { AccessRolesTable } from '~/components/access/AccessRolesTable' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' -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 { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' +import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import type * as PP from '~/util/path-params' const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) -const userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this project" - buttonText="Add user or group to project" - onClick={onClick} - /> - -) +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupListAll) + // Fire per-group member prefetches but don't await them: they back the user + // details modal's group list and fill in as they resolve. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(projectPolicyView(selector)), - // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(userListAll), ]) return null } export const handle = { crumb: 'Project Access' } -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() - export default function ProjectAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) const projectSelector = useProjectSelector() const { data: siloPolicy } = usePrefetchedQuery(policyView) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - - const rows = useMemo(() => { - return groupBy(siloRows.concat(projectRows), (u) => u.id) - .map(([userId, userAssignments]) => { - const { name, identityType } = userAssignments[0] - - const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') - const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') - const roleBadges = R.sortBy( - [siloAccessRow, projectAccessRow].filter((r) => !!r), - (r) => roleOrder[r.roleName] // sorts strongest role first - ) - - return { - id: userId, - identityType, - name, - projectRole: projectAccessRow?.roleName, - roleBadges, - } satisfies UserRow - }) - .sort(byGroupThenName) - }, [siloRows, projectRows]) + const scopedPolicies = useMemo( + () => + [ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ] satisfies ScopedPolicy[], + [siloPolicy, projectPolicy] + ) const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') addToast({ content: 'Access removed' }) }, - // TODO: handle 403 - }) - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('roleBadges', { - header: () => ( - - Role - - A user or group's effective role for this project is the strongest role - on either the silo or project - - - ), - cell: (info) => ( - - {info.getValue().map(({ roleName, roleSource }) => ( - - {roleSource}.{roleName} - - ))} - - ), - }), - - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.projectRole && "You don't have permission to change this user's role", - }, - // TODO: only show if you have permission to do this - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - path: { project: projectSelector.project }, - body: deleteRole(row.id, projectPolicy), - }), - // TODO: explain that this will not affect the role inherited from - // the silo or roles inherited from group membership. Ideally we'd - // be able to say: this will cause the user to have an effective - // role of X. However we would have to look at their groups too. - label: ( - - the {row.projectRole} role for {row.name} - - ), - resourceKind: 'role assignment', - }), - disabled: !row.projectRole && "You don't have permission to delete this user", - }, - ]), - ], - [projectPolicy, projectSelector.project, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), }) useQuickActions( @@ -226,34 +100,25 @@ export default function ProjectAccessPage() { heading="access" icon={} summary="Roles determine who can view, edit, or administer this project. Silo roles are inherited from the silo. If a user or group has both a silo and project role, the stronger role takes precedence." - links={[docLinks.keyConceptsIam, docLinks.access]} + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - setAddModalOpen(true)}>Add user or group - - {projectPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {projectPolicy && editingUserRow?.projectRole && ( - setEditingUserRow(null)} - policy={projectPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.projectRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( -
    - )} + + updatePolicy({ path: { project: projectSelector.project }, body }) + } + onAddClick={() => setAddModalOpen(true)} + /> ) } diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index e9dc2fc5a..e29e7dbbc 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -19,7 +19,6 @@ import { queryClient, useApiMutation, usePrefetchedQuery, - useUserRows, type FleetRole, type IdentityType, } from '@oxide/api' @@ -64,8 +63,8 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) const systemPolicyView = q(api.systemPolicyView, {}) -const userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) const siloList = q(api.siloList, { query: { limit: ALL_ISH } }) export async function clientLoader() { @@ -107,17 +106,34 @@ export default function FleetAccessPage() { const navigate = useNavigate() const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) const { data: silos } = usePrefetchedQuery(siloList) - const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') const rows: AccessRow[] = useMemo(() => { - const assignmentRows: AssignmentRow[] = 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) + // A user might not appear here if they're not in the current user's silo + // (can happen for cross-silo fleet assignments), so fall back to the raw ID. + // The name column detects the fallback and shows an explanatory tooltip. + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + + const assignmentRows: AssignmentRow[] = groupBy( + fleetPolicy.roleAssignments, + (ra) => ra.identityId + ) + .map(([userId, assignments]) => { + const { identityType } = assignments[0] + // non-null: assignments 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))! - return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } + const fleetRole = getEffectiveRole(assignments.map((a) => a.roleName))! + return { + kind: 'assignment' as const, + id: userId, + identityType, + name: nameById.get(userId) ?? userId, + fleetRole, + } }) .sort(byGroupThenName) @@ -135,7 +151,7 @@ export default function FleetAccessPage() { ) return [...assignmentRows, ...mappingRows] - }, [fleetRows, silos]) + }, [fleetPolicy, users, groups, silos]) const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..cf1cac106 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -318,6 +318,11 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)} /> + {/* Users and Groups are sibling routes sharing one "Users & Groups" page */} + import('./pages/SiloUsersGroupsPage').then(convert)}> + import('./pages/SiloUsersTab').then(convert)} /> + import('./pages/SiloGroupsTab').then(convert)} /> + {/* PROJECT */} diff --git a/app/table/cells/MemberCountCell.tsx b/app/table/cells/MemberCountCell.tsx new file mode 100644 index 000000000..e91e58900 --- /dev/null +++ b/app/table/cells/MemberCountCell.tsx @@ -0,0 +1,16 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' + +import { api, q } from '~/api' +import { ALL_ISH } from '~/util/consts' + +export function MemberCountCell({ groupId }: { groupId: string }) { + const { data } = useQuery(q(api.userList, { query: { group: groupId, limit: ALL_ISH } })) + return data ? <>{data.items.length} : null +} diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 6e9b086f4..f8e352089 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -14,6 +14,7 @@ import { Link } from 'react-router' import { OpenLink12Icon } from '@oxide/design-system/icons/react' import { Wrap } from '../util/wrap' +import { useIsInModal, useIsInSideModal } from './modal-context' import { Tooltip } from './Tooltip' // Re-export Root with modal={false} default to prevent scroll locking @@ -62,6 +63,7 @@ type ContentProps = { anchor?: AnchorProp /** Spacing in px between trigger and menu */ gap?: 8 + /** Overrides the default, which is derived from modal context */ zIndex?: ZIndex collisionPadding?: React.ComponentProps['collisionPadding'] } @@ -71,14 +73,21 @@ export function Content({ children, anchor = 'bottom end', gap, - zIndex = 'dropdown', + zIndex, collisionPadding, }: ContentProps) { const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap) + const isInModal = useIsInModal() + const isInSideModal = useIsInSideModal() + const contextZIndex: ZIndex = isInModal + ? 'modal' + : isInSideModal + ? 'sideModal' + : 'dropdown' return ( { "silo": "/system/silos/s/idps", "siloAccess": "/access", "siloFleetRoles": "/system/silos/s/fleet-roles", + "siloGroups": "/groups", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", "siloImage": "/images/im", @@ -98,6 +99,7 @@ test('path builder', () => { "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", "siloSubnetPools": "/system/silos/s/subnet-pools", + "siloUsers": "/users", "siloUtilization": "/utilization", "silos": "/system/silos", "silosNew": "/system/silos-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index e09ad45aa..b9e660719 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -111,6 +111,8 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', + siloUsers: () => '/users', + siloGroups: () => '/groups', siloImages: () => '/images', siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`, diff --git a/mock-api/user-group.ts b/mock-api/user-group.ts index e3ad27dec..2dd6353b5 100644 --- a/mock-api/user-group.ts +++ b/mock-api/user-group.ts @@ -35,7 +35,8 @@ export const userGroup3: Json = { time_modified: new Date(2021, 3, 1).toISOString(), } -export const userGroups = [userGroup1, userGroup2, userGroup3] +// ordered by display_name +export const userGroups = [userGroup3, userGroup2, userGroup1] type GroupMembership = { userId: string @@ -55,4 +56,8 @@ export const groupMemberships: GroupMembership[] = [ userId: user5.id, groupId: userGroup3.id, }, + { + userId: user1.id, + groupId: userGroup2.id, + }, ] diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index d4f34d8ce..e80ff7472 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -7,20 +7,18 @@ */ import { user3, user4 } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, test } from './utils' -test('Click through project access page', async ({ page }) => { +test('Project access shows and edits project role assignments', async ({ page }) => { await page.goto('/projects/mock-project') - await page.click('role=link[name*="Access"]') - - // we see groups and users 1, 3, 6 but not users 2, 4, 5 - await expectVisible(page, ['role=heading[name*="Access"]']) - const table = page.locator('table') - await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', - }) + await page.getByRole('link', { name: 'Project Access' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access$/) + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() + + // identities with a direct silo or project role appear; the effective role is + // the strongest across silo and project + const table = page.getByRole('table') + await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', Role: 'silo.admin' }) await expectRowVisible(table, { Name: 'Jacob Klein', Type: 'User', @@ -42,77 +40,113 @@ test('Click through project access page', async ({ page }) => { Role: 'project.viewer', }) - await expectNotVisible(page, [ - `role=cell[name="Hans Jonas"]`, - `role=cell[name="Simone de Beauvoir"]`, - ]) - - // Add user 4 as collab - await page.click('role=button[name="Add user or group"]') - await expectVisible(page, ['role=heading[name*="Add user or group"]']) - - await page.click('role=button[name*="User or group"]') - // only users not already on the project should be visible - await expectNotVisible(page, [ - 'role=option[name="Jacob Klein"]', - 'role=option[name="Herbert Marcuse"]', - ]) - - await expectVisible(page, [ - 'role=option[name="Hannah Arendt"]', - 'role=option[name="Hans Jonas"]', - 'role=option[name="Simone de Beauvoir"]', - ]) - - await page.click('role=option[name="Simone de Beauvoir"]') + // identities with only an inherited (via-group) role or no role don't appear + await expect(table.getByRole('cell', { name: 'Hans Jonas' })).toBeHidden() + await expect(table.getByRole('cell', { name: 'Simone de Beauvoir' })).toBeHidden() + + // add Simone as collaborator + await page.getByRole('button', { name: 'Add user or group' }).click() + await expect(page.getByRole('heading', { name: 'Add user or group' })).toBeVisible() + await page.getByRole('button', { name: 'User or group' }).click() + // already-assigned identities aren't offered + await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeHidden() + await page.getByRole('option', { name: 'Simone de Beauvoir' }).click() await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') - - // User 4 shows up in the table + await page.getByRole('button', { name: 'Assign role' }).click() await expectRowVisible(table, { Name: 'Simone de Beauvoir', Type: 'User', Role: 'project.collaborator', }) - // now change user 4 role from collab to viewer + // change Simone's role from collaborator to viewer await page - .locator('role=row', { hasText: user4.display_name }) - .locator('role=button[name="Row actions"]') + .getByRole('row', { name: user4.display_name, exact: false }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') - - await expectVisible(page, ['role=heading[name*="Edit role"]']) - - // Verify Collaborator is currently selected + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expect(page.getByRole('heading', { name: 'Edit role' })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() - - // Select Viewer role await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') - + await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: user4.display_name, Role: 'project.viewer' }) - // now delete user 3. has to be 3 or 4 because they're the only ones that come - // from the project policy - const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) - await expect(user3Row).toBeVisible() - await user3Row.getByRole('button', { name: 'Row actions' }).click() + // delete Jacob's project role + const jacobRow = page.getByRole('row', { name: user3.display_name, exact: false }) + await jacobRow.getByRole('button', { name: 'Row actions' }).click() await page.getByRole('menuitem', { name: 'Delete' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(user3Row).toBeHidden() - - // now add a project role to user 1, who currently only has silo role - 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="Hannah Arendt"]') - // Select Viewer role + await expect(jacobRow).toBeHidden() + + // add a project role to Hannah, who has only a silo role. Because we show the + // effective (strongest) role first, the badge stays silo.admin with a +1 for + // the added project role + await page.getByRole('button', { name: 'Add user or group' }).click() + await page.getByRole('button', { name: 'User or group' }).click() + await page.getByRole('option', { name: 'Hannah Arendt' }).click() await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Assign role"]') - // because we only show the "effective" role, we should still see the silo admin role, but should now have an additional count value + await page.getByRole('button', { name: 'Assign role' }).click() await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', Role: 'silo.admin+1', }) }) + +test('Inherited-only row offers Assign project role, not a disabled Change', async ({ + page, +}) => { + await page.goto('/projects/mock-project/access') + const table = page.getByRole('table') + + // Hannah has only a silo role, so there's no project role to change or remove, + // but a project role can still be assigned from the row action + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() + + await page.getByRole('menuitem', { name: 'Assign project role' }).click() + await expect(page.getByRole('heading', { name: 'Assign role' })).toBeVisible() + await expect(page.getByRole('dialog')).toContainText('Hannah Arendt') + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + // effective role is still silo.admin, with a +1 badge for the new project role + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin+1' }) +}) + +test('Project access user details side modal', async ({ page }) => { + await page.goto('/projects/mock-project') + await page.getByRole('link', { name: 'Project Access' }).click() + + // clicking a user opens their details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toContainText('Assigned') + + // group memberships (exact, since a role row also reads "via kernel-devs") + await expect(modal.getByRole('cell', { name: 'kernel-devs', exact: true })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs', exact: true })).toBeVisible() +}) + +test('Project access group members side modal', async ({ page }) => { + await page.goto('/projects/mock-project') + await page.getByRole('link', { name: 'Project Access' }).click() + + // clicking a group opens its members and roles + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 720ef8ba1..c3f0812ab 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,78 +5,299 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' +import { expect, expectRowVisible, test } from './utils' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' - -test('Click through silo access page', async ({ page }) => { +test('Silo Access page shows and edits silo role assignments', async ({ page }) => { await page.goto('/') + await page.getByRole('link', { name: 'Silo Access' }).click() + await expect(page).toHaveURL(/\/access$/) + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() - const table = page.locator('role=table') - - // page is there; we see user 1 and 2 but not 3 - await page.click('role=link[name*="Access"]') - - await expectVisible(page, ['role=heading[name*="Access"]']) + // only identities with a direct silo role appear + const table = page.getByRole('table') await expectRowVisible(table, { Name: 'real-estate-devs', Type: 'Group', Role: 'silo.collaborator', }) + await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', Role: 'silo.admin' }) + + // add Jacob Klein as collaborator + await page.getByRole('button', { name: 'Add user or group' }).click() + await expect(page.getByRole('heading', { name: 'Add user or group' })).toBeVisible() + await page.getByRole('button', { name: 'User or group' }).click() + // already-assigned identities aren't offered + await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() + await page.getByRole('option', { name: 'Jacob Klein' }).click() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() await expectRowVisible(table, { - Name: 'Hannah Arendt', + Name: 'Jacob Klein', Type: 'User', + Role: 'silo.collaborator', + }) + + // change Jacob's role to viewer + await table + .getByRole('row', { name: 'Jacob Klein', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change role' }).click() + 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.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.viewer' }) + + // delete Jacob's role + const jacobRow = page.getByRole('row', { name: 'Jacob Klein', exact: false }) + await jacobRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(jacobRow).toBeHidden() +}) + +test('Silo Access user details side modal', async ({ page }) => { + await page.goto('/access') + + // clicking a user opens their details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toContainText('Assigned') + + // group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs', exact: true })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs', exact: true })).toBeVisible() +}) + +test('Silo Access group members side modal', async ({ page }) => { + await page.goto('/access') + + // clicking a group opens its members and roles + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) + +test('Users & Groups page lands on Users tab; shows direct + via-group silo roles', async ({ + page, +}) => { + await page.goto('/') + await page.getByRole('link', { name: 'Users & Groups' }).click() + await expect(page).toHaveURL(/\/users$/) + await expect(page.getByRole('heading', { name: 'Users & Groups' })).toBeVisible() + + const table = page.getByRole('table') + + // Hannah has a direct silo.admin assignment; Groups column shows the first + // group (kernel-devs) and "+1" for web-devs + await expectRowVisible(table, { + Name: 'Hannah Arendt', Role: 'silo.admin', + Groups: 'kernel-devs+1', + }) + + // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs + await expectRowVisible(table, { + Name: 'Hans Jonas', + Role: 'silo.collaborator', + Groups: 'real-estate-devs', }) - await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`]) - - // Add user 2 as collab - await page.click('role=button[name="Add user or group"]') - await expectVisible(page, ['role=heading[name*="Add user or group"]']) - - await page.click('role=button[name*="User or group"]') - // only users not already on the org should be visible - await expectNotVisible(page, ['role=option[name="Hannah Arendt"]']) - await expectVisible(page, [ - 'role=option[name="Hans Jonas"]', - 'role=option[name="Jacob Klein"]', - 'role=option[name="Simone de Beauvoir"]', - ]) - - await page.click('role=option[name="Jacob Klein"]') + + // Jacob Klein has no silo role and no groups + await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—', Groups: '—' }) + + // Groups tab comes second + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/groups$/) +}) + +test('User details side modal shows assigned + via-group roles and group list', async ({ + page, +}) => { + await page.goto('/users') + + // Open Hannah's details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // Direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toBeVisible() + await expect(roleRow).toContainText('Assigned') + + // Group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs' })).toBeVisible() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + + // Hans Jonas inherits silo.collaborator via real-estate-devs + await page.getByRole('button', { name: 'Hans Jonas' }).click() + await expect(modal).toBeVisible() + const viaRow = modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await expect(viaRow).toContainText('via real-estate-devs') +}) + +test('Change and remove a user role from the Users tab', async ({ page }) => { + await page.goto('/users') + const table = page.getByRole('table') + + // Hannah has a direct silo.admin role; change it to viewer + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await expect(page.getByRole('radio', { name: /^Admin / })).toBeChecked() + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.viewer' }) + + // Remove Hannah's direct role; she still inherits via groups so the row stays + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Remove role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + // After removal, Hannah no longer has a direct silo role, so her displayed role + // reflects whatever she inherits via her groups (kernel-devs, web-devs have no + // silo role assignments by default in mock data). + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: '—' }) +}) + +test('Assign role to a user with no direct role from the row action', async ({ page }) => { + await page.goto('/users') + const table = page.getByRole('table') + + // Jacob Klein has no direct or inherited role + await table + .getByRole('row', { name: 'Jacob Klein', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + // unassigned users show only "Assign role" — no Change/Remove + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Assign role' }).click() + + // Modal opens with the user already targeted (no listbox), and no role pre-selected + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() + await expect(page.getByRole('button', { name: 'User or group' })).toBeHidden() + await expect(page.getByRole('dialog')).toContainText('Jacob Klein') + await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') + await page.getByRole('button', { name: 'Assign role' }).click() - // User 3 shows up in the table await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.collaborator', - Type: 'User', }) +}) - // now change user 3's role from collab 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"]') +test('Inherited-only role shows Change/Remove with Remove disabled', async ({ page }) => { + await page.goto('/users') + const table = page.getByRole('table') - await expectVisible(page, ['role=heading[name*="Edit role"]']) + // Hans Jonas has no direct silo role but inherits silo.collaborator via + // real-estate-devs, so the badge shows but Remove can't act on a direct role + await table + .getByRole('row', { name: 'Hans Jonas', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Assign role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeDisabled() - // Verify Collaborator is currently selected + // Change role opens the edit modal with the inherited role pre-selected + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() +}) - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') +test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { + await page.goto('/users') - await expectRowVisible(table, { Name: user3.display_name, Role: 'silo.viewer' }) + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/groups$/) - // now 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() + const table = page.getByRole('table') + + await expectRowVisible(table, { + Name: 'real-estate-devs', + Role: 'silo.collaborator', + Users: '2', + }) + await expectRowVisible(table, { Name: 'kernel-devs', Role: '—', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', Role: '—', Users: '1' }) + + // Open the real-estate-devs group modal + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) + +test('Change and remove a group role from the Groups tab', async ({ page }) => { + await page.goto('/groups') + const table = page.getByRole('table') + + // real-estate-devs has silo.collaborator; change to viewer + await table + .getByRole('row', { name: 'real-estate-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change role' }).click() + 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.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { + Name: 'real-estate-devs', + Role: 'silo.viewer', + }) + + // Remove the role + await table + .getByRole('row', { name: 'real-estate-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Remove role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(user3Row).toBeHidden() + await expectRowVisible(table, { Name: 'real-estate-devs', Role: '—' }) +}) + +test('Assign a role to a group with no direct role from the row action', async ({ + page, +}) => { + await page.goto('/groups') + const table = page.getByRole('table') + + // kernel-devs has no direct silo role + await table + .getByRole('row', { name: 'kernel-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Assign role' }).click() + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() + await expect(page.getByRole('dialog')).toContainText('kernel-devs') + + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'silo.viewer' }) })