diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 49d1378163..64a5c46878 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -74,7 +74,7 @@ export type EditRoleModalProps = AddRoleModalProps & { name?: string identityId: string identityType: IdentityType - defaultValues: { roleName: RoleKey } + defaultValues: { roleName?: RoleKey } } const AccessDocs = () => ( diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 15566bc562..55e162e65f 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 7ccaeab087..e708cc1621 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -103,6 +103,7 @@ export function SiloAccessEditUserSideModal({ } onSubmit={({ roleName }) => { + if (!roleName) return updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), }) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx new file mode 100644 index 0000000000..4de4ffd936 --- /dev/null +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -0,0 +1,222 @@ +/* + * 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 { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' + +import { + api, + deleteRole, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Group, + 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 { HL } from '~/components/HL' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { confirmDelete } from '~/stores/confirm-delete' +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 { useQueryTable } from '~/table/QueryTable' +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' + +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() + +function MemberCountCell({ groupId }: { groupId: string }) { + const { data } = useQuery(q(api.userList, { query: { group: groupId, limit: ALL_ISH } })) + return data ? <>{data.items.length} : null +} + +const GroupEmptyState = () => ( + } + title="No groups" + body="No groups have been added to this silo" + /> +) + +type GroupMembersSideModalProps = { + group: Group + onDismiss: () => void +} + +function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) { + const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) + const members = data?.items ?? [] + + return ( + + {group.displayName} + + } + onDismiss={onDismiss} + animate + > + + + +
+ {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( + + + + Name + + + + {members.map((member: User) => ( + + {member.displayName} + + ))} + +
+ )} +
+
+ ) +} + +export default function SiloAccessGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => queryClient.invalidateEndpoint('policyView'), + }) + + const siloRoleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [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 makeActions = useCallback( + (group: Group): MenuAction[] => { + const role = siloRoleById.get(group.id) + return [ + { label: 'Change role', onActivate: () => setEditingGroup(group) }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), + label: ( + + the {role} role for {group.displayName} + + ), + }), + disabled: !role && 'This group has no role to remove', + }, + ] + }, + [siloRoleById, siloPolicy, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + /> + )} + {editingGroup && ( + setEditingGroup(null)} + /> + )} + + ) +} diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index eb65e95359..382d168b3a 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,167 +5,17 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo, useState } from 'react' - -import { - api, - byGroupThenName, - deleteRole, - getEffectiveRole, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, -} from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { confirmDelete } from '~/stores/confirm-delete' -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 { RouteTabs, Tab } from '~/components/RouteTabs' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' import { docLinks } from '~/util/links' - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this silo" - buttonText="Add user or group" - onClick={onClick} - /> - -) - -const policyView = q(api.policyView, {}) -const userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) - -export async function clientLoader() { - await Promise.all([ - queryClient.prefetchQuery(policyView), - // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), - ]) - return null -} +import { pb } from '~/util/path-builder' export const handle = { crumb: 'Silo Access' } -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} - -const colHelper = createColumnHelper() - export default function SiloAccessPage() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - - 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 roles = siloRole ? [siloRole] : [] - - const { name, identityType } = userAssignments[0] - - const row: UserRow = { - id: userId, - identityType, - name, - siloRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, - } - - return row - }) - .sort(byGroupThenName) - }, [siloRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), - // 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({ - // we know policy is there, otherwise there's no row to display - body: deleteRole(row.id, siloPolicy), - }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - }), - disabled: !row.siloRole && "You don't have permission to delete this user", - }, - ]), - ], - [siloPolicy, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - return ( <> @@ -174,34 +24,13 @@ 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 - - {siloPolicy && addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {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)} /> - ) : ( - - )} + + Silo Users + Silo Groups + ) } diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx new file mode 100644 index 0000000000..24cc520974 --- /dev/null +++ b/app/pages/SiloAccessUsersTab.tsx @@ -0,0 +1,212 @@ +/* + * 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 { useQueries } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' + +import { + api, + deleteRole, + getListQFn, + q, + queryClient, + roleOrder, + useApiMutation, + usePrefetchedQuery, + userRoleFromPolicies, + type Group, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { confirmDelete } from '~/stores/confirm-delete' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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 displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function SiloAccessUsersTab() { + const [editingUser, setEditingUser] = useState(null) + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => queryClient.invalidateEndpoint('policyView'), + }) + + // direct role assignments by identity ID, used for action menu + const siloRoleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [siloPolicy] + ) + + const groupMemberQueries = useQueries({ + queries: groups.items.map((g) => + q(api.userList, { query: { group: g.id, limit: ALL_ISH } }) + ), + }) + + // map from user ID to the groups they belong to + const groupsByUserId = useMemo(() => { + const map = new Map() + groups.items.forEach((group, i) => { + const members = groupMemberQueries[i]?.data?.items ?? [] + members.forEach((member) => { + map.set(member.id, [...(map.get(member.id) ?? []), group]) + }) + }) + return map + }, [groups, groupMemberQueries]) + + 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 staticColumns = useMemo( + () => [displayNameCol, siloRoleCol, groupsCol, timeCreatedCol], + [siloRoleCol, groupsCol] + ) + + const makeActions = useCallback( + (user: User): MenuAction[] => { + const role = siloRoleById.get(user.id) + return [ + { label: 'Change role', onActivate: () => setEditingUser(user) }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), + label: ( + + the {role} role for {user.displayName} + + ), + }), + disabled: !role && 'This user has no direct role to remove', + }, + ] + }, + [siloRoleById, siloPolicy, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + + return ( + <> + {table} + {editingUser && ( + setEditingUser(null)} + /> + )} + + ) +} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx new file mode 100644 index 0000000000..da7f80a982 --- /dev/null +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -0,0 +1,274 @@ +/* + * 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 { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + deleteRole, + getListQFn, + q, + queryClient, + roleOrder, + useApiMutation, + usePrefetchedQuery, + type Group, + 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 { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { ProjectAccessEditUserSideModal } from '~/forms/project-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +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 { useQueryTable } from '~/table/QueryTable' +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 { 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 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() + +function MemberCountCell({ groupId }: { groupId: string }) { + const { data } = useQuery(q(api.userList, { query: { group: groupId, limit: ALL_ISH } })) + return data ? <>{data.items.length} : null +} + +const GroupEmptyState = () => ( + } + title="No groups" + body="No groups have been added to this silo" + /> +) + +type GroupMembersSideModalProps = { + group: Group + onDismiss: () => void +} + +function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) { + const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) + const members = data?.items ?? [] + + return ( + + {group.displayName} + + } + onDismiss={onDismiss} + animate + > + + + +
+ {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( +
+ + + Name + + + + {members.map((member: User) => ( + + {member.displayName} + + ))} + +
+ )} + + + ) +} + +export default function ProjectAccessGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) + const projectSelector = useProjectSelector() + const { project } = projectSelector + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Role updated' }) + }, + }) + + const siloRoleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [siloPolicy] + ) + const projectRoleById = useMemo( + () => new Map(projectPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [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 makeActions = useCallback( + (group: Group): MenuAction[] => { + const projectRole = projectRoleById.get(group.id) + return [ + { label: 'Change role', onActivate: () => setEditingGroup(group) }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + path: { project }, + body: deleteRole(group.id, projectPolicy), + }), + label: ( + + the {projectRole} role for {group.displayName} + + ), + }), + disabled: !projectRole && 'This group has no project role to remove', + }, + ] + }, + [projectRoleById, projectPolicy, project, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + /> + )} + {editingGroup && ( + setEditingGroup(null)} + /> + )} + + ) +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 17cf4d4538..63963c7e24 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,207 +5,19 @@ * * 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, -} from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' -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 { RouteTabs, Tab } from '~/components/RouteTabs' +import { useProjectSelector } from '~/hooks/use-params' 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 { 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} - /> - -) - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const selector = getProjectSelector(params) - await Promise.all([ - queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(projectPolicyView(selector)), - // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), - ]) - return null -} +import { pb } from '~/util/path-builder' 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 { 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 }, - // we know policy is there, otherwise there's no row to display - 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} - - ), - }), - disabled: !row.projectRole && "You don't have permission to delete this user", - }, - ]), - ], - [projectPolicy, projectSelector.project, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - return ( <> @@ -214,34 +26,13 @@ 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 && ( - 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)} /> - ) : ( - - )} + + Project Users + Project Groups + ) } diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx new file mode 100644 index 0000000000..cb6d0614e7 --- /dev/null +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -0,0 +1,283 @@ +/* + * 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 { useQueries } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + deleteRole, + getListQFn, + q, + queryClient, + roleOrder, + useApiMutation, + usePrefetchedQuery, + userRoleFromPolicies, + type Group, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { ProjectAccessEditUserSideModal } from '~/forms/project-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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 displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function ProjectAccessUsersTab() { + const [editingUser, setEditingUser] = useState(null) + const projectSelector = useProjectSelector() + const { project } = projectSelector + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Role updated' }) + }, + }) + + // direct role assignments by identity ID — siloRoleById used for via-group detection, + // projectRoleById also used for action menu + const siloRoleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [siloPolicy] + ) + const projectRoleById = useMemo( + () => new Map(projectPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [projectPolicy] + ) + + const groupMemberQueries = useQueries({ + queries: groups.items.map((g) => + q(api.userList, { query: { group: g.id, limit: ALL_ISH } }) + ), + }) + + // map from user ID to the groups they belong to + const groupsByUserId = useMemo(() => { + const map = new Map() + groups.items.forEach((group, i) => { + const members = groupMemberQueries[i]?.data?.items ?? [] + members.forEach((member) => { + map.set(member.id, [...(map.get(member.id) ?? []), group]) + }) + }) + return map + }, [groups, groupMemberQueries]) + + 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 siloRole = userRoleFromPolicies(row.original, userGroups, [siloPolicy]) + const projectRole = userRoleFromPolicies(row.original, userGroups, [ + projectPolicy, + ]) + + const viaGroups = ( + effectiveRole: ReturnType, + directRole: ReturnType, + roleMap: typeof siloRoleById + ) => { + if (!effectiveRole) return [] + if (directRole && roleOrder[directRole] <= roleOrder[effectiveRole]) return [] + return userGroups.filter((g) => { + const gr = roleMap.get(g.id) + return gr !== undefined && roleOrder[gr] <= roleOrder[effectiveRole] + }) + } + + const siloViaGroups = viaGroups( + siloRole, + siloRoleById.get(row.original.id), + siloRoleById + ) + const projectViaGroups = viaGroups( + projectRole, + projectRoleById.get(row.original.id), + projectRoleById + ) + + const roles = R.sortBy( + [ + siloRole && { + roleName: siloRole, + roleSource: 'silo' as const, + viaGroups: siloViaGroups, + }, + projectRole && { + roleName: projectRole, + roleSource: 'project' as const, + viaGroups: projectViaGroups, + }, + ].filter((r) => !!r), + (r) => roleOrder[r.roleName] + ) + if (roles.length === 0) return + return ( + + {roles.map(({ roleName, roleSource, viaGroups }, i) => ( + + + {roleSource}.{roleName} + + {i === 0 && viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} + + ))} + + ) + }, + }), + [groupsByUserId, siloPolicy, projectPolicy, siloRoleById, projectRoleById] + ) + + 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( + () => [displayNameCol, rolesCol, groupsCol, timeCreatedCol], + [rolesCol, groupsCol] + ) + + const makeActions = useCallback( + (user: User): MenuAction[] => { + const projectRole = projectRoleById.get(user.id) + return [ + { label: 'Change role', onActivate: () => setEditingUser(user) }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ path: { project }, body: deleteRole(user.id, projectPolicy) }), + label: ( + + the {projectRole} role for {user.displayName} + + ), + }), + disabled: !projectRole && 'This user has no direct project role to remove', + }, + ] + }, + [projectRoleById, projectPolicy, project, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + + return ( + <> + {table} + {editingUser && ( + setEditingUser(null)} + /> + )} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..14895baf31 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -276,7 +276,17 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloAccessPage').then(convert)}> + } /> + import('./pages/SiloAccessUsersTab').then(convert)} + /> + import('./pages/SiloAccessGroupsTab').then(convert)} + /> + {/* PROJECT */} @@ -532,7 +542,21 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} - /> + > + } /> + + import('./pages/project/access/ProjectAccessUsersTab').then(convert) + } + /> + + import('./pages/project/access/ProjectAccessGroupsTab').then(convert) + } + /> + import('./pages/project/affinity/AffinityPage').then(convert)} handle={{ crumb: 'Affinity Groups' }} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 3dcb8ddac3..7261c6bf65 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -459,6 +459,34 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/access", }, ], + "projectAccessGroups (/projects/p/access/groups)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access", + }, + ], + "projectAccessUsers (/projects/p/access/users)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access", + }, + ], "projectEdit (/projects/p/edit)": [ { "label": "Projects", @@ -575,6 +603,18 @@ exports[`breadcrumbs 2`] = ` "path": "/access", }, ], + "siloAccessGroups (/access/groups)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], + "siloAccessUsers (/access/users)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], "siloFleetRoles (/system/silos/s/fleet-roles)": [ { "label": "Silos", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 368731c03d..757095ad28 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -73,6 +73,8 @@ test('path builder', () => { "profile": "/settings/profile", "project": "/projects/p/instances", "projectAccess": "/projects/p/access", + "projectAccessGroups": "/projects/p/access/groups", + "projectAccessUsers": "/projects/p/access/users", "projectEdit": "/projects/p/edit", "projectImageEdit": "/projects/p/images/im/edit", "projectImages": "/projects/p/images", @@ -83,6 +85,8 @@ test('path builder', () => { "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", "siloAccess": "/access", + "siloAccessGroups": "/access/groups", + "siloAccessUsers": "/access/users", "siloFleetRoles": "/system/silos/s/fleet-roles", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6d55092139..67ffe1e700 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,6 +27,8 @@ export const pb = { projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, + projectAccessUsers: (params: PP.Project) => `${projectBase(params)}/access/users`, + projectAccessGroups: (params: PP.Project) => `${projectBase(params)}/access/groups`, projectImages: (params: PP.Project) => `${projectBase(params)}/images`, projectImagesNew: (params: PP.Project) => `${projectBase(params)}/images-new`, projectImageEdit: (params: PP.Image) => @@ -107,6 +109,8 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', + siloAccessUsers: () => '/access/users', + siloAccessGroups: () => '/access/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 e3ad27dec1..82f5717220 100644 --- a/mock-api/user-group.ts +++ b/mock-api/user-group.ts @@ -55,4 +55,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 d4f34d8ce5..4e7ec8f542 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -7,71 +7,56 @@ */ import { user3, user4 } from '@oxide/api-mocks' -import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' +import { closeToast, 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"]']) + + // Users tab is shown by default const table = page.locator('table') + // Hannah is in kernel-devs which has project.viewer, so she starts with silo.admin+1 await expectRowVisible(table, { Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', + Role: 'silo.admin+1', }) await expectRowVisible(table, { Name: 'Jacob Klein', - Type: 'User', Role: 'project.collaborator', }) await expectRowVisible(table, { Name: 'Herbert Marcuse', - Type: 'User', Role: 'project.limited_collaborator', }) + + // Navigate to Groups tab to check groups + await page.getByRole('tab', { name: 'Project Groups' }).click() 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"]') + // Go back to Users tab + await page.getByRole('tab', { name: 'Project Users' }).click() + + // Assign collaborator role to Simone de Beauvoir (no existing project role) + await page + .locator('role=row', { hasText: 'Simone de Beauvoir' }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') + await page.click('role=button[name="Update role"]') - // User 4 shows up in the table + // Simone de Beauvoir now has collaborator role await expectRowVisible(table, { Name: 'Simone de Beauvoir', - Type: 'User', Role: 'project.collaborator', }) @@ -93,26 +78,48 @@ test('Click through project access page', async ({ page }) => { 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 + // now remove user 3's project role. 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() - 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', - }) + // Row is still visible but project role is now empty + await expectRowVisible(table, { Name: user3.display_name, Role: '—' }) +}) + +test('Group role change propagates to user effective role', async ({ page }) => { + await page.goto('/projects/mock-project') + await page.click('role=link[name*="Access"]') + + // On the Project Users tab by default; Jane Austen has silo.collaborator via group + const table = page.locator('table') + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) + + // Verify the tooltip on her role shows it's 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') + + // Navigate to Project Groups tab and change real-estate-devs to project.admin + await page.getByRole('tab', { name: 'Project Groups' }).click() + // Wait for the groups table to load before interacting + await expectRowVisible(table, { Name: 'real-estate-devs', Role: 'silo.collaborator' }) + await table + .locator('role=row', { hasText: 'real-estate-devs' }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.click('role=menuitem[name="Change role"]') + await page.getByRole('radio', { name: /^Admin / }).click() + await page.click('role=button[name="Update role"]') + + // real-estate-devs now shows project.admin (plus silo.collaborator as +1) + await expectRowVisible(table, { Name: 'real-estate-devs', Role: 'project.admin+1' }) + await closeToast(page) + + // Navigate back to Project Users tab; Jane now has project.admin as effective role + await page.getByRole('tab', { name: 'Project Users' }).click() + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin+1' }) }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 720ef8ba19..a315ccf7f8 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,56 +5,52 @@ * * 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 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"]']) + + // Users tab is shown by default + const table = page.locator('role=table') await expectRowVisible(table, { - Name: 'real-estate-devs', - Type: 'Group', - Role: 'silo.collaborator', + Name: 'Hannah Arendt', + 'Silo Role': 'silo.admin', }) + + // Navigate to Groups tab to check groups + await page.getByRole('tab', { name: 'Silo Groups' }).click() await expectRowVisible(table, { - Name: 'Hannah Arendt', - Type: 'User', - Role: 'silo.admin', + Name: 'real-estate-devs', + 'Silo Role': 'silo.collaborator', }) - 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"]') + + // Go back to Users tab to assign a role to Jacob Klein + await page.getByRole('tab', { name: 'Silo Users' }).click() + + // Assign collaborator role to Jacob Klein via Change role action + await page + .locator('role=row', { hasText: user3.display_name }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Change role"]') + + await expectVisible(page, ['role=heading[name*="Edit role"]']) await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.click('role=button[name="Assign role"]') + await page.click('role=button[name="Update role"]') - // User 3 shows up in the table + // Jacob Klein shows up with collaborator role await expectRowVisible(table, { Name: 'Jacob Klein', - Role: 'silo.collaborator', - Type: 'User', + 'Silo Role': 'silo.collaborator', }) - // now change user 3's role from collab to viewer + // now change Jacob Klein's role from collab to viewer await page .locator('role=row', { hasText: user3.display_name }) .locator('role=button[name="Row actions"]') @@ -70,13 +66,15 @@ test('Click through silo access page', async ({ page }) => { await page.getByRole('radio', { name: /^Viewer / }).click() await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { Name: user3.display_name, Role: 'silo.viewer' }) + await expectRowVisible(table, { Name: user3.display_name, 'Silo Role': 'silo.viewer' }) - // now delete user 3 + // now remove Jacob Klein's silo role 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() + + // Row is still visible but silo role is now empty + await expectRowVisible(table, { Name: user3.display_name, 'Silo Role': '—' }) })