diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index c31f4621a..4e0437f19 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -45,7 +45,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.ts b/app/api/roles.ts index 70c12ecaa..f72fde155 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 { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' +import { useMemo, useRef } 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. @@ -186,3 +202,70 @@ export function userRoleFromPolicies( .map((ra) => ra.roleName) return getEffectiveRole(myRoles) || null } + +export type ScopedRoleEntry = { + scope: 'silo' | 'project' + roleName: RoleKey + source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } } +} + +/** + * Enumerate all role assignments relevant to a user — one entry per direct + * assignment and one per group assignment — across one or more scoped policies. + * Callers are responsible for sorting and any display-layer merging. + */ +export function userScopedRoleEntries( + userId: string, + userGroups: { id: string; displayName: string }[], + scopedPolicies: Array<{ scope: 'silo' | 'project'; policy: Policy }> +): ScopedRoleEntry[] { + const entries: ScopedRoleEntry[] = [] + for (const { scope, policy } of scopedPolicies) { + const direct = policy.roleAssignments.find((ra) => ra.identityId === userId) + if (direct) + entries.push({ scope, roleName: direct.roleName, source: { type: 'direct' } }) + for (const group of userGroups) { + const via = policy.roleAssignments.find((ra) => ra.identityId === group.id) + if (via) + entries.push({ scope, roleName: via.roleName, source: { type: 'group', group } }) + } + } + return entries +} + +/** + * 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. + */ +export function useGroupsByUserId(groups: Group[]): Map { + const groupMemberQueries = useQueries({ + queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })), + }) + + // Use refs to return a stable Map reference when the underlying data hasn't + // changed. Without this, a new Map on every render causes downstream useMemos + // to recompute continuously, which destabilizes table rows in Playwright. + const mapRef = useRef>(new Map()) + const versionRef = useRef('') + + const version = [ + groups.map((g) => g.id).join(','), + ...groupMemberQueries.map((q) => q.dataUpdatedAt), + ].join('|') + + if (version !== versionRef.current) { + versionRef.current = version + 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]) + }) + }) + mapRef.current = map + } + + return mapRef.current +} diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx new file mode 100644 index 000000000..248486b01 --- /dev/null +++ b/app/components/access/GroupMembersSideModal.tsx @@ -0,0 +1,124 @@ +/* + * 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 * as R from 'remeda' + +import { api, q, roleOrder, type Group, type Policy, type User } from '@oxide/api' +import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { RowActions } from '~/table/columns/action-col' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { Table } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +type ScopedGroupPolicy = { + scope: 'silo' | 'project' + policy: Policy + /** Label for the Source column, e.g. "Assigned" or "Inherited from silo" */ + sourceLabel: string +} + +type Props = { + group: Group + onDismiss: () => void + scopedPolicies: ScopedGroupPolicy[] +} + +export function GroupMembersSideModal({ group, onDismiss, scopedPolicies }: Props) { + const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) + const members = data?.items ?? [] + + const roleEntries = R.sortBy( + scopedPolicies.flatMap(({ scope, policy, sourceLabel }) => { + const assignment = policy.roleAssignments.find((ra) => ra.identityId === group.id) + return assignment ? [{ scope, roleName: assignment.roleName, sourceLabel }] : [] + }), + (e) => roleOrder[e.roleName] + ) + + return ( + + {group.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName, sourceLabel }, i) => ( + + + + {scope}.{roleName} + + + {sourceLabel} + + )) + )} + +
+
+
+ {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( + + + + Members + + + + + {members.map((member: User) => ( + + {member.displayName} + + + + + ))} + +
+ )} +
+
+ ) +} diff --git a/app/components/access/UserDetailsSideModal.tsx b/app/components/access/UserDetailsSideModal.tsx new file mode 100644 index 000000000..5774e9d45 --- /dev/null +++ b/app/components/access/UserDetailsSideModal.tsx @@ -0,0 +1,124 @@ +/* + * 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, + userScopedRoleEntries, + type Group, + type Policy, + type User, +} from '@oxide/api' +import { Person16Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { RowActions } from '~/table/columns/action-col' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel } from '~/ui/lib/SideModal' +import { Table } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' + +type Props = { + user: User + onDismiss: () => void + userGroups: Group[] + scopedPolicies: Array<{ scope: 'silo' | 'project'; policy: Policy }> +} + +export function UserDetailsSideModal({ + user, + onDismiss, + userGroups, + scopedPolicies, +}: Props) { + const roleEntries = R.sortBy( + userScopedRoleEntries(user.id, userGroups, scopedPolicies), + (e) => roleOrder[e.roleName], + (e) => (e.scope === 'silo' ? 0 : 1) + ) + + return ( + + {user.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' && 'Assigned'} + {source.type === 'group' && `via ${source.group.displayName}`} + + + )) + )} + +
+
+
+ + + + Groups + + + + + {userGroups.length === 0 ? ( + + + Not a member of any groups + + + ) : ( + userGroups.map((group) => ( + + {group.displayName} + + + + + )) + )} + +
+
+
+ ) +} 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..55e162e65 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -113,6 +113,7 @@ export function ProjectAccessEditUserSideModal({ } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ path: { project }, body: updateRole({ identityId, identityType, roleName }, policy), diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 6bc711230..5896900c2 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -106,6 +106,7 @@ export function SiloAccessEditUserSideModal({ } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), }) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 74e734a84..e83ca9491 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -17,6 +17,7 @@ import { Instances16Icon, IpGlobal16Icon, Networking16Icon, + Person16Icon, Snapshots16Icon, Storage16Icon, } from '@oxide/design-system/icons/react' @@ -71,6 +72,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, { value: 'Project Access', path: pb.projectAccess(projectSelector) }, + { value: 'Users & Groups', path: pb.projectUsersAndGroups(projectSelector) }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -117,6 +119,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Affinity Groups + + Users & Groups + Project Access diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 361727119..0b500fa30 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -13,6 +13,7 @@ import { Folder16Icon, Images16Icon, Metrics16Icon, + Person16Icon, } from '@oxide/design-system/icons/react' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' @@ -37,6 +38,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.siloUsersAndGroups() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -67,6 +69,9 @@ export default function SiloLayout() { Utilization + + Users & Groups + Silo Access diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 6b274f91b..ea16f1fe6 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -12,13 +12,17 @@ import { api, byGroupThenName, deleteRole, + getEffectiveRole, q, queryClient, + roleOrder, useApiMutation, + rolesByIdFromPolicy, + useGroupsByUserId, usePrefetchedQuery, - useUserRows, + type Group, type IdentityType, - type RoleKey, + type SiloRole, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -29,7 +33,6 @@ import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' -import { useCurrentUser } from '~/hooks/use-current-user' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' @@ -38,84 +41,179 @@ 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' -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} - /> - -) - const policyView = q(api.policyView, {}) -const userList = q(api.userList, {}) +const userListQ = q(api.userList, {}) const groupList = q(api.groupList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { + const [groups, policy] = await Promise.all([ + queryClient.fetchQuery(groupListAll), + queryClient.fetchQuery(policyView), + ]) + const groupsWithRoles = new Set( + policy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ra.identityId) + ) await Promise.all([ - queryClient.prefetchQuery(policyView), - // used to resolve user names - queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(userListQ), queryClient.prefetchQuery(groupList), + ...groups.items + .filter((g) => groupsWithRoles.has(g.id)) + .map((g) => + queryClient.prefetchQuery( + q(api.userList, { query: { group: g.id, limit: ALL_ISH } }) + ) + ), ]) return null } export const handle = { crumb: 'Silo Access' } -type UserRow = { +type AccessRow = { id: string - identityType: IdentityType name: string - siloRole: RoleKey | undefined + identityType: IdentityType + effectiveRole: SiloRole + /** Groups that provide or boost the effective role. Empty if role is purely direct. */ + viaGroups: Group[] + /** Undefined if access is only via a group, no direct role assignment. */ + directRole: SiloRole | undefined } -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() + +const EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No silo roles assigned" + body="Give permission to view, edit, or administer this silo." + buttonText="Add user or group" + onClick={onClick} + /> + +) export default function SiloAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingRow, setEditingRow] = 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 { data: users } = usePrefetchedQuery(userListQ) + const { data: groups } = usePrefetchedQuery(groupListAll) 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 siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + + // Only fetch memberships for groups that have silo roles — their members have group-based access + const groupsWithRoles = useMemo( + () => groups.items.filter((g) => siloRoleById.has(g.id)), + [groups, siloRoleById] + ) + + // userId → groups[] (only role-bearing groups, so only users with group-based access appear) + const groupsByUserId = useGroupsByUserId(groupsWithRoles) + + const rows: AccessRow[] = useMemo(() => { + const userById = new Map(users.items.map((u) => [u.id, u])) + const groupById = new Map(groups.items.map((g) => [g.id, g])) + + type IntermediateUserRow = { + id: string + name: string + directRole: SiloRole | undefined + memberOfGroups: Group[] + } + + const intermediateUserRows = new Map() + + // Collect directly assigned users + for (const ra of siloPolicy.roleAssignments) { + if (ra.identityType === 'silo_user') { + intermediateUserRows.set(ra.identityId, { + id: ra.identityId, + name: userById.get(ra.identityId)?.displayName ?? ra.identityId, + directRole: ra.roleName, + memberOfGroups: [], + }) + } + } + + // Merge in users who have access via groups + for (const [userId, memberGroups] of groupsByUserId) { + const existing = intermediateUserRows.get(userId) + if (existing) { + existing.memberOfGroups = memberGroups + } else { + intermediateUserRows.set(userId, { + id: userId, + name: userById.get(userId)?.displayName ?? userId, + directRole: undefined, + memberOfGroups: memberGroups, + }) + } + } + + // Build final user rows with effective roles + const userRows: AccessRow[] = [] + for (const row of intermediateUserRows.values()) { + const groupRoles = row.memberOfGroups + .map((g) => siloRoleById.get(g.id)) + .filter((r): r is SiloRole => r !== undefined) + const allRoles: SiloRole[] = [ + ...(row.directRole ? [row.directRole] : []), + ...groupRoles, + ] + const effectiveRole = getEffectiveRole(allRoles) + if (!effectiveRole) continue + + // Show viaGroups when effective role comes from or is boosted by a group + const viaGroups = + !row.directRole || roleOrder[effectiveRole] < roleOrder[row.directRole] + ? row.memberOfGroups.filter((g) => { + const gr = siloRoleById.get(g.id) + return gr !== undefined && roleOrder[gr] <= roleOrder[effectiveRole] + }) + : [] + + userRows.push({ + id: row.id, + name: row.name, + identityType: 'silo_user', + effectiveRole, + viaGroups, + directRole: row.directRole, + }) + } + + // Group rows from direct policy assignments + const groupRows: AccessRow[] = siloPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ({ + id: ra.identityId, + name: groupById.get(ra.identityId)?.displayName ?? ra.identityId, + identityType: 'silo_group' as IdentityType, + effectiveRole: ra.roleName, + viaGroups: [], + directRole: ra.roleName, + })) + + return [...groupRows, ...userRows].sort(byGroupThenName) + }, [siloPolicy, users, groups, groupsByUserId, siloRoleById]) const columns = useMemo( () => [ @@ -124,38 +222,50 @@ export default function SiloAccessPage() { header: 'Type', cell: (info) => identityTypeLabel[info.getValue()], }), - colHelper.accessor('siloRole', { - header: 'Role', - cell: (info) => { - const role = info.getValue() - return role ? silo.{role} : null + colHelper.display({ + id: 'effectiveRole', + header: 'Silo Role', + cell: ({ row }) => { + const { effectiveRole, viaGroups } = row.original + return ( +
+ silo.{effectiveRole} + {viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} +
+ ) }, }), - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ + getActionsCol((row: AccessRow) => [ { label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: !row.siloRole && "You don't have permission to change this user's role", + onActivate: () => setEditingRow(row), + disabled: !row.directRole && 'This identity has no direct role to change', }, - // TODO: only show if you have permission to do this { - label: 'Delete', + label: 'Remove role', onActivate: confirmDelete({ doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), label: ( - the {row.siloRole} role for {row.name} + the {row.directRole} role for {row.name} ), - extraContent: - row.id === me.id ? 'This will remove your own silo access.' : undefined, }), - disabled: !row.siloRole && "You don't have permission to delete this user", + disabled: !row.directRole && 'This identity has no direct role to remove', }, ]), ], - [siloPolicy, updatePolicy, me] + [siloPolicy, updatePolicy] ) const tableInstance = useReactTable({ @@ -172,10 +282,9 @@ 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 @@ -185,14 +294,14 @@ export default function SiloAccessPage() { policy={siloPolicy} /> )} - {editingUserRow?.siloRole && ( + {editingRow && ( setEditingUserRow(null)} + onDismiss={() => setEditingRow(null)} policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.directRole }} /> )} {rows.length === 0 ? ( diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx new file mode 100644 index 000000000..2fc4fe30e --- /dev/null +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -0,0 +1,117 @@ +/* + * 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 } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + getListQFn, + q, + queryClient, + rolesByIdFromPolicy, + usePrefetchedQuery, + type Group, +} from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' +import { titleCrumb } from '~/hooks/use-crumbs' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { MemberCountCell } from '~/table/cells/MemberCountCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { roleColor } from '~/util/access' + +const policyView = q(api.policyView, {}) +const groupList = getListQFn(api.groupList, {}) + +export async function clientLoader() { + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(groupList.optionsFn()), + ]) + return null +} + +export const handle = titleCrumb('Groups') + +const colHelper = createColumnHelper() + +const GroupEmptyState = () => ( + } + title="No groups" + body="No groups have been added to this silo" + /> +) + +export default function SiloUsersAndGroupsGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + + const siloRoleCol = useMemo( + () => + colHelper.display({ + id: 'siloRole', + header: 'Silo Role', + cell: ({ row }) => { + const role = siloRoleById.get(row.original.id) + return role ? silo.{role} : + }, + }), + [siloRoleById] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + siloRoleCol, + colHelper.display({ + id: 'memberCount', + header: 'Users', + cell: ({ row }) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [siloRoleCol] + ) + + const columns = staticColumns + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={[{ scope: 'silo', policy: siloPolicy, sourceLabel: 'Assigned' }]} + /> + )} + + ) +} diff --git a/app/pages/SiloUsersAndGroupsPage.tsx b/app/pages/SiloUsersAndGroupsPage.tsx new file mode 100644 index 000000000..d7260a6fa --- /dev/null +++ b/app/pages/SiloUsersAndGroupsPage.tsx @@ -0,0 +1,36 @@ +/* + * 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 { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const handle = { crumb: 'Users & Groups' } + +export default function SiloUsersAndGroupsPage() { + return ( + <> + + }>Users & Groups + } + 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, docLinks.identityProviders]} + /> + + + Users + Groups + + + ) +} diff --git a/app/pages/SiloUsersAndGroupsUsersTab.tsx b/app/pages/SiloUsersAndGroupsUsersTab.tsx new file mode 100644 index 000000000..ba1d0a2b3 --- /dev/null +++ b/app/pages/SiloUsersAndGroupsUsersTab.tsx @@ -0,0 +1,169 @@ +/* + * 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 } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + getListQFn, + q, + queryClient, + roleOrder, + rolesByIdFromPolicy, + useGroupsByUserId, + usePrefetchedQuery, + userRoleFromPolicies, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' +import { ListPlusCell } from '~/components/ListPlusCell' +import { titleCrumb } from '~/hooks/use-crumbs' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +const policyView = q(api.policyView, {}) +const userList = getListQFn(api.userList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + const groups = await queryClient.fetchQuery(groupListAll) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(userList.optionsFn()), + ...groups.items.map((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ), + ]) + return null +} + +export const handle = titleCrumb('Users') + +const colHelper = createColumnHelper() + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function SiloUsersAndGroupsUsersTab() { + const [selectedUser, setSelectedUser] = useState(null) + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + + const groupsByUserId = useGroupsByUserId(groups.items) + + const siloRoleCol = useMemo( + () => + colHelper.display({ + id: 'siloRole', + header: 'Silo Role', + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + const role = userRoleFromPolicies(row.original, userGroups, [siloPolicy]) + if (!role) return + const directRole = siloRoleById.get(row.original.id) + // groups that have a role at least as strong as the effective role, + // only relevant when a group is boosting beyond the user's direct assignment + const viaGroups = + !directRole || roleOrder[role] < roleOrder[directRole] + ? userGroups.filter((g) => { + const gr = siloRoleById.get(g.id) + return gr !== undefined && roleOrder[gr] <= roleOrder[role] + }) + : [] + return ( +
+ silo.{role} + {viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} +
+ ) + }, + }), + [groupsByUserId, siloPolicy, siloRoleById] + ) + + 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 columns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + siloRoleCol, + groupsCol, + timeCreatedCol, + ], + [siloRoleCol, groupsCol] + ) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + + return ( + <> + {table} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + + ) +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 460bf3aae..694621890 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,22 +5,23 @@ * * 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, + getEffectiveRole, q, queryClient, roleOrder, useApiMutation, + rolesByIdFromPolicy, + useGroupsByUserId, usePrefetchedQuery, - useUserRows, + type Group, type IdentityType, type RoleKey, } from '@oxide/api' @@ -29,7 +30,6 @@ import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, @@ -45,97 +45,248 @@ 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 userListQ = 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 groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) + const [groups, siloPolicy, projectPolicy] = await Promise.all([ + queryClient.fetchQuery(groupListAll), + queryClient.fetchQuery(policyView), + queryClient.fetchQuery(projectPolicyView(selector)), + ]) + // Fetch group memberships for groups with roles in either policy + const groupsWithAnyRole = new Set([ + ...siloPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ra.identityId), + ...projectPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ra.identityId), + ]) await Promise.all([ - queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(projectPolicyView(selector)), - // used to resolve user names - queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(userListQ), queryClient.prefetchQuery(groupList), + ...groups.items + .filter((g) => groupsWithAnyRole.has(g.id)) + .map((g) => + queryClient.prefetchQuery( + q(api.userList, { query: { group: g.id, limit: ALL_ISH } }) + ) + ), ]) return null } export const handle = { crumb: 'Project Access' } -type UserRow = { +type AccessRow = { id: string - identityType: IdentityType name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] + identityType: IdentityType + effectiveScope: 'silo' | 'project' + effectiveRole: RoleKey + viaGroups: Group[] + /** Direct project-level role only — the only one manageable on this page. */ + directProjectRole: RoleKey | undefined } -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() + +const EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No project roles assigned" + body="Give permission to view, edit, or administer this project." + buttonText="Add user or group" + onClick={onClick} + /> + +) export default function ProjectAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingRow, setEditingRow] = useState(null) + const projectSelector = useProjectSelector() + const { project } = projectSelector 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 { data: users } = usePrefetchedQuery(userListQ) + const { data: groups } = usePrefetchedQuery(groupListAll) 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 siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) + + // Fetch memberships for groups with roles in either policy + const groupsWithAnyRole = useMemo( + () => groups.items.filter((g) => siloRoleById.has(g.id) || projectRoleById.has(g.id)), + [groups, siloRoleById, projectRoleById] + ) + const groupsByUserId = useGroupsByUserId(groupsWithAnyRole) + + const rows: AccessRow[] = useMemo(() => { + const userById = new Map(users.items.map((u) => [u.id, u])) + const groupById = new Map(groups.items.map((g) => [g.id, g])) + + type IntermediateUserRow = { + id: string + name: string + directSiloRole: RoleKey | undefined + directProjectRole: RoleKey | undefined + memberOfGroups: Group[] + } + + const intermediateUserRows = new Map() + + const ensureUserRow = (userId: string): IntermediateUserRow => { + const existing = intermediateUserRows.get(userId) + if (existing) return existing + const row: IntermediateUserRow = { + id: userId, + name: userById.get(userId)?.displayName ?? userId, + directSiloRole: undefined, + directProjectRole: undefined, + memberOfGroups: [], + } + intermediateUserRows.set(userId, row) + return row + } + + for (const ra of siloPolicy.roleAssignments) { + if (ra.identityType === 'silo_user') + ensureUserRow(ra.identityId).directSiloRole = ra.roleName + } + for (const ra of projectPolicy.roleAssignments) { + if (ra.identityType === 'silo_user') + ensureUserRow(ra.identityId).directProjectRole = ra.roleName + } + for (const [userId, memberGroups] of groupsByUserId) { + ensureUserRow(userId).memberOfGroups = memberGroups + } + + const userRows: AccessRow[] = [] + for (const row of intermediateUserRows.values()) { + const groupRoles: RoleKey[] = row.memberOfGroups.flatMap((g) => { + const sr = siloRoleById.get(g.id) + const pr = projectRoleById.get(g.id) + return [sr, pr].filter((r): r is RoleKey => r !== undefined) + }) + + const allRoles: RoleKey[] = [ + ...(row.directSiloRole ? [row.directSiloRole] : []), + ...(row.directProjectRole ? [row.directProjectRole] : []), + ...groupRoles, + ] + const effectiveRole = getEffectiveRole(allRoles) + if (!effectiveRole) continue + + // Scope is 'silo' if the silo policy provides a role at least as strong as effective + const siloRoles: RoleKey[] = [ + ...(row.directSiloRole ? [row.directSiloRole] : []), + ...row.memberOfGroups + .map((g) => siloRoleById.get(g.id)) + .filter((r): r is RoleKey => r !== undefined), + ] + const effectiveSiloRole = getEffectiveRole(siloRoles) + const effectiveScope: 'silo' | 'project' = + effectiveSiloRole !== undefined && + roleOrder[effectiveSiloRole] <= roleOrder[effectiveRole] + ? 'silo' + : 'project' + + // Show viaGroups when a group provides or boosts the effective role beyond direct assignments + const directRoles: RoleKey[] = [ + ...(row.directSiloRole ? [row.directSiloRole] : []), + ...(row.directProjectRole ? [row.directProjectRole] : []), + ] + const effectiveDirectRole = getEffectiveRole(directRoles) + const viaGroups = + !effectiveDirectRole || roleOrder[effectiveRole] < roleOrder[effectiveDirectRole] + ? row.memberOfGroups.filter((g) => { + const groupBestRole = getEffectiveRole( + [siloRoleById.get(g.id), projectRoleById.get(g.id)].filter( + (r): r is RoleKey => r !== undefined + ) + ) + return ( + groupBestRole !== undefined && + roleOrder[groupBestRole] <= roleOrder[effectiveRole] + ) + }) + : [] + + userRows.push({ + id: row.id, + name: row.name, + identityType: 'silo_user', + effectiveScope, + effectiveRole, + viaGroups, + directProjectRole: row.directProjectRole, + }) + } + + // Group rows: collect all groups from either policy + const groupIds = new Set([ + ...siloPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ra.identityId), + ...projectPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_group') + .map((ra) => ra.identityId), + ]) + + const groupRows: AccessRow[] = Array.from(groupIds).map((groupId) => { + const siloRole = siloRoleById.get(groupId) + const projectRole = projectRoleById.get(groupId) + const allGroupRoles = [siloRole, projectRole].filter( + (r): r is RoleKey => r !== undefined + ) + // non-null: group is in at least one policy so allGroupRoles is non-empty + const effectiveRole = getEffectiveRole(allGroupRoles)! + const effectiveScope: 'silo' | 'project' = + siloRole !== undefined && roleOrder[siloRole] <= roleOrder[effectiveRole] + ? 'silo' + : 'project' + return { + id: groupId, + name: groupById.get(groupId)?.displayName ?? groupId, + identityType: 'silo_group' as IdentityType, + effectiveScope, + effectiveRole, + viaGroups: [], + directProjectRole: projectRole, + } + }) + + return [...groupRows, ...userRows].sort(byGroupThenName) + }, [ + siloPolicy, + projectPolicy, + users, + groups, + groupsByUserId, + siloRoleById, + projectRoleById, + ]) const columns = useMemo( () => [ @@ -144,59 +295,55 @@ export default function ProjectAccessPage() { 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} + colHelper.display({ + id: 'effectiveRole', + header: 'Role', + cell: ({ row }) => { + const { effectiveScope, effectiveRole, viaGroups } = row.original + return ( +
+ + {effectiveScope}.{effectiveRole} - ))} - - ), + {viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} +
+ ) + }, }), - - // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ + getActionsCol((row: AccessRow) => [ { label: 'Change role', - onActivate: () => setEditingUserRow(row), + onActivate: () => setEditingRow(row), disabled: - !row.projectRole && "You don't have permission to change this user's role", + !row.directProjectRole && 'This identity has no direct project role to change', }, - // TODO: only show if you have permission to do this { - label: 'Delete', + label: 'Remove role', 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. + updatePolicy({ path: { project }, body: deleteRole(row.id, projectPolicy) }), label: ( - the {row.projectRole} role for {row.name} + the {row.directProjectRole} role for {row.name} ), }), - disabled: !row.projectRole && "You don't have permission to delete this user", + disabled: + !row.directProjectRole && 'This identity has no direct project role to remove', }, ]), ], - [projectPolicy, projectSelector.project, updatePolicy] + [projectPolicy, project, updatePolicy] ) const tableInstance = useReactTable({ @@ -213,27 +360,26 @@ 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 && ( + {editingRow && ( setEditingUserRow(null)} + onDismiss={() => setEditingRow(null)} policy={projectPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.projectRole }} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.directProjectRole }} /> )} {rows.length === 0 ? ( diff --git a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx new file mode 100644 index 000000000..338692a3e --- /dev/null +++ b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx @@ -0,0 +1,160 @@ +/* + * 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 } from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + getListQFn, + q, + queryClient, + roleOrder, + rolesByIdFromPolicy, + usePrefetchedQuery, + type Group, +} from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' +import { ListPlusCell } from '~/components/ListPlusCell' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { MemberCountCell } from '~/table/cells/MemberCountCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import type * as PP from '~/util/path-params' + +const policyView = q(api.policyView, {}) +const projectPolicyView = ({ project }: PP.Project) => + q(api.projectPolicyView, { path: { project } }) +const groupList = getListQFn(api.groupList, {}) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getProjectSelector(params) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(projectPolicyView(selector)), + queryClient.prefetchQuery(groupList.optionsFn()), + ]) + return null +} + +export const handle = titleCrumb('Groups') + +const colHelper = createColumnHelper() + +const GroupEmptyState = () => ( + } + title="No groups" + body="No groups have been added to this project" + /> +) + +export default function ProjectUsersAndGroupsGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + const projectSelector = useProjectSelector() + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + + const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) + + const rolesCol = useMemo( + () => + colHelper.display({ + id: 'roles', + header: () => ( + + Role + + A group's effective role for this project is the strongest role on either + the silo or project. Groups without an assigned role have no access to this + project. + + + ), + cell: ({ row }) => { + const siloRole = siloRoleById.get(row.original.id) + const projectRole = projectRoleById.get(row.original.id) + const roles = R.sortBy( + [ + siloRole && { roleName: siloRole, roleSource: 'silo' as const }, + projectRole && { roleName: projectRole, roleSource: 'project' as const }, + ].filter((r) => !!r), + (r) => roleOrder[r.roleName] + ) + if (roles.length === 0) return + return ( + + {roles.map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} + + ))} + + ) + }, + }), + [siloRoleById, projectRoleById] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + rolesCol, + colHelper.display({ + id: 'memberCount', + header: 'Users', + cell: ({ row }) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [rolesCol] + ) + + const columns = staticColumns + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={[ + { scope: 'project', policy: projectPolicy, sourceLabel: 'Assigned' }, + { scope: 'silo', policy: siloPolicy, sourceLabel: 'Inherited from silo' }, + ]} + /> + )} + + ) +} diff --git a/app/pages/project/access/ProjectUsersAndGroupsPage.tsx b/app/pages/project/access/ProjectUsersAndGroupsPage.tsx new file mode 100644 index 000000000..f9c81c5b7 --- /dev/null +++ b/app/pages/project/access/ProjectUsersAndGroupsPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { useProjectSelector } from '~/hooks/use-params' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const handle = { crumb: 'Users & Groups' } + +export default function ProjectUsersAndGroupsPage() { + const projectSelector = useProjectSelector() + return ( + <> + + }>Users & Groups + } + 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, docLinks.identityProviders]} + /> + + + Users + Groups + + + ) +} diff --git a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx new file mode 100644 index 000000000..cacd17ec1 --- /dev/null +++ b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx @@ -0,0 +1,186 @@ +/* + * 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 } from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + getListQFn, + q, + queryClient, + roleOrder, + useGroupsByUserId, + usePrefetchedQuery, + userScopedRoleEntries, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' +import { ListPlusCell } from '~/components/ListPlusCell' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' +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 = getListQFn(api.userList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getProjectSelector(params) + const groups = await queryClient.fetchQuery(groupListAll) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(projectPolicyView(selector)), + queryClient.prefetchQuery(userList.optionsFn()), + ...groups.items.map((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ), + ]) + return null +} + +export const handle = titleCrumb('Users') + +const colHelper = createColumnHelper() + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this project" + /> +) + +export default function ProjectUsersAndGroupsUsersTab() { + const [selectedUser, setSelectedUser] = useState(null) + const projectSelector = useProjectSelector() + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const groupsByUserId = useGroupsByUserId(groups.items) + + const rolesCol = useMemo( + () => + colHelper.display({ + id: 'roles', + header: () => ( + + Role + + A user's effective role for this project is the strongest role on either + the silo or project, including roles inherited via group membership. Users + without any assigned role have no access to this project. + + + ), + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + const roles = R.sortBy( + userScopedRoleEntries(row.original.id, userGroups, [ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ]), + (e) => roleOrder[e.roleName] + ) + if (roles.length === 0) return + return ( + + {roles.map(({ scope, roleName, source }, i) => ( + + + {scope}.{roleName} + + {i > 0 && source.type === 'group' && ` via ${source.group.displayName}`} + {i === 0 && source.type === 'group' && ( + via {source.group.displayName} + )} + + ))} + + ) + }, + }), + [groupsByUserId, siloPolicy, projectPolicy] + ) + + 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 columns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + rolesCol, + groupsCol, + timeCreatedCol, + ], + [rolesCol, groupsCol] + ) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + + return ( + <> + {table} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={[ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ]} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 03da82fa4..fefa92c16 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -281,6 +281,20 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloUsersAndGroupsPage').then(convert)} + > + } /> + import('./pages/SiloUsersAndGroupsUsersTab').then(convert)} + /> + import('./pages/SiloUsersAndGroupsGroupsTab').then(convert)} + /> + {/* PROJECT */} @@ -537,6 +551,28 @@ export const routes = createRoutesFromElements( path="access" lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} /> + + import('./pages/project/access/ProjectUsersAndGroupsPage').then(convert) + } + > + } /> + + import('./pages/project/access/ProjectUsersAndGroupsUsersTab').then(convert) + } + /> + + import('./pages/project/access/ProjectUsersAndGroupsGroupsTab').then( + convert + ) + } + /> + import('./pages/project/affinity/AffinityPage').then(convert)} handle={{ crumb: 'Affinity Groups' }} 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 6585875b2..97da1d927 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 @@ -57,10 +58,17 @@ type ContentProps = { export function Content({ className, children, anchor = 'bottom end', gap }: ContentProps) { const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap) + const isInModal = useIsInModal() + const isInSideModal = useIsInSideModal() + const zClass = isInModal + ? 'z-(--z-modal-dropdown)' + : isInSideModal + ? 'z-(--z-side-modal-dropdown)' + : 'z-(--z-top-bar-dropdown)' return ( { "projectImageEdit": "/projects/p/images/im/edit", "projectImages": "/projects/p/images", "projectImagesNew": "/projects/p/images-new", + "projectUsersAndGroups": "/projects/p/users-and-groups", + "projectUsersAndGroupsGroups": "/projects/p/users-and-groups/groups", + "projectUsersAndGroupsUsers": "/projects/p/users-and-groups/users", "projects": "/projects", "projectsNew": "/projects-new", "samlIdp": "/system/silos/s/idps/saml/pr", @@ -92,6 +95,9 @@ test('path builder', () => { "siloIpPools": "/system/silos/s/ip-pools", "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", + "siloUsersAndGroups": "/users-and-groups", + "siloUsersAndGroupsGroups": "/users-and-groups/groups", + "siloUsersAndGroupsUsers": "/users-and-groups/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 b7470e845..53d21c724 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,6 +27,11 @@ export const pb = { projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, + projectUsersAndGroups: (params: PP.Project) => `${projectBase(params)}/users-and-groups`, + projectUsersAndGroupsUsers: (params: PP.Project) => + `${projectBase(params)}/users-and-groups/users`, + projectUsersAndGroupsGroups: (params: PP.Project) => + `${projectBase(params)}/users-and-groups/groups`, projectImages: (params: PP.Project) => `${projectBase(params)}/images`, projectImagesNew: (params: PP.Project) => `${projectBase(params)}/images-new`, projectImageEdit: (params: PP.Image) => @@ -107,6 +112,9 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', + siloUsersAndGroups: () => '/users-and-groups', + siloUsersAndGroupsUsers: () => '/users-and-groups/users', + siloUsersAndGroupsGroups: () => '/users-and-groups/groups', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, 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..e13c4e480 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -5,114 +5,41 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' +import { user3 } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, expectVisible, test } from './utils' test('Click through project access page', 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"]']) + + // Mixed table: groups first, then users const table = page.locator('table') - await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', - }) - await expectRowVisible(table, { - Name: 'Jacob Klein', - Type: 'User', - Role: 'project.collaborator', - }) + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin' }) + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'project.collaborator' }) await expectRowVisible(table, { Name: 'Herbert Marcuse', - Type: 'User', Role: 'project.limited_collaborator', }) - await expectRowVisible(table, { - Name: 'real-estate-devs', - Type: 'Group', - Role: 'silo.collaborator', - }) - await expectRowVisible(table, { - Name: 'kernel-devs', - Type: 'Group', - 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"]') - await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') - - // User 4 shows up in the table - await expectRowVisible(table, { - Name: 'Simone de Beauvoir', - Type: 'User', - Role: 'project.collaborator', - }) - // now change user 4 role from collab to viewer + // Change Jacob Klein from collaborator to viewer await page - .locator('role=row', { hasText: user4.display_name }) + .locator('role=row', { hasText: user3.display_name }) .locator('role=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 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 expectRowVisible(table, { Name: user3.display_name, Role: 'project.viewer' }) - 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 + // Remove Jacob Klein's project role; row disappears since he has no other access const user3Row = page.getByRole('row', { name: user3.display_name, exact: false }) await expect(user3Row).toBeVisible() await user3Row.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove role' }).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 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 expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin+1', - }) + await expect(table.locator('role=row', { hasText: user3.display_name })).toBeHidden() }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 720ef8ba1..a47f419f8 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,78 +5,66 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' - -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, expectVisible, test } from './utils' test('Click through silo access page', async ({ page }) => { await page.goto('/') - - 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"]']) + + // Mixed table: groups first, then users + const table = page.locator('role=table') await expectRowVisible(table, { Name: 'real-estate-devs', - Type: 'Group', - Role: 'silo.collaborator', + 'Silo Role': 'silo.collaborator', }) - await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', - }) - await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`]) + await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': 'silo.admin' }) + // Hans Jonas is a member of real-estate-devs, so gets collaborator via group + await expectRowVisible(table, { Name: 'Hans Jonas', 'Silo Role': 'silo.collaborator' }) + + // Change real-estate-devs role from collaborator to viewer + await table + .locator('role=row', { hasText: 'real-estate-devs' }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Update role"]') + await expectRowVisible(table, { Name: 'real-estate-devs', 'Silo Role': 'silo.viewer' }) - // 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"]']) + // Remove real-estate-devs role; row disappears since it now has no silo role + await table + .locator('role=row', { hasText: 'real-estate-devs' }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Remove role"]') + await page.click('role=button[name="Confirm"]') + await expect(table.locator('role=row', { hasText: 'real-estate-devs' })).toBeHidden() +}) - 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"]', - ]) +test('Group role change propagates to user effective role', async ({ page }) => { + await page.goto('/') + await page.click('role=link[name*="Access"]') - await page.click('role=option[name="Jacob Klein"]') - await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') + const table = page.locator('role=table') + // Jane Austen has collaborator via real-estate-devs group + await expectRowVisible(table, { Name: 'Jane Austen', 'Silo Role': 'silo.collaborator' }) - // User 3 shows up in the table - await expectRowVisible(table, { - Name: 'Jacob Klein', - Role: 'silo.collaborator', - Type: 'User', - }) + // Verify her role tip shows via real-estate-devs + const janeRow = table.locator('role=row', { hasText: 'Jane Austen' }) + await janeRow.getByRole('button', { name: 'Tip' }).hover() + await expect(page.locator('.ox-tooltip')).toContainText('real-estate-devs') - // now change user 3's role from collab to viewer - await page - .locator('role=row', { hasText: user3.display_name }) + // Change real-estate-devs role to admin + await table + .locator('role=row', { hasText: 'real-estate-devs' }) .locator('role=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 expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() - - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('radio', { name: /^Admin / }).click() await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { Name: user3.display_name, Role: 'silo.viewer' }) - - // 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() - await page.getByRole('button', { name: 'Confirm' }).click() - await expect(user3Row).toBeHidden() + // Jane Austen now shows admin as effective role (inherited via real-estate-devs) + await expectRowVisible(table, { Name: 'Jane Austen', 'Silo Role': 'silo.admin' }) })