From c59c55ab634c067842bc2162f050601b44ef64a8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 09:52:53 -0800 Subject: [PATCH 01/45] Add access tabs showing all users and groups --- app/pages/AccessGroupsTab.tsx | 113 +++++++++ app/pages/AccessUsersTab.tsx | 44 ++++ app/pages/SiloAccessPage.tsx | 184 +------------- app/pages/SiloAccessRolesTab.tsx | 195 +++++++++++++++ .../project/access/ProjectAccessPage.tsx | 224 +---------------- .../project/access/ProjectAccessRolesTab.tsx | 234 ++++++++++++++++++ app/routes.tsx | 31 ++- .../__snapshots__/path-builder.spec.ts.snap | 60 +++++ app/util/path-builder.spec.ts | 6 + app/util/path-builder.ts | 6 + 10 files changed, 702 insertions(+), 395 deletions(-) create mode 100644 app/pages/AccessGroupsTab.tsx create mode 100644 app/pages/AccessUsersTab.tsx create mode 100644 app/pages/SiloAccessRolesTab.tsx create mode 100644 app/pages/project/access/ProjectAccessRolesTab.tsx diff --git a/app/pages/AccessGroupsTab.tsx b/app/pages/AccessGroupsTab.tsx new file mode 100644 index 0000000000..b6b6a06388 --- /dev/null +++ b/app/pages/AccessGroupsTab.tsx @@ -0,0 +1,113 @@ +/* + * 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 { useMemo, useState } from 'react' + +import { api, getListQFn, q, queryClient, type Group, type User } from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { titleCrumb } from '~/hooks/use-crumbs' +import { ButtonCell } from '~/table/cells/LinkCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ALL_ISH } from '~/util/consts' + +const groupList = getListQFn(api.groupList, {}) + +export async function clientLoader() { + await queryClient.prefetchQuery(groupList.optionsFn()) + return null +} + +export const handle = titleCrumb('Groups') + +const colHelper = createColumnHelper() +const idColumn = colHelper.accessor('id', Columns.id) + +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 ( + + {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( +
    + {members.map((member: User) => ( +
  • + {member.displayName} +
  • + ))} +
+ )} +
+ ) +} + +export default function AccessGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + + const columns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + idColumn, + ], + [setSelectedGroup] + ) + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + /> + )} + + ) +} diff --git a/app/pages/AccessUsersTab.tsx b/app/pages/AccessUsersTab.tsx new file mode 100644 index 0000000000..a857a2e8af --- /dev/null +++ b/app/pages/AccessUsersTab.tsx @@ -0,0 +1,44 @@ +/* + * 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 { api, getListQFn, queryClient, type User } from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' + +import { titleCrumb } from '~/hooks/use-crumbs' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' + +const userList = getListQFn(api.userList, {}) + +export async function clientLoader() { + await queryClient.prefetchQuery(userList.optionsFn()) + return null +} + +export const handle = titleCrumb('Users') + +const colHelper = createColumnHelper() +const columns = [ + colHelper.accessor('displayName', { header: 'Name' }), + colHelper.accessor('id', Columns.id), +] + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function AccessUsersTab() { + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + return table +} diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index eb65e95359..59c3645780 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 ( <> @@ -177,31 +27,11 @@ export default function SiloAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access]} /> - - - 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)} /> - ) : ( - - )} + + Roles + Silo Users + Silo Groups + ) } diff --git a/app/pages/SiloAccessRolesTab.tsx b/app/pages/SiloAccessRolesTab.tsx new file mode 100644 index 0000000000..d7c8d8a9a3 --- /dev/null +++ b/app/pages/SiloAccessRolesTab.tsx @@ -0,0 +1,195 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type IdentityType, + type RoleKey, +} from '@oxide/api' +import { Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { + SiloAccessAddUserSideModal, + SiloAccessEditUserSideModal, +} from '~/forms/silo-access' +import { titleCrumb } from '~/hooks/use-crumbs' +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 { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { identityTypeLabel, roleColor } from '~/util/access' +import { groupBy } from '~/util/array' + +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 +} + +export const handle = titleCrumb('Roles') + +type UserRow = { + id: string + identityType: IdentityType + name: string + siloRole: RoleKey | undefined + effectiveRole: RoleKey +} + +const colHelper = createColumnHelper() + +export default function SiloAccessRolesTab() { + 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 ( + <> + + 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)} /> + ) : ( +
+ )} + + ) +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 17cf4d4538..d27fc553b8 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 ( <> @@ -217,31 +29,11 @@ export default function ProjectAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access]} /> - - - 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)} /> - ) : ( -
- )} + + Roles + Project Users + Project Groups + ) } diff --git a/app/pages/project/access/ProjectAccessRolesTab.tsx b/app/pages/project/access/ProjectAccessRolesTab.tsx new file mode 100644 index 0000000000..86c4b9c1ba --- /dev/null +++ b/app/pages/project/access/ProjectAccessRolesTab.tsx @@ -0,0 +1,234 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import 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 { Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { + ProjectAccessAddUserSideModal, + 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 { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { identityTypeLabel, roleColor } from '~/util/access' +import { groupBy } from '~/util/array' +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 +} + +export const handle = titleCrumb('Roles') + +type UserRow = { + id: string + identityType: IdentityType + name: string + projectRole: RoleKey | undefined + roleBadges: { roleSource: string; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +export default function ProjectAccessRolesTab() { + 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 ( + <> + + 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)} /> + ) : ( +
+ )} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..d008eb659d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -276,7 +276,18 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloAccessPage').then(convert)}> + } /> + import('./pages/SiloAccessRolesTab').then(convert)} + /> + import('./pages/AccessUsersTab').then(convert)} /> + import('./pages/AccessGroupsTab').then(convert)} + /> + {/* PROJECT */} @@ -532,7 +543,23 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} - /> + > + } /> + + import('./pages/project/access/ProjectAccessRolesTab').then(convert) + } + /> + import('./pages/AccessUsersTab').then(convert)} + /> + import('./pages/AccessGroupsTab').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..3e59621a97 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -459,6 +459,48 @@ 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", + }, + ], + "projectAccessRoles (/projects/p/access/roles)": [ + { + "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 +617,24 @@ exports[`breadcrumbs 2`] = ` "path": "/access", }, ], + "siloAccessGroups (/access/groups)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], + "siloAccessRoles (/access/roles)": [ + { + "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..9e5bfc5bf5 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -73,6 +73,9 @@ test('path builder', () => { "profile": "/settings/profile", "project": "/projects/p/instances", "projectAccess": "/projects/p/access", + "projectAccessGroups": "/projects/p/access/groups", + "projectAccessRoles": "/projects/p/access/roles", + "projectAccessUsers": "/projects/p/access/users", "projectEdit": "/projects/p/edit", "projectImageEdit": "/projects/p/images/im/edit", "projectImages": "/projects/p/images", @@ -83,6 +86,9 @@ test('path builder', () => { "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", "siloAccess": "/access", + "siloAccessGroups": "/access/groups", + "siloAccessRoles": "/access/roles", + "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..a4a22b3e3d 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,6 +27,9 @@ export const pb = { projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, + projectAccessRoles: (params: PP.Project) => `${projectBase(params)}/access/roles`, + 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 +110,9 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', + siloAccessRoles: () => '/access/roles', + siloAccessUsers: () => '/access/users', + siloAccessGroups: () => '/access/groups', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, From 2df92c6f7a66533c9005fbb309b66860e7702bf3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 11:38:48 -0800 Subject: [PATCH 02/45] Add role and member count to users and groups tabs --- app/pages/AccessUsersTab.tsx | 44 ---- ...sGroupsTab.tsx => SiloAccessGroupsTab.tsx} | 48 ++++- app/pages/SiloAccessRolesTab.tsx | 6 +- app/pages/SiloAccessUsersTab.tsx | 71 +++++++ .../project/access/ProjectAccessGroupsTab.tsx | 195 ++++++++++++++++++ .../project/access/ProjectAccessRolesTab.tsx | 6 +- .../project/access/ProjectAccessUsersTab.tsx | 120 +++++++++++ app/routes.tsx | 15 +- 8 files changed, 447 insertions(+), 58 deletions(-) delete mode 100644 app/pages/AccessUsersTab.tsx rename app/pages/{AccessGroupsTab.tsx => SiloAccessGroupsTab.tsx} (68%) create mode 100644 app/pages/SiloAccessUsersTab.tsx create mode 100644 app/pages/project/access/ProjectAccessGroupsTab.tsx create mode 100644 app/pages/project/access/ProjectAccessUsersTab.tsx diff --git a/app/pages/AccessUsersTab.tsx b/app/pages/AccessUsersTab.tsx deleted file mode 100644 index a857a2e8af..0000000000 --- a/app/pages/AccessUsersTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 { api, getListQFn, queryClient, type User } from '@oxide/api' -import { Person24Icon } from '@oxide/design-system/icons/react' - -import { titleCrumb } from '~/hooks/use-crumbs' -import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' - -const userList = getListQFn(api.userList, {}) - -export async function clientLoader() { - await queryClient.prefetchQuery(userList.optionsFn()) - return null -} - -export const handle = titleCrumb('Users') - -const colHelper = createColumnHelper() -const columns = [ - colHelper.accessor('displayName', { header: 'Name' }), - colHelper.accessor('id', Columns.id), -] - -const EmptyState = () => ( - } - title="No users" - body="No users have been added to this silo" - /> -) - -export default function AccessUsersTab() { - const { table } = useQueryTable({ query: userList, columns, emptyState: }) - return table -} diff --git a/app/pages/AccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx similarity index 68% rename from app/pages/AccessGroupsTab.tsx rename to app/pages/SiloAccessGroupsTab.tsx index b6b6a06388..f10ffd6b32 100644 --- a/app/pages/AccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -9,21 +9,36 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useMemo, useState } from 'react' -import { api, getListQFn, q, queryClient, type Group, type User } from '@oxide/api' +import { + api, + getListQFn, + q, + queryClient, + usePrefetchedQuery, + type Group, + type User, +} from '@oxide/api' import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' 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 { 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 queryClient.prefetchQuery(groupList.optionsFn()) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(groupList.optionsFn()), + ]) return null } @@ -32,6 +47,11 @@ export const handle = titleCrumb('Groups') const colHelper = createColumnHelper() const idColumn = colHelper.accessor('id', Columns.id) +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 = () => ( } @@ -75,9 +95,16 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) ) } -export default function AccessGroupsTab() { +export default function SiloAccessGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const roleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [siloPolicy] + ) + const columns = useMemo( () => [ colHelper.accessor('displayName', { @@ -88,9 +115,22 @@ export default function AccessGroupsTab() { ), }), + colHelper.display({ + id: 'memberCount', + header: 'Members', + cell: ({ row }) => , + }), + colHelper.display({ + id: 'siloRole', + header: 'Silo Role', + cell: ({ row }) => { + const role = roleById.get(row.original.id) + return role ? silo.{role} : + }, + }), idColumn, ], - [setSelectedGroup] + [setSelectedGroup, roleById] ) const { table } = useQueryTable({ diff --git a/app/pages/SiloAccessRolesTab.tsx b/app/pages/SiloAccessRolesTab.tsx index d7c8d8a9a3..e3d56ec91a 100644 --- a/app/pages/SiloAccessRolesTab.tsx +++ b/app/pages/SiloAccessRolesTab.tsx @@ -35,7 +35,7 @@ import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableEmptyBox } from '~/ui/lib/Table' import { identityTypeLabel, roleColor } from '~/util/access' import { groupBy } from '~/util/array' @@ -166,9 +166,9 @@ export default function SiloAccessRolesTab() { return ( <> - +
setAddModalOpen(true)}>Add user or group - +
{siloPolicy && addModalOpen && ( setAddModalOpen(false)} diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx new file mode 100644 index 0000000000..531d888f5d --- /dev/null +++ b/app/pages/SiloAccessUsersTab.tsx @@ -0,0 +1,71 @@ +/* + * 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 } from 'react' + +import { api, getListQFn, q, queryClient, usePrefetchedQuery, type User } from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { titleCrumb } from '~/hooks/use-crumbs' +import { EmptyCell } from '~/table/cells/EmptyCell' +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 userList = getListQFn(api.userList, {}) + +export async function clientLoader() { + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(userList.optionsFn()), + ]) + return null +} + +export const handle = titleCrumb('Users') + +const colHelper = createColumnHelper() + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function SiloAccessUsersTab() { + const { data: siloPolicy } = usePrefetchedQuery(policyView) + + const roleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [siloPolicy] + ) + + const columns = useMemo( + () => [ + colHelper.accessor('displayName', { header: 'Name' }), + colHelper.display({ + id: 'siloRole', + header: 'Silo Role', + cell: ({ row }) => { + const role = roleById.get(row.original.id) + return role ? silo.{role} : + }, + }), + colHelper.accessor('id', Columns.id), + ], + [roleById] + ) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + return table +} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx new file mode 100644 index 0000000000..6d7a893c6c --- /dev/null +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -0,0 +1,195 @@ +/* + * 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 { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + getListQFn, + q, + queryClient, + roleOrder, + usePrefetchedQuery, + type Group, + type User, +} from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +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 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 idColumn = colHelper.accessor('id', Columns.id) + +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 ( + + {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( +
    + {members.map((member: User) => ( +
  • + {member.displayName} +
  • + ))} +
+ )} +
+ ) +} + +export default function ProjectAccessGroupsTab() { + const [selectedGroup, setSelectedGroup] = useState(null) + const projectSelector = useProjectSelector() + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + + 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 columns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + colHelper.display({ + id: 'memberCount', + header: 'Members', + cell: ({ row }) => , + }), + colHelper.display({ + id: 'roles', + header: () => ( + + Role + + A group's effective role for this project is the strongest role on either + the silo or 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} + + ))} + + ) + }, + }), + idColumn, + ], + [setSelectedGroup, siloRoleById, projectRoleById] + ) + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {selectedGroup && ( + setSelectedGroup(null)} + /> + )} + + ) +} diff --git a/app/pages/project/access/ProjectAccessRolesTab.tsx b/app/pages/project/access/ProjectAccessRolesTab.tsx index 86c4b9c1ba..5925843167 100644 --- a/app/pages/project/access/ProjectAccessRolesTab.tsx +++ b/app/pages/project/access/ProjectAccessRolesTab.tsx @@ -40,7 +40,7 @@ import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableEmptyBox } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { identityTypeLabel, roleColor } from '~/util/access' import { groupBy } from '~/util/array' @@ -205,9 +205,9 @@ export default function ProjectAccessRolesTab() { return ( <> - +
setAddModalOpen(true)}>Add user or group - +
{projectPolicy && addModalOpen && ( setAddModalOpen(false)} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx new file mode 100644 index 0000000000..f4b0de9ca4 --- /dev/null +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -0,0 +1,120 @@ +/* + * 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 } from 'react' +import type { LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + getListQFn, + q, + queryClient, + roleOrder, + usePrefetchedQuery, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ListPlusCell } from '~/components/ListPlusCell' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' +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 userList = getListQFn(api.userList, {}) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getProjectSelector(params) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(projectPolicyView(selector)), + queryClient.prefetchQuery(userList.optionsFn()), + ]) + return null +} + +export const handle = titleCrumb('Users') + +const colHelper = createColumnHelper() + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +export default function ProjectAccessUsersTab() { + const projectSelector = useProjectSelector() + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + + 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 columns = useMemo( + () => [ + colHelper.accessor('displayName', { header: 'Name' }), + colHelper.display({ + id: 'roles', + header: () => ( + + Role + + A user's effective role for this project is the strongest role on either + the silo or 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} + + ))} + + ) + }, + }), + colHelper.accessor('id', Columns.id), + ], + [siloRoleById, projectRoleById] + ) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + return table +} diff --git a/app/routes.tsx b/app/routes.tsx index d008eb659d..2a6fd26afd 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -282,10 +282,13 @@ export const routes = createRoutesFromElements( path="roles" lazy={() => import('./pages/SiloAccessRolesTab').then(convert)} /> - import('./pages/AccessUsersTab').then(convert)} /> + import('./pages/SiloAccessUsersTab').then(convert)} + /> import('./pages/AccessGroupsTab').then(convert)} + lazy={() => import('./pages/SiloAccessGroupsTab').then(convert)} /> @@ -553,11 +556,15 @@ export const routes = createRoutesFromElements( /> import('./pages/AccessUsersTab').then(convert)} + lazy={() => + import('./pages/project/access/ProjectAccessUsersTab').then(convert) + } /> import('./pages/AccessGroupsTab').then(convert)} + lazy={() => + import('./pages/project/access/ProjectAccessGroupsTab').then(convert) + } /> Date: Thu, 5 Mar 2026 13:28:51 -0800 Subject: [PATCH 03/45] add docs link, better microcopy --- app/pages/SiloAccessPage.tsx | 2 +- app/pages/project/access/ProjectAccessGroupsTab.tsx | 3 ++- app/pages/project/access/ProjectAccessPage.tsx | 2 +- app/pages/project/access/ProjectAccessUsersTab.tsx | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 59c3645780..6f88b10657 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -24,7 +24,7 @@ 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]} /> diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 6d7a893c6c..e91c6e1b71 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -144,7 +144,8 @@ export default function ProjectAccessGroupsTab() { Role A group's effective role for this project is the strongest role on either - the silo or project + the silo or project. Groups without an assigned role have no access to this + project. ), diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index d27fc553b8..57e1a682d5 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -26,7 +26,7 @@ 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]} /> diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index f4b0de9ca4..c43fd738e1 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -84,7 +84,8 @@ export default function ProjectAccessUsersTab() { Role A user's effective role for this project is the strongest role on either - the silo or project + the silo or project. Users without an assigned role have no access to this + project. ), From f0dfa912bb3d2b6f47f2d3afd94aa659ca9c7589 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 14:27:08 -0800 Subject: [PATCH 04/45] Roles tab is redundant, with Users / Groups present --- app/forms/access-util.tsx | 2 +- app/forms/project-access.tsx | 1 + app/forms/silo-access.tsx | 1 + app/pages/SiloAccessGroupsTab.tsx | 78 ++++-- app/pages/SiloAccessPage.tsx | 1 - app/pages/SiloAccessRolesTab.tsx | 195 --------------- app/pages/SiloAccessUsersTab.tsx | 82 +++++- .../project/access/ProjectAccessGroupsTab.tsx | 97 ++++++-- .../project/access/ProjectAccessPage.tsx | 1 - .../project/access/ProjectAccessRolesTab.tsx | 234 ------------------ .../project/access/ProjectAccessUsersTab.tsx | 73 +++++- app/routes.tsx | 14 +- .../__snapshots__/path-builder.spec.ts.snap | 20 -- app/util/path-builder.spec.ts | 2 - app/util/path-builder.ts | 2 - 15 files changed, 280 insertions(+), 523 deletions(-) delete mode 100644 app/pages/SiloAccessRolesTab.tsx delete mode 100644 app/pages/project/access/ProjectAccessRolesTab.tsx diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 1987be8408..bae8e4bf21 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 index f10ffd6b32..05bd3fbc4f 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -7,13 +7,15 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { api, + deleteRole, getListQFn, q, queryClient, + useApiMutation, usePrefetchedQuery, type Group, type User, @@ -22,10 +24,13 @@ import { 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 { Columns } from '~/table/columns/common' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { roleColor } from '~/util/access' @@ -45,7 +50,6 @@ export async function clientLoader() { export const handle = titleCrumb('Groups') const colHelper = createColumnHelper() -const idColumn = colHelper.accessor('id', Columns.id) function MemberCountCell({ groupId }: { groupId: string }) { const { data } = useQuery(q(api.userList, { query: { group: groupId, limit: ALL_ISH } })) @@ -97,15 +101,33 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) export default function SiloAccessGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) const { data: siloPolicy } = usePrefetchedQuery(policyView) - const roleById = useMemo( + 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 columns = useMemo( + 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', @@ -120,19 +142,35 @@ export default function SiloAccessGroupsTab() { header: 'Members', cell: ({ row }) => , }), - colHelper.display({ - id: 'siloRole', - header: 'Silo Role', - cell: ({ row }) => { - const role = roleById.get(row.original.id) - return role ? silo.{role} : - }, - }), - idColumn, + siloRoleCol, ], - [setSelectedGroup, roleById] + [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, @@ -148,6 +186,16 @@ export default function SiloAccessGroupsTab() { onDismiss={() => setSelectedGroup(null)} /> )} + {editingGroup && ( + setEditingGroup(null)} + /> + )} ) } diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 6f88b10657..382d168b3a 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -28,7 +28,6 @@ export default function SiloAccessPage() { /> - Roles Silo Users Silo Groups diff --git a/app/pages/SiloAccessRolesTab.tsx b/app/pages/SiloAccessRolesTab.tsx deleted file mode 100644 index e3d56ec91a..0000000000 --- a/app/pages/SiloAccessRolesTab.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo, useState } from 'react' - -import { - api, - byGroupThenName, - deleteRole, - getEffectiveRole, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - useUserRows, - type IdentityType, - type RoleKey, -} from '@oxide/api' -import { Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { titleCrumb } from '~/hooks/use-crumbs' -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 { TableEmptyBox } from '~/ui/lib/Table' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' - -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 -} - -export const handle = titleCrumb('Roles') - -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} - -const colHelper = createColumnHelper() - -export default function SiloAccessRolesTab() { - 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 ( - <> -
- 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)} /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 531d888f5d..6ab1c2d2dc 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -6,15 +6,27 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' -import { api, getListQFn, q, queryClient, usePrefetchedQuery, type User } from '@oxide/api' +import { + api, + deleteRole, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + 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 { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' +import { confirmDelete } from '~/stores/confirm-delete' import { EmptyCell } from '~/table/cells/EmptyCell' -import { Columns } from '~/table/columns/common' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { roleColor } from '~/util/access' @@ -34,6 +46,8 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() +const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) + const EmptyState = () => ( } @@ -43,29 +57,73 @@ const EmptyState = () => ( ) export default function SiloAccessUsersTab() { + const [editingUser, setEditingUser] = useState(null) + const { data: siloPolicy } = usePrefetchedQuery(policyView) - const roleById = useMemo( + 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 columns = useMemo( - () => [ - colHelper.accessor('displayName', { header: 'Name' }), + const siloRoleCol = useMemo( + () => colHelper.display({ id: 'siloRole', header: 'Silo Role', cell: ({ row }) => { - const role = roleById.get(row.original.id) + const role = siloRoleById.get(row.original.id) return role ? silo.{role} : }, }), - colHelper.accessor('id', Columns.id), - ], - [roleById] + [siloRoleById] + ) + + const staticColumns = useMemo(() => [displayNameCol, siloRoleCol], [siloRoleCol]) + + 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 role to remove', + }, + ] + }, + [siloRoleById, siloPolicy, updatePolicy] ) + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ query: userList, columns, emptyState: }) - return table + + return ( + <> + {table} + {editingUser && ( + setEditingUser(null)} + /> + )} + + ) } diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index e91c6e1b71..e2e02f5b70 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -7,16 +7,18 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useMemo, useState } from 'react' +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, @@ -25,12 +27,16 @@ import { 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 { Columns } from '~/table/columns/common' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TipIcon } from '~/ui/lib/TipIcon' @@ -56,7 +62,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const handle = titleCrumb('Groups') const colHelper = createColumnHelper() -const idColumn = colHelper.accessor('id', Columns.id) function MemberCountCell({ groupId }: { groupId: string }) { const { data } = useQuery(q(api.userList, { query: { group: groupId, limit: ALL_ISH } })) @@ -108,11 +113,20 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) 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] @@ -122,21 +136,8 @@ export default function ProjectAccessGroupsTab() { [projectPolicy] ) - const columns = useMemo( - () => [ - colHelper.accessor('displayName', { - header: 'Name', - cell: (info) => ( - setSelectedGroup(info.row.original)}> - {info.getValue()} - - ), - }), - colHelper.display({ - id: 'memberCount', - header: 'Members', - cell: ({ row }) => , - }), + const rolesCol = useMemo( + () => colHelper.display({ id: 'roles', header: () => ( @@ -171,11 +172,57 @@ export default function ProjectAccessGroupsTab() { ) }, }), - idColumn, + [siloRoleById, projectRoleById] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + colHelper.display({ + id: 'memberCount', + header: 'Members', + cell: ({ row }) => , + }), + rolesCol, ], - [setSelectedGroup, siloRoleById, projectRoleById] + [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, @@ -191,6 +238,16 @@ export default function ProjectAccessGroupsTab() { onDismiss={() => setSelectedGroup(null)} /> )} + {editingGroup && ( + setEditingGroup(null)} + /> + )} ) } diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 57e1a682d5..63963c7e24 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -30,7 +30,6 @@ export default function ProjectAccessPage() { /> - Roles Project Users Project Groups diff --git a/app/pages/project/access/ProjectAccessRolesTab.tsx b/app/pages/project/access/ProjectAccessRolesTab.tsx deleted file mode 100644 index 5925843167..0000000000 --- a/app/pages/project/access/ProjectAccessRolesTab.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo, useState } from 'react' -import 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 { Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - 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 { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableEmptyBox } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' -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 -} - -export const handle = titleCrumb('Roles') - -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() - -export default function ProjectAccessRolesTab() { - 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 ( - <> -
- 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)} /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index c43fd738e1..13e0602f02 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -6,27 +6,33 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useMemo } from 'react' +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 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 { Columns } from '~/table/columns/common' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TipIcon } from '~/ui/lib/TipIcon' @@ -52,6 +58,8 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() +const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) + const EmptyState = () => ( } @@ -61,10 +69,20 @@ const EmptyState = () => ( ) 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 { 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] @@ -74,9 +92,8 @@ export default function ProjectAccessUsersTab() { [projectPolicy] ) - const columns = useMemo( - () => [ - colHelper.accessor('displayName', { header: 'Name' }), + const rolesCol = useMemo( + () => colHelper.display({ id: 'roles', header: () => ( @@ -111,11 +128,51 @@ export default function ProjectAccessUsersTab() { ) }, }), - colHelper.accessor('id', Columns.id), - ], [siloRoleById, projectRoleById] ) + const staticColumns = useMemo(() => [displayNameCol, rolesCol], [rolesCol]) + + 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 project role to remove', + }, + ] + }, + [projectRoleById, projectPolicy, project, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ query: userList, columns, emptyState: }) - return table + + return ( + <> + {table} + {editingUser && ( + setEditingUser(null)} + /> + )} + + ) } diff --git a/app/routes.tsx b/app/routes.tsx index 2a6fd26afd..14895baf31 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -277,11 +277,7 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)}> - } /> - import('./pages/SiloAccessRolesTab').then(convert)} - /> + } /> import('./pages/SiloAccessUsersTab').then(convert)} @@ -547,13 +543,7 @@ export const routes = createRoutesFromElements( path="access" lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} > - } /> - - import('./pages/project/access/ProjectAccessRolesTab').then(convert) - } - /> + } /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 3e59621a97..7261c6bf65 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -473,20 +473,6 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/access", }, ], - "projectAccessRoles (/projects/p/access/roles)": [ - { - "label": "Projects", - "path": "/projects", - }, - { - "label": "p", - "path": "/projects/p/instances", - }, - { - "label": "Project Access", - "path": "/projects/p/access", - }, - ], "projectAccessUsers (/projects/p/access/users)": [ { "label": "Projects", @@ -623,12 +609,6 @@ exports[`breadcrumbs 2`] = ` "path": "/access", }, ], - "siloAccessRoles (/access/roles)": [ - { - "label": "Silo Access", - "path": "/access", - }, - ], "siloAccessUsers (/access/users)": [ { "label": "Silo Access", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9e5bfc5bf5..757095ad28 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -74,7 +74,6 @@ test('path builder', () => { "project": "/projects/p/instances", "projectAccess": "/projects/p/access", "projectAccessGroups": "/projects/p/access/groups", - "projectAccessRoles": "/projects/p/access/roles", "projectAccessUsers": "/projects/p/access/users", "projectEdit": "/projects/p/edit", "projectImageEdit": "/projects/p/images/im/edit", @@ -87,7 +86,6 @@ test('path builder', () => { "silo": "/system/silos/s/idps", "siloAccess": "/access", "siloAccessGroups": "/access/groups", - "siloAccessRoles": "/access/roles", "siloAccessUsers": "/access/users", "siloFleetRoles": "/system/silos/s/fleet-roles", "siloIdps": "/system/silos/s/idps", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index a4a22b3e3d..67ffe1e700 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,7 +27,6 @@ export const pb = { projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, - projectAccessRoles: (params: PP.Project) => `${projectBase(params)}/access/roles`, projectAccessUsers: (params: PP.Project) => `${projectBase(params)}/access/users`, projectAccessGroups: (params: PP.Project) => `${projectBase(params)}/access/groups`, projectImages: (params: PP.Project) => `${projectBase(params)}/images`, @@ -110,7 +109,6 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', - siloAccessRoles: () => '/access/roles', siloAccessUsers: () => '/access/users', siloAccessGroups: () => '/access/groups', siloImages: () => '/images', From f8785c88c32e4da77b55777aeb2d45ed2bd80264 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 15:45:30 -0800 Subject: [PATCH 05/45] Add tooltip to show source of role when it comes from group --- app/pages/SiloAccessGroupsTab.tsx | 4 +- app/pages/SiloAccessUsersTab.tsx | 91 ++++++++++++- .../project/access/ProjectAccessGroupsTab.tsx | 4 +- .../project/access/ProjectAccessUsersTab.tsx | 127 ++++++++++++++++-- mock-api/user-group.ts | 4 + 5 files changed, 209 insertions(+), 21 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 05bd3fbc4f..17bad29dc8 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -137,12 +137,12 @@ export default function SiloAccessGroupsTab() { ), }), + siloRoleCol, colHelper.display({ id: 'memberCount', - header: 'Members', + header: 'Users', cell: ({ row }) => , }), - siloRoleCol, ], [siloRoleCol] ) diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 6ab1c2d2dc..48792d41e9 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useQueries } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' @@ -14,14 +15,18 @@ import { 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' @@ -29,15 +34,22 @@ import { EmptyCell } from '~/table/cells/EmptyCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' 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 } @@ -60,30 +72,99 @@ 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 role = siloRoleById.get(row.original.id) - return role ? silo.{role} : + 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} + + ))} + + )} +
+ ) }, }), - [siloRoleById] + [groupsByUserId, siloPolicy, siloRoleById] ) - const staticColumns = useMemo(() => [displayNameCol, siloRoleCol], [siloRoleCol]) + 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], + [siloRoleCol, groupsCol] + ) const makeActions = useCallback( (user: User): MenuAction[] => { @@ -100,7 +181,7 @@ export default function SiloAccessUsersTab() { ), }), - disabled: !role && 'This user has no role to remove', + disabled: !role && 'This user has no direct role to remove', }, ] }, diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index e2e02f5b70..14b16aa16b 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -185,12 +185,12 @@ export default function ProjectAccessGroupsTab() { ), }), + rolesCol, colHelper.display({ id: 'memberCount', - header: 'Members', + header: 'Users', cell: ({ row }) => , }), - rolesCol, ], [rolesCol] ) diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 13e0602f02..297a46be0b 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -5,6 +5,7 @@ * * 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' @@ -19,6 +20,8 @@ import { roleOrder, useApiMutation, usePrefetchedQuery, + userRoleFromPolicies, + type Group, type User, } from '@oxide/api' import { Person24Icon } from '@oxide/design-system/icons/react' @@ -37,19 +40,25 @@ 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 } @@ -75,6 +84,7 @@ export default function ProjectAccessUsersTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: groups } = usePrefetchedQuery(groupListAll) const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { @@ -83,6 +93,8 @@ export default function ProjectAccessUsersTab() { }, }) + // 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] @@ -92,6 +104,24 @@ export default function ProjectAccessUsersTab() { [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({ @@ -101,37 +131,110 @@ export default function ProjectAccessUsersTab() { Role A user's effective role for this project is the strongest role on either - the silo or project. Users without an assigned role have no access to this - project. + the silo or project, including roles inherited via group membership. Users + without any 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 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 }, - projectRole && { roleName: projectRole, roleSource: 'project' as const }, + 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 }) => ( - - {roleSource}.{roleName} + {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} ))} ) }, }), - [siloRoleById, projectRoleById] + [groupsByUserId] ) - const staticColumns = useMemo(() => [displayNameCol, rolesCol], [rolesCol]) + const staticColumns = useMemo( + () => [displayNameCol, rolesCol, groupsCol], + [rolesCol, groupsCol] + ) const makeActions = useCallback( (user: User): MenuAction[] => { @@ -149,7 +252,7 @@ export default function ProjectAccessUsersTab() { ), }), - disabled: !projectRole && 'This user has no project role to remove', + disabled: !projectRole && 'This user has no direct project role to remove', }, ] }, diff --git a/mock-api/user-group.ts b/mock-api/user-group.ts index 312b05439c..75b6e0160f 100644 --- a/mock-api/user-group.ts +++ b/mock-api/user-group.ts @@ -49,4 +49,8 @@ export const groupMemberships: GroupMembership[] = [ userId: user5.id, groupId: userGroup3.id, }, + { + userId: user1.id, + groupId: userGroup2.id, + }, ] From 1a6461ce64ca662de8ef584d64d713c1430d654c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 16:36:23 -0800 Subject: [PATCH 06/45] Update tests --- test/e2e/project-access.e2e.ts | 109 ++++++++++++++++++--------------- test/e2e/silo-access.e2e.ts | 70 ++++++++++----------- 2 files changed, 92 insertions(+), 87 deletions(-) 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': '—' }) }) From 2b61971658193ace62f346ca0486f3f64572aa33 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 08:25:17 -0800 Subject: [PATCH 07/45] Updated styling on sidebars --- app/pages/SiloAccessGroupsTab.tsx | 44 ++++++++++++------- .../project/access/ProjectAccessGroupsTab.tsx | 44 ++++++++++++------- 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 17bad29dc8..ef9a164204 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -33,6 +33,8 @@ import { ButtonCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { Table } from '~/ui/lib/Table' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -80,21 +82,33 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) onDismiss={onDismiss} animate > - {members.length === 0 ? ( - } - title="No members" - body="This group has no members" - /> - ) : ( -
    - {members.map((member: User) => ( -
  • - {member.displayName} -
  • - ))} -
- )} + + + +
+ {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( +
+ + + Name + + + + {members.map((member: User) => ( + + {member.displayName} + + ))} + +
+ )} + ) } diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 14b16aa16b..458a9a6d28 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -39,6 +39,8 @@ import { ButtonCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { Table } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -92,21 +94,33 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) onDismiss={onDismiss} animate > - {members.length === 0 ? ( - } - title="No members" - body="This group has no members" - /> - ) : ( -
    - {members.map((member: User) => ( -
  • - {member.displayName} -
  • - ))} -
- )} + + + +
+ {members.length === 0 ? ( + } + title="No members" + body="This group has no members" + /> + ) : ( + + + + Name + + + + {members.map((member: User) => ( + + {member.displayName} + + ))} + +
+ )} +
) } From b959e59f05ebb7026d65809c5f98cf7368d8cc5d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 08:34:01 -0800 Subject: [PATCH 08/45] Use consistent subtitle style for group sidebars --- app/pages/SiloAccessGroupsTab.tsx | 9 +++++++-- app/pages/project/access/ProjectAccessGroupsTab.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index ef9a164204..2ea095a19c 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -20,7 +20,7 @@ import { type Group, type User, } from '@oxide/api' -import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' @@ -34,6 +34,7 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' 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' @@ -78,7 +79,11 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) return ( + {group.displayName} + + } onDismiss={onDismiss} animate > diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 458a9a6d28..977c5b78d0 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -23,7 +23,7 @@ import { type Group, type User, } from '@oxide/api' -import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' @@ -40,6 +40,7 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' 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' @@ -90,7 +91,11 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) return ( + {group.displayName} + + } onDismiss={onDismiss} animate > From 7f055265e4c3a109f95c427359e675e6b58b6848 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 16:37:20 -0800 Subject: [PATCH 09/45] Add time_created column --- app/pages/SiloAccessGroupsTab.tsx | 2 ++ app/pages/SiloAccessUsersTab.tsx | 4 +++- app/pages/project/access/ProjectAccessGroupsTab.tsx | 2 ++ app/pages/project/access/ProjectAccessUsersTab.tsx | 4 +++- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 2ea095a19c..4de4ffd936 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -31,6 +31,7 @@ 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' @@ -162,6 +163,7 @@ export default function SiloAccessGroupsTab() { header: 'Users', cell: ({ row }) => , }), + colHelper.accessor('timeCreated', Columns.timeCreated), ], [siloRoleCol] ) diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 48792d41e9..24cc520974 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -32,6 +32,7 @@ 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' @@ -59,6 +60,7 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) const EmptyState = () => ( [displayNameCol, siloRoleCol, groupsCol], + () => [displayNameCol, siloRoleCol, groupsCol, timeCreatedCol], [siloRoleCol, groupsCol] ) diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 977c5b78d0..da7f80a982 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -37,6 +37,7 @@ 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' @@ -210,6 +211,7 @@ export default function ProjectAccessGroupsTab() { header: 'Users', cell: ({ row }) => , }), + colHelper.accessor('timeCreated', Columns.timeCreated), ], [rolesCol] ) diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 297a46be0b..cb6d0614e7 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -36,6 +36,7 @@ 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' @@ -68,6 +69,7 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) const EmptyState = () => ( [displayNameCol, rolesCol, groupsCol], + () => [displayNameCol, rolesCol, groupsCol, timeCreatedCol], [rolesCol, groupsCol] ) From 7d966e2d74b9b54460e0f53fc29c34aaf379f605 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Mar 2026 22:02:28 -0800 Subject: [PATCH 10/45] stub out user sidebars for more info, tabs --- app/pages/SiloAccessGroupsTab.tsx | 1 + app/pages/SiloAccessUsersTab.tsx | 51 +++++++++++++++++-- .../project/access/ProjectAccessGroupsTab.tsx | 1 + .../project/access/ProjectAccessUsersTab.tsx | 51 +++++++++++++++++-- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 4de4ffd936..3b12dcac18 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -90,6 +90,7 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) > +
{members.length === 0 ? ( diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 24cc520974..38464ca3f7 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -22,19 +22,23 @@ import { type Group, type User, } from '@oxide/api' -import { Person24Icon } from '@oxide/design-system/icons/react' +import { Person16Icon, Person24Icon } 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 { 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 { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -59,9 +63,33 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() -const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) +type UserDetailsSideModalProps = { + user: User + onDismiss: () => void +} + +function UserDetailsSideModal({ user, onDismiss }: UserDetailsSideModalProps) { + return ( + + {user.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + + + ) +} + const EmptyState = () => ( } @@ -71,6 +99,7 @@ const EmptyState = () => ( ) export default function SiloAccessUsersTab() { + const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) const { data: siloPolicy } = usePrefetchedQuery(policyView) @@ -163,9 +192,22 @@ export default function SiloAccessUsersTab() { [groupsByUserId] ) + const displayNameCol = useMemo( + () => + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + [] + ) + const staticColumns = useMemo( () => [displayNameCol, siloRoleCol, groupsCol, timeCreatedCol], - [siloRoleCol, groupsCol] + [displayNameCol, siloRoleCol, groupsCol] ) const makeActions = useCallback( @@ -197,6 +239,9 @@ export default function SiloAccessUsersTab() { return ( <> {table} + {selectedUser && ( + setSelectedUser(null)} /> + )} {editingUser && ( +
{members.length === 0 ? ( diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index cb6d0614e7..b20322ee36 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -24,9 +24,10 @@ import { type Group, type User, } from '@oxide/api' -import { Person24Icon } from '@oxide/design-system/icons/react' +import { Person16Icon, Person24Icon } 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' @@ -35,10 +36,13 @@ 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 { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -68,9 +72,33 @@ export const handle = titleCrumb('Users') const colHelper = createColumnHelper() -const displayNameCol = colHelper.accessor('displayName', { header: 'Name' }) const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) +type UserDetailsSideModalProps = { + user: User + onDismiss: () => void +} + +function UserDetailsSideModal({ user, onDismiss }: UserDetailsSideModalProps) { + return ( + + {user.displayName} + + } + onDismiss={onDismiss} + animate + > + + + + + + ) +} + const EmptyState = () => ( } @@ -80,6 +108,7 @@ const EmptyState = () => ( ) export default function ProjectAccessUsersTab() { + const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) const projectSelector = useProjectSelector() const { project } = projectSelector @@ -233,9 +262,22 @@ export default function ProjectAccessUsersTab() { [groupsByUserId] ) + const displayNameCol = useMemo( + () => + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + [] + ) + const staticColumns = useMemo( () => [displayNameCol, rolesCol, groupsCol, timeCreatedCol], - [rolesCol, groupsCol] + [displayNameCol, rolesCol, groupsCol] ) const makeActions = useCallback( @@ -268,6 +310,9 @@ export default function ProjectAccessUsersTab() { return ( <> {table} + {selectedUser && ( + setSelectedUser(null)} /> + )} {editingUser && ( Date: Wed, 11 Mar 2026 12:11:06 -0700 Subject: [PATCH 11/45] Add additional data to User and Group sidebars --- app/pages/SiloAccessGroupsTab.tsx | 64 +++++++- app/pages/SiloAccessUsersTab.tsx | 111 +++++++++++++- .../project/access/ProjectAccessGroupsTab.tsx | 81 ++++++++++- .../project/access/ProjectAccessUsersTab.tsx | 137 +++++++++++++++++- 4 files changed, 383 insertions(+), 10 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 3b12dcac18..d3f4c3d243 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -15,9 +15,12 @@ import { getListQFn, q, queryClient, + roleOrder, useApiMutation, usePrefetchedQuery, type Group, + type Policy, + type RoleKey, type User, } from '@oxide/api' import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' @@ -71,15 +74,39 @@ const GroupEmptyState = () => ( type GroupMembersSideModalProps = { group: Group onDismiss: () => void + siloPolicy: Policy } -function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) { +type SiloGroupRoleEntry = { + scope: 'silo' + roleName: RoleKey + source: { type: 'direct' } +} + +function GroupMembersSideModal({ + group, + onDismiss, + siloPolicy, +}: GroupMembersSideModalProps) { const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) const members = data?.items ?? [] + const roleEntries: SiloGroupRoleEntry[] = [] + const directAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (directAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: directAssignment.roleName, + source: { type: 'direct' }, + }) + } + roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + return ( {group.displayName} @@ -92,6 +119,36 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName }, i) => ( + + + + {scope}.{roleName} + + + Assigned + + )) + )} + +
+
{members.length === 0 ? ( - Name + Members @@ -206,6 +263,7 @@ export default function SiloAccessGroupsTab() { setSelectedGroup(null)} + siloPolicy={siloPolicy} /> )} {editingGroup && ( diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 38464ca3f7..9e240326af 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -20,6 +20,8 @@ import { usePrefetchedQuery, userRoleFromPolicies, type Group, + type Policy, + type RoleKey, type User, } from '@oxide/api' import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' @@ -39,6 +41,7 @@ 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' @@ -68,9 +71,50 @@ const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) type UserDetailsSideModalProps = { user: User onDismiss: () => void + siloPolicy: Policy + userGroups: Group[] } -function UserDetailsSideModal({ user, onDismiss }: UserDetailsSideModalProps) { +type SiloRoleEntry = { + scope: 'silo' + roleName: RoleKey + source: { type: 'direct' } | { type: 'group'; group: Group } +} + +function UserDetailsSideModal({ + user, + onDismiss, + siloPolicy, + userGroups, +}: UserDetailsSideModalProps) { + const roleEntries: SiloRoleEntry[] = [] + + const directAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === user.id + ) + if (directAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: directAssignment.roleName, + source: { type: 'direct' }, + }) + } + + for (const group of userGroups) { + const groupAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (groupAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: groupAssignment.roleName, + source: { type: 'group', group }, + }) + } + } + + roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + return ( +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' && 'Assigned'} + {source.type === 'group' && + `Inherited from ${source.group.displayName}`} + + + )) + )} + +
+
+
+ + + + Groups + + + + {userGroups.length === 0 ? ( + + + Not a member of any groups + + + ) : ( + userGroups.map((group) => ( + + {group.displayName} + + )) + )} + +
+
) } @@ -240,7 +342,12 @@ export default function SiloAccessUsersTab() { <> {table} {selectedUser && ( - setSelectedUser(null)} /> + setSelectedUser(null)} + siloPolicy={siloPolicy} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> )} {editingUser && ( ( type GroupMembersSideModalProps = { group: Group onDismiss: () => void + siloPolicy: Policy + projectPolicy: Policy } -function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) { +type ProjectGroupRoleEntry = { + scope: 'silo' | 'project' + roleName: RoleKey + source: { type: 'direct' } | { type: 'silo' } +} + +function GroupMembersSideModal({ + group, + onDismiss, + siloPolicy, + projectPolicy, +}: GroupMembersSideModalProps) { const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) const members = data?.items ?? [] + const roleEntries: ProjectGroupRoleEntry[] = [] + + const directProjectAssignment = projectPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (directProjectAssignment) { + roleEntries.push({ + scope: 'project', + roleName: directProjectAssignment.roleName, + source: { type: 'direct' }, + }) + } + + const directSiloAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (directSiloAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: directSiloAssignment.roleName, + source: { type: 'silo' }, + }) + } + + roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + return ( {group.displayName} @@ -104,6 +145,38 @@ function GroupMembersSideModal({ group, onDismiss }: GroupMembersSideModalProps) +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' ? 'Assigned' : 'Inherited from silo'} + + + )) + )} + +
+
{members.length === 0 ? ( - Name + Members @@ -258,6 +331,8 @@ export default function ProjectAccessGroupsTab() { setSelectedGroup(null)} + siloPolicy={siloPolicy} + projectPolicy={projectPolicy} /> )} {editingGroup && ( diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index b20322ee36..d902d81691 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -22,6 +22,8 @@ import { usePrefetchedQuery, userRoleFromPolicies, type Group, + type Policy, + type RoleKey, type User, } from '@oxide/api' import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' @@ -43,6 +45,7 @@ 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' @@ -77,9 +80,74 @@ const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) type UserDetailsSideModalProps = { user: User onDismiss: () => void + siloPolicy: Policy + projectPolicy: Policy + userGroups: Group[] } -function UserDetailsSideModal({ user, onDismiss }: UserDetailsSideModalProps) { +type ProjectRoleEntry = { + scope: 'silo' | 'project' + roleName: RoleKey + source: { type: 'direct' } | { type: 'silo' } | { type: 'group'; group: Group } +} + +function UserDetailsSideModal({ + user, + onDismiss, + siloPolicy, + projectPolicy, + userGroups, +}: UserDetailsSideModalProps) { + const roleEntries: ProjectRoleEntry[] = [] + + const directProjectAssignment = projectPolicy.roleAssignments.find( + (ra) => ra.identityId === user.id + ) + if (directProjectAssignment) { + roleEntries.push({ + scope: 'project', + roleName: directProjectAssignment.roleName, + source: { type: 'direct' }, + }) + } + + const directSiloAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === user.id + ) + if (directSiloAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: directSiloAssignment.roleName, + source: { type: 'silo' }, + }) + } + + for (const group of userGroups) { + const groupProjectAssignment = projectPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (groupProjectAssignment) { + roleEntries.push({ + scope: 'project', + roleName: groupProjectAssignment.roleName, + source: { type: 'group', group }, + }) + } + + const groupSiloAssignment = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === group.id + ) + if (groupSiloAssignment) { + roleEntries.push({ + scope: 'silo', + roleName: groupSiloAssignment.roleName, + source: { type: 'group', group }, + }) + } + } + + roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + return ( +
+ + + + Role + Source + + + + {roleEntries.length === 0 ? ( + + + No roles assigned + + + ) : ( + roleEntries.map(({ scope, roleName, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' && 'Assigned'} + {source.type === 'silo' && 'Inherited from silo'} + {source.type === 'group' && + `Inherited from ${source.group.displayName}`} + + + )) + )} + +
+
+
+ + + + Groups + + + + {userGroups.length === 0 ? ( + + + Not a member of any groups + + + ) : ( + userGroups.map((group) => ( + + {group.displayName} + + )) + )} + +
+
) } @@ -311,7 +438,13 @@ export default function ProjectAccessUsersTab() { <> {table} {selectedUser && ( - setSelectedUser(null)} /> + setSelectedUser(null)} + siloPolicy={siloPolicy} + projectPolicy={projectPolicy} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> )} {editingUser && ( Date: Wed, 11 Mar 2026 18:22:25 -0700 Subject: [PATCH 12/45] Updates to sidebar and roles table --- app/api/roles.ts | 30 ++++ app/pages/SiloAccessGroupsTab.tsx | 2 +- app/pages/SiloAccessUsersTab.tsx | 7 +- .../project/access/ProjectAccessGroupsTab.tsx | 2 +- .../project/access/ProjectAccessUsersTab.tsx | 152 ++++-------------- 5 files changed, 62 insertions(+), 131 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 70c12ecaaf..d56a43ae87 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -186,3 +186,33 @@ 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 +} diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index d3f4c3d243..5786da5786 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -106,7 +106,7 @@ function GroupMembersSideModal({ return ( {group.displayName} diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 9e240326af..62a44a0b89 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -117,7 +117,7 @@ function UserDetailsSideModal({ return ( {user.displayName} @@ -155,8 +155,7 @@ function UserDetailsSideModal({ {source.type === 'direct' && 'Assigned'} - {source.type === 'group' && - `Inherited from ${source.group.displayName}`} + {source.type === 'group' && `via ${source.group.displayName}`} )) @@ -263,7 +262,7 @@ export default function SiloAccessUsersTab() { {viaGroups.map((g, i) => ( {i > 0 && ', '} - {g.displayName} + {g.displayName} ))} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 2441b0cd75..e00eac95b1 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -132,7 +132,7 @@ function GroupMembersSideModal({ return ( {group.displayName} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index d902d81691..8b9ae9011b 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -20,10 +20,9 @@ import { roleOrder, useApiMutation, usePrefetchedQuery, - userRoleFromPolicies, + userScopedRoleEntries, type Group, type Policy, - type RoleKey, type User, } from '@oxide/api' import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' @@ -85,12 +84,6 @@ type UserDetailsSideModalProps = { userGroups: Group[] } -type ProjectRoleEntry = { - scope: 'silo' | 'project' - roleName: RoleKey - source: { type: 'direct' } | { type: 'silo' } | { type: 'group'; group: Group } -} - function UserDetailsSideModal({ user, onDismiss, @@ -98,59 +91,18 @@ function UserDetailsSideModal({ projectPolicy, userGroups, }: UserDetailsSideModalProps) { - const roleEntries: ProjectRoleEntry[] = [] - - const directProjectAssignment = projectPolicy.roleAssignments.find( - (ra) => ra.identityId === user.id + const roleEntries = userScopedRoleEntries(user.id, userGroups, [ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ]).sort( + (a, b) => + roleOrder[a.roleName] - roleOrder[b.roleName] || + (a.scope === 'silo' ? -1 : 1) - (b.scope === 'silo' ? -1 : 1) ) - if (directProjectAssignment) { - roleEntries.push({ - scope: 'project', - roleName: directProjectAssignment.roleName, - source: { type: 'direct' }, - }) - } - - const directSiloAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === user.id - ) - if (directSiloAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: directSiloAssignment.roleName, - source: { type: 'silo' }, - }) - } - - for (const group of userGroups) { - const groupProjectAssignment = projectPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (groupProjectAssignment) { - roleEntries.push({ - scope: 'project', - roleName: groupProjectAssignment.roleName, - source: { type: 'group', group }, - }) - } - - const groupSiloAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (groupSiloAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: groupSiloAssignment.roleName, - source: { type: 'group', group }, - }) - } - } - - roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) return ( {user.displayName} @@ -188,9 +140,7 @@ function UserDetailsSideModal({ {source.type === 'direct' && 'Assigned'} - {source.type === 'silo' && 'Inherited from silo'} - {source.type === 'group' && - `Inherited from ${source.group.displayName}`} + {source.type === 'group' && `via ${source.group.displayName}`} )) @@ -251,12 +201,7 @@ export default function ProjectAccessUsersTab() { }, }) - // 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] - ) + // direct role assignments by identity ID, used for action menu const projectRoleById = useMemo( () => new Map(projectPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), [projectPolicy] @@ -296,68 +241,27 @@ export default function ProjectAccessUsersTab() { ), 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] + 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(({ roleName, roleSource, viaGroups }, i) => ( - + {roles.map(({ scope, roleName, source }, i) => ( + - {roleSource}.{roleName} + {scope}.{roleName} - {i === 0 && viaGroups.length > 0 && ( - - via{' '} - {viaGroups.map((g, i) => ( - - {i > 0 && ', '} - {g.displayName} - - ))} - + {i > 0 && source.type === 'group' && ` via ${source.group.displayName}`} + {i === 0 && source.type === 'group' && ( + via {source.group.displayName} )} ))} @@ -365,7 +269,7 @@ export default function ProjectAccessUsersTab() { ) }, }), - [groupsByUserId, siloPolicy, projectPolicy, siloRoleById, projectRoleById] + [groupsByUserId, siloPolicy, projectPolicy] ) const groupsCol = useMemo( @@ -378,9 +282,7 @@ export default function ProjectAccessUsersTab() { return ( {userGroups.map((g) => ( - - {g.displayName} - + {g.displayName} ))} ) From 35c79bd72582d2b8f2f971e2ad774b44488df9fb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 11 Mar 2026 20:39:56 -0700 Subject: [PATCH 13/45] Refactor / consolidate --- app/api/roles.ts | 38 ++++++++++++++++++- app/pages/SiloAccessGroupsTab.tsx | 6 +-- app/pages/SiloAccessUsersTab.tsx | 20 +--------- .../project/access/ProjectAccessGroupsTab.tsx | 6 +-- .../project/access/ProjectAccessUsersTab.tsx | 20 +--------- app/table/cells/MemberCountCell.tsx | 16 ++++++++ 6 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 app/table/cells/MemberCountCell.tsx diff --git a/app/api/roles.ts b/app/api/roles.ts index d56a43ae87..962207a07f 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -11,10 +11,19 @@ * layer and not in app/ because we are experimenting with it to decide whether * it belongs in the API proper. */ +import { useQueries } from '@tanstack/react-query' import { useMemo } from 'react' import * as R from 'remeda' -import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' +import { ALL_ISH } from '~/util/consts' + +import type { + FleetRole, + Group, + IdentityType, + ProjectRole, + SiloRole, +} from './__generated__/Api' import { api, q, usePrefetchedQuery } from './client' /** @@ -216,3 +225,30 @@ export function userScopedRoleEntries( } return entries } + +/** + * Builds a map from user ID to the list of groups that user belongs to. + * It has to be a hook because it fires one query per group to fetch members. + * The logic is shared between the silo and project access 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 } })), + }) + + return useMemo(() => { + const map = new Map() + groups.forEach((group, i) => { + const members = groupMemberQueries[i]?.data?.items ?? [] + members.forEach((member) => { + const existing = map.get(member.id) + if (existing) existing.push(group) + else map.set(member.id, [group]) + }) + }) + return map + // groupMemberQueries is a new array reference every render; depend on individual + // query data objects instead, which are stable references until data actually changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groups, ...groupMemberQueries.map((q) => q.data)]) +} diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 5786da5786..5351896c6b 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -33,6 +33,7 @@ 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 { MemberCountCell } from '~/table/cells/MemberCountCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -58,11 +59,6 @@ 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 = () => ( } diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 62a44a0b89..1f7c2d7d73 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { useQueries } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' @@ -17,6 +16,7 @@ import { queryClient, roleOrder, useApiMutation, + useGroupsByUserId, usePrefetchedQuery, userRoleFromPolicies, type Group, @@ -216,23 +216,7 @@ export default function SiloAccessUsersTab() { [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 groupsByUserId = useGroupsByUserId(groups.items) const siloRoleCol = useMemo( () => diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index e00eac95b1..c906f89b4c 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -38,6 +38,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' +import { MemberCountCell } from '~/table/cells/MemberCountCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -69,11 +70,6 @@ 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 = () => ( } diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 8b9ae9011b..31cff0c93b 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -5,7 +5,6 @@ * * 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' @@ -19,6 +18,7 @@ import { queryClient, roleOrder, useApiMutation, + useGroupsByUserId, usePrefetchedQuery, userScopedRoleEntries, type Group, @@ -207,23 +207,7 @@ export default function ProjectAccessUsersTab() { [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 groupsByUserId = useGroupsByUserId(groups.items) const rolesCol = useMemo( () => diff --git a/app/table/cells/MemberCountCell.tsx b/app/table/cells/MemberCountCell.tsx new file mode 100644 index 0000000000..e91e589004 --- /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 +} From 7de778544e9640dbdfa2215f11e90a9099a2870a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 11 Mar 2026 21:10:15 -0700 Subject: [PATCH 14/45] More refactoring, plus remeda --- app/pages/SiloAccessGroupsTab.tsx | 13 ++++-- app/pages/SiloAccessUsersTab.tsx | 44 +++++-------------- .../project/access/ProjectAccessGroupsTab.tsx | 8 ++-- .../project/access/ProjectAccessUsersTab.tsx | 14 +++--- 4 files changed, 30 insertions(+), 49 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index 5351896c6b..b2f09dcba4 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' +import * as R from 'remeda' import { api, @@ -31,6 +32,7 @@ import { HL } from '~/components/HL' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' @@ -98,7 +100,7 @@ function GroupMembersSideModal({ source: { type: 'direct' }, }) } - roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + const sortedRoleEntries = R.sortBy(roleEntries, (e) => roleOrder[e.roleName]) return ( - {roleEntries.length === 0 ? ( + {sortedRoleEntries.length === 0 ? ( No roles assigned ) : ( - roleEntries.map(({ scope, roleName }, i) => ( + sortedRoleEntries.map(({ scope, roleName }, i) => ( @@ -180,7 +182,10 @@ export default function SiloAccessGroupsTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role updated' }) + }, }) const siloRoleById = useMemo( diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index 1f7c2d7d73..b940516e7a 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -7,6 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' +import * as R from 'remeda' import { api, @@ -19,9 +20,9 @@ import { useGroupsByUserId, usePrefetchedQuery, userRoleFromPolicies, + userScopedRoleEntries, type Group, type Policy, - type RoleKey, type User, } from '@oxide/api' import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' @@ -33,6 +34,7 @@ import { ListPlusCell } from '~/components/ListPlusCell' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' 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' @@ -75,45 +77,16 @@ type UserDetailsSideModalProps = { userGroups: Group[] } -type SiloRoleEntry = { - scope: 'silo' - roleName: RoleKey - source: { type: 'direct' } | { type: 'group'; group: Group } -} - function UserDetailsSideModal({ user, onDismiss, siloPolicy, userGroups, }: UserDetailsSideModalProps) { - const roleEntries: SiloRoleEntry[] = [] - - const directAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === user.id + const roleEntries = R.sortBy( + userScopedRoleEntries(user.id, userGroups, [{ scope: 'silo', policy: siloPolicy }]), + (e) => roleOrder[e.roleName] ) - if (directAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: directAssignment.roleName, - source: { type: 'direct' }, - }) - } - - for (const group of userGroups) { - const groupAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (groupAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: groupAssignment.roleName, - source: { type: 'group', group }, - }) - } - } - - roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) return ( queryClient.invalidateEndpoint('policyView'), + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role updated' }) + }, }) // direct role assignments by identity ID, used for action menu diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index c906f89b4c..6e2507bf2c 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -74,7 +74,7 @@ const GroupEmptyState = () => ( } title="No groups" - body="No groups have been added to this silo" + body="No groups have been added to this project" /> ) @@ -124,7 +124,7 @@ function GroupMembersSideModal({ }) } - roleEntries.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) + const sortedRoleEntries = R.sortBy(roleEntries, (e) => roleOrder[e.roleName]) return ( - {roleEntries.length === 0 ? ( + {sortedRoleEntries.length === 0 ? ( No roles assigned ) : ( - roleEntries.map(({ scope, roleName, source }, i) => ( + sortedRoleEntries.map(({ scope, roleName, source }, i) => ( diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 31cff0c93b..fd57313681 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -91,13 +91,13 @@ function UserDetailsSideModal({ projectPolicy, userGroups, }: UserDetailsSideModalProps) { - const roleEntries = userScopedRoleEntries(user.id, userGroups, [ - { scope: 'silo', policy: siloPolicy }, - { scope: 'project', policy: projectPolicy }, - ]).sort( - (a, b) => - roleOrder[a.roleName] - roleOrder[b.roleName] || - (a.scope === 'silo' ? -1 : 1) - (b.scope === 'silo' ? -1 : 1) + const roleEntries = R.sortBy( + userScopedRoleEntries(user.id, userGroups, [ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ]), + (e) => roleOrder[e.roleName], + (e) => (e.scope === 'silo' ? 0 : 1) ) return ( From f0cadbf787cd8cfdd3d879c2a21f8ea96932f56a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 12 Mar 2026 08:43:23 -0700 Subject: [PATCH 15/45] Add button to copy IDs of Users, Groups, though will try to set up links to direct paths --- app/pages/SiloAccessGroupsTab.tsx | 6 +++++- app/pages/SiloAccessUsersTab.tsx | 8 ++++++-- app/pages/project/access/ProjectAccessGroupsTab.tsx | 6 +++++- app/pages/project/access/ProjectAccessUsersTab.tsx | 8 ++++++-- app/ui/lib/DropdownMenu.tsx | 10 +++++++++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index b2f09dcba4..11cb6ba62f 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -36,7 +36,7 @@ import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' -import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { RowActions, 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' @@ -159,12 +159,16 @@ function GroupMembersSideModal({ Members + {members.map((member: User) => ( {member.displayName} + + + ))} diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index b940516e7a..de24d332de 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -37,7 +37,7 @@ 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 { RowActions, 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' @@ -141,12 +141,13 @@ function UserDetailsSideModal({ Groups + {userGroups.length === 0 ? ( - + Not a member of any groups @@ -154,6 +155,9 @@ function UserDetailsSideModal({ userGroups.map((group) => ( {group.displayName} + + + )) )} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 6e2507bf2c..384c8081d8 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -39,7 +39,7 @@ import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' -import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { RowActions, 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' @@ -185,12 +185,16 @@ function GroupMembersSideModal({ Members + {members.map((member: User) => ( {member.displayName} + + + ))} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index fd57313681..a0f3262cd3 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -38,7 +38,7 @@ 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 { RowActions, 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' @@ -153,12 +153,13 @@ function UserDetailsSideModal({ Groups + {userGroups.length === 0 ? ( - + Not a member of any groups @@ -166,6 +167,9 @@ function UserDetailsSideModal({ userGroups.map((group) => ( {group.displayName} + + + )) )} diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 6585875b2b..97da1d9277 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 ( Date: Mon, 16 Mar 2026 13:42:34 -0700 Subject: [PATCH 16/45] Add Users & Groups nav --- app/layouts/ProjectLayoutBase.tsx | 5 + app/layouts/SiloLayout.tsx | 5 + app/pages/SiloAccessPage.tsx | 290 +++++++++++++- ...ab.tsx => SiloUsersAndGroupsGroupsTab.tsx} | 2 +- app/pages/SiloUsersAndGroupsPage.tsx | 36 ++ ...Tab.tsx => SiloUsersAndGroupsUsersTab.tsx} | 2 +- .../project/access/ProjectAccessPage.tsx | 371 +++++++++++++++++- ...tsx => ProjectUsersAndGroupsGroupsTab.tsx} | 2 +- .../access/ProjectUsersAndGroupsPage.tsx | 38 ++ ....tsx => ProjectUsersAndGroupsUsersTab.tsx} | 2 +- app/routes.tsx | 22 +- .../__snapshots__/path-builder.spec.ts.snap | 76 ++-- app/util/path-builder.spec.ts | 10 +- app/util/path-builder.ts | 12 +- mock-api/user-group.ts | 3 +- 15 files changed, 817 insertions(+), 59 deletions(-) rename app/pages/{SiloAccessGroupsTab.tsx => SiloUsersAndGroupsGroupsTab.tsx} (99%) create mode 100644 app/pages/SiloUsersAndGroupsPage.tsx rename app/pages/{SiloAccessUsersTab.tsx => SiloUsersAndGroupsUsersTab.tsx} (99%) rename app/pages/project/access/{ProjectAccessGroupsTab.tsx => ProjectUsersAndGroupsGroupsTab.tsx} (99%) create mode 100644 app/pages/project/access/ProjectUsersAndGroupsPage.tsx rename app/pages/project/access/{ProjectAccessUsersTab.tsx => ProjectUsersAndGroupsUsersTab.tsx} (99%) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 74e734a841..e83ca94914 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 361727119a..0b500fa300 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 382d168b3a..1107b07c42 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,17 +5,275 @@ * * Copyright Oxide Computer Company */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + roleOrder, + useApiMutation, + useGroupsByUserId, + usePrefetchedQuery, + type Group, + type IdentityType, + type SiloRole, +} 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 { RouteTabs, Tab } from '~/components/RouteTabs' +import { HL } from '~/components/HL' +import { + SiloAccessAddUserSideModal, + SiloAccessEditUserSideModal, +} from '~/forms/silo-access' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { identityTypeLabel, roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' + +const policyView = q(api.policyView, {}) +const userListQ = q(api.userList, {}) +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(userListQ), + ...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 AccessRow = { + id: string + name: string + 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 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 [editingRow, setEditingRow] = useState(null) + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: users } = usePrefetchedQuery(userListQ) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Access removed' }) + }, + }) + + const siloRoleById = useMemo( + () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), + [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( + () => [ + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + 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} + + ))} + + )} +
+ ) + }, + }), + getActionsCol((row: AccessRow) => [ + { + label: 'Change role', + onActivate: () => setEditingRow(row), + disabled: !row.directRole && 'This identity has no direct role to change', + }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), + label: ( + + the {row.directRole} role for {row.name} + + ), + }), + disabled: !row.directRole && 'This identity has no direct role to remove', + }, + ]), + ], + [siloPolicy, updatePolicy] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + return ( <> @@ -27,10 +285,30 @@ export default function SiloAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - Silo Users - Silo Groups - + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={siloPolicy} + /> + )} + {editingRow && ( + setEditingRow(null)} + policy={siloPolicy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.directRole }} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( + + )} ) } diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx similarity index 99% rename from app/pages/SiloAccessGroupsTab.tsx rename to app/pages/SiloUsersAndGroupsGroupsTab.tsx index 11cb6ba62f..b0361cfa22 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -179,7 +179,7 @@ function GroupMembersSideModal({ ) } -export default function SiloAccessGroupsTab() { +export default function SiloUsersAndGroupsGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) diff --git a/app/pages/SiloUsersAndGroupsPage.tsx b/app/pages/SiloUsersAndGroupsPage.tsx new file mode 100644 index 0000000000..d7260a6fa9 --- /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/SiloAccessUsersTab.tsx b/app/pages/SiloUsersAndGroupsUsersTab.tsx similarity index 99% rename from app/pages/SiloAccessUsersTab.tsx rename to app/pages/SiloUsersAndGroupsUsersTab.tsx index de24d332de..14c8b20ea9 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloUsersAndGroupsUsersTab.tsx @@ -176,7 +176,7 @@ const EmptyState = () => ( /> ) -export default function SiloAccessUsersTab() { +export default function SiloUsersAndGroupsUsersTab() { const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 63963c7e24..3818ce2b5d 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,19 +5,356 @@ * * Copyright Oxide Computer Company */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' +import type { LoaderFunctionArgs } from 'react-router' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + roleOrder, + useApiMutation, + useGroupsByUserId, + usePrefetchedQuery, + type Group, + 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 { RouteTabs, Tab } from '~/components/RouteTabs' -import { useProjectSelector } from '~/hooks/use-params' +import { HL } from '~/components/HL' +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 { 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 { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const policyView = q(api.policyView, {}) +const projectPolicyView = ({ project }: PP.Project) => + q(api.projectPolicyView, { path: { project } }) +const userListQ = q(api.userList, {}) +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(userListQ), + ...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 AccessRow = { + id: string + name: string + 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 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 [editingRow, setEditingRow] = useState(null) + const projectSelector = useProjectSelector() + const { project } = projectSelector + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: users } = usePrefetchedQuery(userListQ) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + 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] + ) + + // 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( + () => [ + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + 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} + + ))} + + )} +
+ ) + }, + }), + getActionsCol((row: AccessRow) => [ + { + label: 'Change role', + onActivate: () => setEditingRow(row), + disabled: + !row.directProjectRole && 'This identity has no direct project role to change', + }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ path: { project }, body: deleteRole(row.id, projectPolicy) }), + label: ( + + the {row.directProjectRole} role for {row.name} + + ), + }), + disabled: + !row.directProjectRole && 'This identity has no direct project role to remove', + }, + ]), + ], + [projectPolicy, project, updatePolicy] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + return ( <> @@ -29,10 +366,30 @@ export default function ProjectAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - Project Users - Project Groups - + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={projectPolicy} + /> + )} + {editingRow && ( + setEditingRow(null)} + policy={projectPolicy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.directProjectRole }} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( +
+ )} ) } diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx similarity index 99% rename from app/pages/project/access/ProjectAccessGroupsTab.tsx rename to app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx index 384c8081d8..dfae7e7628 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx @@ -205,7 +205,7 @@ function GroupMembersSideModal({ ) } -export default function ProjectAccessGroupsTab() { +export default function ProjectUsersAndGroupsGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) const projectSelector = useProjectSelector() diff --git a/app/pages/project/access/ProjectUsersAndGroupsPage.tsx b/app/pages/project/access/ProjectUsersAndGroupsPage.tsx new file mode 100644 index 0000000000..f9c81c5b74 --- /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/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx similarity index 99% rename from app/pages/project/access/ProjectAccessUsersTab.tsx rename to app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx index a0f3262cd3..b1460afe7a 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx @@ -188,7 +188,7 @@ const EmptyState = () => ( /> ) -export default function ProjectAccessUsersTab() { +export default function ProjectUsersAndGroupsUsersTab() { const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) const projectSelector = useProjectSelector() diff --git a/app/routes.tsx b/app/routes.tsx index 00bc87d0ab..fefa92c168 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -280,15 +280,19 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)}> + import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/SiloUsersAndGroupsPage').then(convert)} + > } /> import('./pages/SiloAccessUsersTab').then(convert)} + lazy={() => import('./pages/SiloUsersAndGroupsUsersTab').then(convert)} /> import('./pages/SiloAccessGroupsTab').then(convert)} + lazy={() => import('./pages/SiloUsersAndGroupsGroupsTab').then(convert)} /> @@ -546,18 +550,26 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} + /> + + import('./pages/project/access/ProjectUsersAndGroupsPage').then(convert) + } > } /> - import('./pages/project/access/ProjectAccessUsersTab').then(convert) + import('./pages/project/access/ProjectUsersAndGroupsUsersTab').then(convert) } /> - import('./pages/project/access/ProjectAccessGroupsTab').then(convert) + import('./pages/project/access/ProjectUsersAndGroupsGroupsTab').then( + convert + ) } /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 8b0677f005..09d743494b 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -465,7 +465,13 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/access", }, ], - "projectAccessGroups (/projects/p/access/groups)": [ + "projectEdit (/projects/p/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + ], + "projectImageEdit (/projects/p/images/im/edit)": [ { "label": "Projects", "path": "/projects", @@ -475,11 +481,11 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Project Access", - "path": "/projects/p/access", + "label": "Images", + "path": "/projects/p/images", }, ], - "projectAccessUsers (/projects/p/access/users)": [ + "projectImages (/projects/p/images)": [ { "label": "Projects", "path": "/projects", @@ -489,17 +495,25 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Project Access", - "path": "/projects/p/access", + "label": "Images", + "path": "/projects/p/images", }, ], - "projectEdit (/projects/p/edit)": [ + "projectImagesNew (/projects/p/images-new)": [ { "label": "Projects", "path": "/projects", }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Images", + "path": "/projects/p/images", + }, ], - "projectImageEdit (/projects/p/images/im/edit)": [ + "projectUsersAndGroups (/projects/p/users-and-groups)": [ { "label": "Projects", "path": "/projects", @@ -509,11 +523,11 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Images", - "path": "/projects/p/images", + "label": "Users & Groups", + "path": "/projects/p/users-and-groups", }, ], - "projectImages (/projects/p/images)": [ + "projectUsersAndGroupsGroups (/projects/p/users-and-groups/groups)": [ { "label": "Projects", "path": "/projects", @@ -523,11 +537,11 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Images", - "path": "/projects/p/images", + "label": "Users & Groups", + "path": "/projects/p/users-and-groups", }, ], - "projectImagesNew (/projects/p/images-new)": [ + "projectUsersAndGroupsUsers (/projects/p/users-and-groups/users)": [ { "label": "Projects", "path": "/projects", @@ -537,8 +551,8 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Images", - "path": "/projects/p/images", + "label": "Users & Groups", + "path": "/projects/p/users-and-groups", }, ], "projects (/projects)": [ @@ -609,18 +623,6 @@ 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", @@ -717,6 +719,24 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/scim", }, ], + "siloUsersAndGroups (/users-and-groups)": [ + { + "label": "Users & Groups", + "path": "/users-and-groups", + }, + ], + "siloUsersAndGroupsGroups (/users-and-groups/groups)": [ + { + "label": "Users & Groups", + "path": "/users-and-groups", + }, + ], + "siloUsersAndGroupsUsers (/users-and-groups/users)": [ + { + "label": "Users & Groups", + "path": "/users-and-groups", + }, + ], "siloUtilization (/utilization)": [ { "label": "Utilization", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 734b73035b..df8aea4925 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -74,20 +74,19 @@ 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", "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", "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", @@ -96,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 0c556a4f41..53d21c7241 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,8 +27,11 @@ 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`, + 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) => @@ -109,8 +112,9 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', - siloAccessUsers: () => '/access/users', - siloAccessGroups: () => '/access/groups', + 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 82f5717220..2dd6353b52 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 From e852345e27f925d39a651710bc344327ad48a3a7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 16 Mar 2026 15:03:05 -0700 Subject: [PATCH 17/45] Refactor --- app/api/roles.ts | 7 + .../access/GroupMembersSideModal.tsx | 124 ++++++++++++++ .../access/UserDetailsSideModal.tsx | 124 ++++++++++++++ app/pages/SiloAccessPage.tsx | 6 +- app/pages/SiloUsersAndGroupsGroupsTab.tsx | 134 +-------------- app/pages/SiloUsersAndGroupsUsersTab.tsx | 119 +------------ .../project/access/ProjectAccessPage.tsx | 11 +- .../access/ProjectUsersAndGroupsGroupsTab.tsx | 158 ++---------------- .../access/ProjectUsersAndGroupsUsersTab.tsx | 127 +------------- 9 files changed, 291 insertions(+), 519 deletions(-) create mode 100644 app/components/access/GroupMembersSideModal.tsx create mode 100644 app/components/access/UserDetailsSideModal.tsx diff --git a/app/api/roles.ts b/app/api/roles.ts index 962207a07f..4afcdf743c 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -85,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. diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx new file mode 100644 index 0000000000..248486b01c --- /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 0000000000..5774e9d45d --- /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/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 1107b07c42..fa3627b9ad 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -17,6 +17,7 @@ import { queryClient, roleOrder, useApiMutation, + rolesByIdFromPolicy, useGroupsByUserId, usePrefetchedQuery, type Group, @@ -114,10 +115,7 @@ export default function SiloAccessPage() { }, }) - const siloRoleById = useMemo( - () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), - [siloPolicy] - ) + const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) // Only fetch memberships for groups that have silo roles — their members have group-based access const groupsWithRoles = useMemo( diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx index b0361cfa22..f4779f9112 100644 --- a/app/pages/SiloUsersAndGroupsGroupsTab.tsx +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -5,10 +5,8 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' -import * as R from 'remeda' import { api, @@ -16,18 +14,15 @@ import { getListQFn, q, queryClient, - roleOrder, + rolesByIdFromPolicy, useApiMutation, usePrefetchedQuery, type Group, - type Policy, - type RoleKey, - type User, } from '@oxide/api' -import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' import { HL } from '~/components/HL' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' @@ -36,15 +31,11 @@ import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' -import { RowActions, useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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, {}) @@ -69,116 +60,6 @@ const GroupEmptyState = () => ( /> ) -type GroupMembersSideModalProps = { - group: Group - onDismiss: () => void - siloPolicy: Policy -} - -type SiloGroupRoleEntry = { - scope: 'silo' - roleName: RoleKey - source: { type: 'direct' } -} - -function GroupMembersSideModal({ - group, - onDismiss, - siloPolicy, -}: GroupMembersSideModalProps) { - const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) - const members = data?.items ?? [] - - const roleEntries: SiloGroupRoleEntry[] = [] - const directAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (directAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: directAssignment.roleName, - source: { type: 'direct' }, - }) - } - const sortedRoleEntries = R.sortBy(roleEntries, (e) => roleOrder[e.roleName]) - - return ( - - {group.displayName} - - } - onDismiss={onDismiss} - animate - > - - - - -
- - - - Role - Source - - - - {sortedRoleEntries.length === 0 ? ( - - - No roles assigned - - - ) : ( - sortedRoleEntries.map(({ scope, roleName }, i) => ( - - - - {scope}.{roleName} - - - Assigned - - )) - )} - -
-
-
- {members.length === 0 ? ( - } - title="No members" - body="This group has no members" - /> - ) : ( - - - - Members - - - - - {members.map((member: User) => ( - - {member.displayName} - - - - - ))} - -
- )} -
-
- ) -} - export default function SiloUsersAndGroupsGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) @@ -192,10 +73,7 @@ export default function SiloUsersAndGroupsGroupsTab() { }, }) - const siloRoleById = useMemo( - () => new Map(siloPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), - [siloPolicy] - ) + const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) const siloRoleCol = useMemo( () => @@ -268,7 +146,7 @@ export default function SiloUsersAndGroupsGroupsTab() { setSelectedGroup(null)} - siloPolicy={siloPolicy} + scopedPolicies={[{ scope: 'silo', policy: siloPolicy, sourceLabel: 'Assigned' }]} /> )} {editingGroup && ( diff --git a/app/pages/SiloUsersAndGroupsUsersTab.tsx b/app/pages/SiloUsersAndGroupsUsersTab.tsx index 14c8b20ea9..da60d00247 100644 --- a/app/pages/SiloUsersAndGroupsUsersTab.tsx +++ b/app/pages/SiloUsersAndGroupsUsersTab.tsx @@ -7,7 +7,6 @@ */ import { createColumnHelper } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' -import * as R from 'remeda' import { api, @@ -16,19 +15,17 @@ import { q, queryClient, roleOrder, + rolesByIdFromPolicy, useApiMutation, useGroupsByUserId, usePrefetchedQuery, userRoleFromPolicies, - userScopedRoleEntries, - type Group, - type Policy, type User, } from '@oxide/api' -import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' +import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' @@ -37,13 +34,10 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' -import { RowActions, useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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' @@ -70,104 +64,6 @@ const colHelper = createColumnHelper() const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) -type UserDetailsSideModalProps = { - user: User - onDismiss: () => void - siloPolicy: Policy - userGroups: Group[] -} - -function UserDetailsSideModal({ - user, - onDismiss, - siloPolicy, - userGroups, -}: UserDetailsSideModalProps) { - const roleEntries = R.sortBy( - userScopedRoleEntries(user.id, userGroups, [{ scope: 'silo', policy: siloPolicy }]), - (e) => roleOrder[e.roleName] - ) - - 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} - - - - - )) - )} - -
-
-
- ) -} - const EmptyState = () => ( } @@ -191,10 +87,7 @@ export default function SiloUsersAndGroupsUsersTab() { }) // 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 siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) const groupsByUserId = useGroupsByUserId(groups.items) @@ -308,7 +201,7 @@ export default function SiloUsersAndGroupsUsersTab() { setSelectedUser(null)} - siloPolicy={siloPolicy} + 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 3818ce2b5d..f0ed80a89f 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -18,6 +18,7 @@ import { queryClient, roleOrder, useApiMutation, + rolesByIdFromPolicy, useGroupsByUserId, usePrefetchedQuery, type Group, @@ -129,14 +130,8 @@ export default function ProjectAccessPage() { }, }) - 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 siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) // Fetch memberships for groups with roles in either policy const groupsWithAnyRole = useMemo( diff --git a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx index dfae7e7628..1be73d5b96 100644 --- a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx @@ -5,7 +5,6 @@ * * 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' @@ -18,17 +17,15 @@ import { q, queryClient, roleOrder, + rolesByIdFromPolicy, useApiMutation, usePrefetchedQuery, type Group, - type Policy, - type RoleKey, - type User, } from '@oxide/api' -import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessEditUserSideModal } from '~/forms/project-access' @@ -39,16 +36,12 @@ import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' -import { RowActions, useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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, {}) @@ -78,133 +71,6 @@ const GroupEmptyState = () => ( /> ) -type GroupMembersSideModalProps = { - group: Group - onDismiss: () => void - siloPolicy: Policy - projectPolicy: Policy -} - -type ProjectGroupRoleEntry = { - scope: 'silo' | 'project' - roleName: RoleKey - source: { type: 'direct' } | { type: 'silo' } -} - -function GroupMembersSideModal({ - group, - onDismiss, - siloPolicy, - projectPolicy, -}: GroupMembersSideModalProps) { - const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) - const members = data?.items ?? [] - - const roleEntries: ProjectGroupRoleEntry[] = [] - - const directProjectAssignment = projectPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (directProjectAssignment) { - roleEntries.push({ - scope: 'project', - roleName: directProjectAssignment.roleName, - source: { type: 'direct' }, - }) - } - - const directSiloAssignment = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === group.id - ) - if (directSiloAssignment) { - roleEntries.push({ - scope: 'silo', - roleName: directSiloAssignment.roleName, - source: { type: 'silo' }, - }) - } - - const sortedRoleEntries = R.sortBy(roleEntries, (e) => roleOrder[e.roleName]) - - return ( - - {group.displayName} - - } - onDismiss={onDismiss} - animate - > - - - - -
- - - - Role - Source - - - - {sortedRoleEntries.length === 0 ? ( - - - No roles assigned - - - ) : ( - sortedRoleEntries.map(({ scope, roleName, source }, i) => ( - - - - {scope}.{roleName} - - - - {source.type === 'direct' ? 'Assigned' : 'Inherited from silo'} - - - )) - )} - -
-
-
- {members.length === 0 ? ( - } - title="No members" - body="This group has no members" - /> - ) : ( - - - - Members - - - - - {members.map((member: User) => ( - - {member.displayName} - - - - - ))} - -
- )} -
-
- ) -} - export default function ProjectUsersAndGroupsGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) @@ -221,14 +87,8 @@ export default function ProjectUsersAndGroupsGroupsTab() { }, }) - 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 siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) const rolesCol = useMemo( () => @@ -331,8 +191,10 @@ export default function ProjectUsersAndGroupsGroupsTab() { setSelectedGroup(null)} - siloPolicy={siloPolicy} - projectPolicy={projectPolicy} + scopedPolicies={[ + { scope: 'project', policy: projectPolicy, sourceLabel: 'Assigned' }, + { scope: 'silo', policy: siloPolicy, sourceLabel: 'Inherited from silo' }, + ]} /> )} {editingGroup && ( diff --git a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx index b1460afe7a..3298bee09f 100644 --- a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx @@ -17,18 +17,17 @@ import { q, queryClient, roleOrder, + rolesByIdFromPolicy, useApiMutation, useGroupsByUserId, usePrefetchedQuery, userScopedRoleEntries, - type Group, - type Policy, type User, } from '@oxide/api' -import { Person16Icon, Person24Icon } from '@oxide/design-system/icons/react' +import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessEditUserSideModal } from '~/forms/project-access' @@ -38,13 +37,10 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' -import { RowActions, useColsWithActions, type MenuAction } from '~/table/columns/action-col' +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' @@ -76,110 +72,6 @@ const colHelper = createColumnHelper() const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) -type UserDetailsSideModalProps = { - user: User - onDismiss: () => void - siloPolicy: Policy - projectPolicy: Policy - userGroups: Group[] -} - -function UserDetailsSideModal({ - user, - onDismiss, - siloPolicy, - projectPolicy, - userGroups, -}: UserDetailsSideModalProps) { - const roleEntries = R.sortBy( - userScopedRoleEntries(user.id, userGroups, [ - { scope: 'silo', policy: siloPolicy }, - { scope: 'project', policy: projectPolicy }, - ]), - (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} - - - - - )) - )} - -
-
-
- ) -} - const EmptyState = () => ( } @@ -206,10 +98,7 @@ export default function ProjectUsersAndGroupsUsersTab() { }) // direct role assignments by identity ID, used for action menu - const projectRoleById = useMemo( - () => new Map(projectPolicy.roleAssignments.map((a) => [a.identityId, a.roleName])), - [projectPolicy] - ) + const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) const groupsByUserId = useGroupsByUserId(groups.items) @@ -331,8 +220,10 @@ export default function ProjectUsersAndGroupsUsersTab() { setSelectedUser(null)} - siloPolicy={siloPolicy} - projectPolicy={projectPolicy} + scopedPolicies={[ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ]} userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} From 8ca63fd4ce614726ad7c9d6c101d3956156d0a9d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 16 Mar 2026 16:39:31 -0700 Subject: [PATCH 18/45] Fix broken access form --- app/api/__tests__/safety.spec.ts | 1 - app/api/roles.ts | 30 ++++-- app/pages/SiloAccessPage.tsx | 2 + app/pages/SiloUsersAndGroupsGroupsTab.tsx | 51 +-------- app/pages/SiloUsersAndGroupsUsersTab.tsx | 67 ++---------- .../project/access/ProjectAccessPage.tsx | 2 + .../access/ProjectUsersAndGroupsGroupsTab.tsx | 56 +--------- .../access/ProjectUsersAndGroupsUsersTab.tsx | 74 ++----------- test/e2e/project-access.e2e.ts | 100 ++---------------- test/e2e/silo-access.e2e.ts | 82 +++++++------- 10 files changed, 91 insertions(+), 374 deletions(-) diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index c31f4621a2..4e0437f19d 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 4afcdf743c..f72fde1552 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -12,7 +12,7 @@ * it belongs in the API proper. */ import { useQueries } from '@tanstack/react-query' -import { useMemo } from 'react' +import { useMemo, useRef } from 'react' import * as R from 'remeda' import { ALL_ISH } from '~/util/consts' @@ -234,16 +234,27 @@ export function userScopedRoleEntries( } /** - * Builds a map from user ID to the list of groups that user belongs to. - * It has to be a hook because it fires one query per group to fetch members. - * The logic is shared between the silo and project access user tabs. + * 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 } })), }) - return useMemo(() => { + // 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 ?? [] @@ -253,9 +264,8 @@ export function useGroupsByUserId(groups: Group[]): Map { else map.set(member.id, [group]) }) }) - return map - // groupMemberQueries is a new array reference every render; depend on individual - // query data objects instead, which are stable references until data actually changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [groups, ...groupMemberQueries.map((q) => q.data)]) + mapRef.current = map + } + + return mapRef.current } diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index fa3627b9ad..ea16f1fe64 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -48,6 +48,7 @@ import { docLinks } from '~/util/links' const policyView = q(api.policyView, {}) const userListQ = q(api.userList, {}) +const groupList = q(api.groupList, {}) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { @@ -62,6 +63,7 @@ export async function clientLoader() { ) await Promise.all([ queryClient.prefetchQuery(userListQ), + queryClient.prefetchQuery(groupList), ...groups.items .filter((g) => groupsWithRoles.has(g.id)) .map((g) => diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx index f4779f9112..2fc4fe30ea 100644 --- a/app/pages/SiloUsersAndGroupsGroupsTab.tsx +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -6,16 +6,14 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { api, - deleteRole, getListQFn, q, queryClient, rolesByIdFromPolicy, - useApiMutation, usePrefetchedQuery, type Group, } from '@oxide/api' @@ -23,15 +21,10 @@ import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' -import { HL } from '~/components/HL' -import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' -import { confirmDelete } from '~/stores/confirm-delete' -import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' -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' @@ -62,17 +55,9 @@ const GroupEmptyState = () => ( export default function SiloUsersAndGroupsGroupsTab() { 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') - addToast({ content: 'Role updated' }) - }, - }) - const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) const siloRoleCol = useMemo( @@ -109,29 +94,7 @@ export default function SiloUsersAndGroupsGroupsTab() { [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 columns = staticColumns const { table } = useQueryTable({ query: groupList, @@ -149,16 +112,6 @@ export default function SiloUsersAndGroupsGroupsTab() { scopedPolicies={[{ scope: 'silo', policy: siloPolicy, sourceLabel: 'Assigned' }]} /> )} - {editingGroup && ( - setEditingGroup(null)} - /> - )} ) } diff --git a/app/pages/SiloUsersAndGroupsUsersTab.tsx b/app/pages/SiloUsersAndGroupsUsersTab.tsx index da60d00247..ba1d0a2b33 100644 --- a/app/pages/SiloUsersAndGroupsUsersTab.tsx +++ b/app/pages/SiloUsersAndGroupsUsersTab.tsx @@ -6,17 +6,15 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { api, - deleteRole, getListQFn, q, queryClient, roleOrder, rolesByIdFromPolicy, - useApiMutation, useGroupsByUserId, usePrefetchedQuery, userRoleFromPolicies, @@ -26,15 +24,10 @@ import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' -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 { 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' @@ -74,19 +67,10 @@ const EmptyState = () => ( export default function SiloUsersAndGroupsUsersTab() { const [selectedUser, setSelectedUser] = useState(null) - 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') - addToast({ content: 'Role updated' }) - }, - }) - - // direct role assignments by identity ID, used for action menu const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) const groupsByUserId = useGroupsByUserId(groups.items) @@ -150,8 +134,8 @@ export default function SiloUsersAndGroupsUsersTab() { [groupsByUserId] ) - const displayNameCol = useMemo( - () => + const columns = useMemo( + () => [ colHelper.accessor('displayName', { header: 'Name', cell: (info) => ( @@ -160,38 +144,13 @@ export default function SiloUsersAndGroupsUsersTab() { ), }), - [] - ) - - const staticColumns = useMemo( - () => [displayNameCol, siloRoleCol, groupsCol, timeCreatedCol], - [displayNameCol, 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] + siloRoleCol, + groupsCol, + timeCreatedCol, + ], + [siloRoleCol, groupsCol] ) - const columns = useColsWithActions(staticColumns, makeActions) - const { table } = useQueryTable({ query: userList, columns, emptyState: }) return ( @@ -205,16 +164,6 @@ export default function SiloUsersAndGroupsUsersTab() { userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} - {editingUser && ( - setEditingUser(null)} - /> - )} ) } diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index f0ed80a89f..694621890f 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -53,6 +53,7 @@ const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) const userListQ = q(api.userList, {}) +const groupList = q(api.groupList, {}) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -73,6 +74,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { ]) await Promise.all([ queryClient.prefetchQuery(userListQ), + queryClient.prefetchQuery(groupList), ...groups.items .filter((g) => groupsWithAnyRole.has(g.id)) .map((g) => diff --git a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx index 1be73d5b96..338692a3ec 100644 --- a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx @@ -6,19 +6,17 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { api, - deleteRole, getListQFn, q, queryClient, roleOrder, rolesByIdFromPolicy, - useApiMutation, usePrefetchedQuery, type Group, } from '@oxide/api' @@ -26,17 +24,12 @@ import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' -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 { MemberCountCell } from '~/table/cells/MemberCountCell' -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' @@ -73,20 +66,11 @@ const GroupEmptyState = () => ( export default function ProjectUsersAndGroupsGroupsTab() { 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(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) @@ -150,33 +134,7 @@ export default function ProjectUsersAndGroupsGroupsTab() { [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 columns = staticColumns const { table } = useQueryTable({ query: groupList, @@ -197,16 +155,6 @@ export default function ProjectUsersAndGroupsGroupsTab() { ]} /> )} - {editingGroup && ( - setEditingGroup(null)} - /> - )} ) } diff --git a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx index 3298bee09f..cacd17ec14 100644 --- a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx +++ b/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx @@ -6,19 +6,16 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { api, - deleteRole, getListQFn, q, queryClient, roleOrder, - rolesByIdFromPolicy, - useApiMutation, useGroupsByUserId, usePrefetchedQuery, userScopedRoleEntries, @@ -28,16 +25,11 @@ import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' -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' @@ -76,30 +68,18 @@ const EmptyState = () => ( } title="No users" - body="No users have been added to this silo" + body="No users have been added to this project" /> ) export default function ProjectUsersAndGroupsUsersTab() { const [selectedUser, setSelectedUser] = useState(null) - 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, used for action menu - const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) - const groupsByUserId = useGroupsByUserId(groups.items) const rolesCol = useMemo( @@ -168,8 +148,8 @@ export default function ProjectUsersAndGroupsUsersTab() { [groupsByUserId] ) - const displayNameCol = useMemo( - () => + const columns = useMemo( + () => [ colHelper.accessor('displayName', { header: 'Name', cell: (info) => ( @@ -178,39 +158,13 @@ export default function ProjectUsersAndGroupsUsersTab() { ), }), - [] + rolesCol, + groupsCol, + timeCreatedCol, + ], + [rolesCol, groupsCol] ) - const staticColumns = useMemo( - () => [displayNameCol, rolesCol, groupsCol, timeCreatedCol], - [displayNameCol, 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 ( @@ -227,16 +181,6 @@ export default function ProjectUsersAndGroupsUsersTab() { userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} - {editingUser && ( - setEditingUser(null)} - /> - )} ) } diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 4e7ec8f542..e13c4e480d 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -5,121 +5,41 @@ * * Copyright Oxide Computer Company */ -import { user3, user4 } from '@oxide/api-mocks' +import { user3 } from '@oxide/api-mocks' -import { closeToast, expect, 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"]') - await expectVisible(page, ['role=heading[name*="Access"]']) - // Users tab is shown by default + // Mixed table: groups first, then users 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', - Role: 'silo.admin+1', - }) - await expectRowVisible(table, { - Name: 'Jacob Klein', - 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', 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', - Role: 'silo.collaborator', - }) - await expectRowVisible(table, { - Name: 'kernel-devs', - Role: 'project.viewer', - }) - - // 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="Update role"]') - - // Simone de Beauvoir now has collaborator role - await expectRowVisible(table, { - Name: 'Simone de Beauvoir', - 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 remove user 3's project role. 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: 'Remove role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - - // 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' }) + 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 a315ccf7f8..a47f419f8d 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,76 +5,66 @@ * * Copyright Oxide Computer Company */ -import { user3 } from '@oxide/api-mocks' - import { expect, expectRowVisible, expectVisible, test } from './utils' test('Click through silo access page', async ({ page }) => { await page.goto('/') - await page.click('role=link[name*="Access"]') - await expectVisible(page, ['role=heading[name*="Access"]']) - // Users tab is shown by default + // Mixed table: groups first, then users const table = page.locator('role=table') - await expectRowVisible(table, { - 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: 'real-estate-devs', 'Silo Role': 'silo.collaborator', }) + 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' }) - // 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 }) + // 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: /^Collaborator / }).click() + 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' }) - // Jacob Klein shows up with collaborator role - await expectRowVisible(table, { - Name: 'Jacob Klein', - 'Silo Role': 'silo.collaborator', - }) - - // now change Jacob Klein's role from collab to viewer - await page - .locator('role=row', { hasText: user3.display_name }) + // 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="Change role"]') - - await expectVisible(page, ['role=heading[name*="Edit role"]']) + 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() +}) - // Verify Collaborator is currently selected - await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() +test('Group role change propagates to user effective role', async ({ page }) => { + await page.goto('/') + await page.click('role=link[name*="Access"]') - // Select Viewer role - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update 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' }) - await expectRowVisible(table, { Name: user3.display_name, 'Silo Role': 'silo.viewer' }) + // 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 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: 'Remove role' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() + // 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 page.getByRole('radio', { name: /^Admin / }).click() + await page.click('role=button[name="Update role"]') - // Row is still visible but silo role is now empty - await expectRowVisible(table, { Name: user3.display_name, 'Silo Role': '—' }) + // Jane Austen now shows admin as effective role (inherited via real-estate-devs) + await expectRowVisible(table, { Name: 'Jane Austen', 'Silo Role': 'silo.admin' }) }) From 7ccc0fc71fa0f22a7ac884e20f7f6c5e1d32b562 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 18 Mar 2026 18:27:52 -0700 Subject: [PATCH 19/45] Remove Users & Groups from Project page / nav --- app/api/roles.spec.ts | 63 ++---- app/api/roles.ts | 24 +-- .../access/GroupMembersSideModal.tsx | 42 ++-- .../access/UserDetailsSideModal.tsx | 20 +- app/layouts/ProjectLayoutBase.tsx | 5 - app/pages/SiloUsersAndGroupsGroupsTab.tsx | 2 +- app/pages/SiloUsersAndGroupsUsersTab.tsx | 4 +- .../access/ProjectUsersAndGroupsGroupsTab.tsx | 160 --------------- .../access/ProjectUsersAndGroupsPage.tsx | 38 ---- .../access/ProjectUsersAndGroupsUsersTab.tsx | 186 ------------------ app/routes.tsx | 22 --- .../__snapshots__/path-builder.spec.ts.snap | 42 ---- app/util/path-builder.spec.ts | 3 - app/util/path-builder.ts | 5 - 14 files changed, 49 insertions(+), 567 deletions(-) delete mode 100644 app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx delete mode 100644 app/pages/project/access/ProjectUsersAndGroupsPage.tsx delete mode 100644 app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index e8b44c6381..abd906176b 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -82,68 +82,41 @@ const user1 = { const groups = [{ id: 'group1' }, { id: 'group2' }] describe('getEffectiveRole', () => { - it('returns null when there are no policies', () => { - expect(userRoleFromPolicies(user1, groups, [])).toBe(null) - }) - it('returns null when there are no roles', () => { - expect(userRoleFromPolicies(user1, groups, [{ roleAssignments: [] }])).toBe(null) + expect(userRoleFromPolicies(user1, groups, { roleAssignments: [] })).toBe(null) }) it('returns role if user matches directly', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'admin' }, - ], - }, - ]) + userRoleFromPolicies(user1, groups, { + roleAssignments: [ + { identityId: 'user1', identityType: 'silo_user', roleName: 'admin' }, + ], + }) ).toEqual('admin') }) it('returns strongest role if both group and user match', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' }, - ], - }, - ]) + userRoleFromPolicies(user1, groups, { + roleAssignments: [ + { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, + { identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' }, + ], + }) ).toEqual('collaborator') }) it('ignores groups and users that do not match', () => { expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'other', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' }, - ], - }, - ]) + userRoleFromPolicies(user1, groups, { + roleAssignments: [ + { identityId: 'other', identityType: 'silo_user', roleName: 'viewer' }, + { identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' }, + ], + }) ).toEqual(null) }) - - it('resolves multiple policies', () => { - expect( - userRoleFromPolicies(user1, groups, [ - { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, - ], - }, - { - roleAssignments: [ - { identityId: 'group1', identityType: 'silo_group', roleName: 'admin' }, - ], - }, - ]) - ).toEqual('admin') - }) }) test('byGroupThenName sorts as expected', () => { diff --git a/app/api/roles.ts b/app/api/roles.ts index f72fde1552..a031a6e9f6 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -193,42 +193,36 @@ export function useActorsNotInPolicy( export function userRoleFromPolicies( user: { id: string }, groups: { id: string }[], - policies: Policy[] + policy: Policy ): RoleKey | null { const myIds = new Set([user.id, ...groups.map((g) => g.id)]) - const myRoles = policies - .flatMap((p) => p.roleAssignments) // concat all the role assignments together + const myRoles = policy.roleAssignments .filter((ra) => myIds.has(ra.identityId)) .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. + * assignment and one per group assignment — from the silo policy. * 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 }> + 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 } }) - } + const direct = policy.roleAssignments.find((ra) => ra.identityId === userId) + if (direct) entries.push({ 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({ roleName: via.roleName, source: { type: 'group', group } }) } return entries } diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx index 248486b01c..46a754cded 100644 --- a/app/components/access/GroupMembersSideModal.tsx +++ b/app/components/access/GroupMembersSideModal.tsx @@ -6,9 +6,8 @@ * 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 { api, q, 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' @@ -21,30 +20,17 @@ 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[] + policy: Policy } -export function GroupMembersSideModal({ group, onDismiss, scopedPolicies }: Props) { +export function GroupMembersSideModal({ group, onDismiss, policy }: 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] - ) + const assignment = policy.roleAssignments.find((ra) => ra.identityId === group.id) return ( - {roleEntries.length === 0 ? ( + {!assignment ? ( No roles assigned ) : ( - roleEntries.map(({ scope, roleName, sourceLabel }, i) => ( - - - - {scope}.{roleName} - - - {sourceLabel} - - )) + + + + silo.{assignment.roleName} + + + Assigned + )} diff --git a/app/components/access/UserDetailsSideModal.tsx b/app/components/access/UserDetailsSideModal.tsx index 5774e9d45d..95e937ed43 100644 --- a/app/components/access/UserDetailsSideModal.tsx +++ b/app/components/access/UserDetailsSideModal.tsx @@ -28,19 +28,13 @@ type Props = { user: User onDismiss: () => void userGroups: Group[] - scopedPolicies: Array<{ scope: 'silo' | 'project'; policy: Policy }> + policy: Policy } -export function UserDetailsSideModal({ - user, - onDismiss, - userGroups, - scopedPolicies, -}: Props) { +export function UserDetailsSideModal({ user, onDismiss, userGroups, policy }: Props) { const roleEntries = R.sortBy( - userScopedRoleEntries(user.id, userGroups, scopedPolicies), - (e) => roleOrder[e.roleName], - (e) => (e.scope === 'silo' ? 0 : 1) + userScopedRoleEntries(user.id, userGroups, policy), + (e) => roleOrder[e.roleName] ) return ( @@ -74,12 +68,10 @@ export function UserDetailsSideModal({ ) : ( - roleEntries.map(({ scope, roleName, source }, i) => ( + roleEntries.map(({ roleName, source }, i) => ( - - {scope}.{roleName} - + silo.{roleName} {source.type === 'direct' && 'Assigned'} diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index c2d74077d0..407876df19 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -17,7 +17,6 @@ import { Instances16Icon, IpGlobal16Icon, Networking16Icon, - Person16Icon, Snapshots16Icon, Storage16Icon, } from '@oxide/design-system/icons/react' @@ -71,7 +70,6 @@ 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,9 +115,6 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Affinity Groups - - Users & Groups - Project Access diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx index 2fc4fe30ea..9438828ef3 100644 --- a/app/pages/SiloUsersAndGroupsGroupsTab.tsx +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -109,7 +109,7 @@ export default function SiloUsersAndGroupsGroupsTab() { setSelectedGroup(null)} - scopedPolicies={[{ scope: 'silo', policy: siloPolicy, sourceLabel: 'Assigned' }]} + policy={siloPolicy} /> )} diff --git a/app/pages/SiloUsersAndGroupsUsersTab.tsx b/app/pages/SiloUsersAndGroupsUsersTab.tsx index ba1d0a2b33..2f9e6297e9 100644 --- a/app/pages/SiloUsersAndGroupsUsersTab.tsx +++ b/app/pages/SiloUsersAndGroupsUsersTab.tsx @@ -82,7 +82,7 @@ export default function SiloUsersAndGroupsUsersTab() { header: 'Silo Role', cell: ({ row }) => { const userGroups = groupsByUserId.get(row.original.id) ?? [] - const role = userRoleFromPolicies(row.original, userGroups, [siloPolicy]) + 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, @@ -160,7 +160,7 @@ export default function SiloUsersAndGroupsUsersTab() { setSelectedUser(null)} - scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} + policy={siloPolicy} userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} diff --git a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx b/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx deleted file mode 100644 index 338692a3ec..0000000000 --- a/app/pages/project/access/ProjectUsersAndGroupsGroupsTab.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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 deleted file mode 100644 index f9c81c5b74..0000000000 --- a/app/pages/project/access/ProjectUsersAndGroupsPage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 deleted file mode 100644 index cacd17ec14..0000000000 --- a/app/pages/project/access/ProjectUsersAndGroupsUsersTab.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/* - * 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 fefa92c168..20bcba6199 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -551,28 +551,6 @@ 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/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 09d743494b..fbddb3c3fe 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -513,48 +513,6 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/images", }, ], - "projectUsersAndGroups (/projects/p/users-and-groups)": [ - { - "label": "Projects", - "path": "/projects", - }, - { - "label": "p", - "path": "/projects/p/instances", - }, - { - "label": "Users & Groups", - "path": "/projects/p/users-and-groups", - }, - ], - "projectUsersAndGroupsGroups (/projects/p/users-and-groups/groups)": [ - { - "label": "Projects", - "path": "/projects", - }, - { - "label": "p", - "path": "/projects/p/instances", - }, - { - "label": "Users & Groups", - "path": "/projects/p/users-and-groups", - }, - ], - "projectUsersAndGroupsUsers (/projects/p/users-and-groups/users)": [ - { - "label": "Projects", - "path": "/projects", - }, - { - "label": "p", - "path": "/projects/p/instances", - }, - { - "label": "Users & Groups", - "path": "/projects/p/users-and-groups", - }, - ], "projects (/projects)": [ { "label": "Projects", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index df8aea4925..6fa8a08ffc 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -78,9 +78,6 @@ test('path builder', () => { "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", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 53d21c7241..2663db582a 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -27,11 +27,6 @@ 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) => From 9b0710d292d3677e6c445d2ca7a045362e948811 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 19 Mar 2026 09:26:16 -0700 Subject: [PATCH 20/45] Don't show all users on access pages; only assigned, inherited from silo, and group names --- app/api/roles.ts | 41 ----- app/pages/SiloAccessPage.tsx | 154 +++--------------- .../project/access/ProjectAccessPage.tsx | 123 +++----------- app/pages/system/FleetAccessPage.tsx | 34 +++- test/e2e/silo-access.e2e.ts | 28 ---- 5 files changed, 64 insertions(+), 316 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index a031a6e9f6..91a1b5d57b 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -106,47 +106,6 @@ export function deleteRole( return { roleAssignments } } -type UserAccessRow = { - id: string - identityType: IdentityType - name: string - roleName: Role - roleSource: string -} - -/** - * Role assignments come from the API in (user, role) pairs without display - * names and without info about which resource the role came from. This tags - * each row with that info. It has to be a hook because it depends on the result - * of an API request for the list of users. It's a bit awkward, but the logic is - * identical between projects and orgs so it is worth sharing. - */ -export function useUserRows( - roleAssignments: RoleAssignment[], - roleSource: string -): UserAccessRow[] { - // HACK: because the policy has no names, we are fetching ~all the users, - // putting them in a dictionary, and adding the names to the rows - const { data: users } = usePrefetchedQuery(q(api.userList, {})) - const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) - return useMemo(() => { - const userItems = users?.items || [] - const groupItems = groups?.items || [] - const usersDict = Object.fromEntries(userItems.concat(groupItems).map((u) => [u.id, u])) - return roleAssignments.map((ra) => ({ - id: ra.identityId, - identityType: ra.identityType, - // A user might not appear here if they are not in the current user's - // silo. This could happen in a fleet policy, which might have users from - // different silos. Hence the ID fallback. The code that displays this - // detects when we've fallen back and includes an explanatory tooltip. - name: usersDict[ra.identityId]?.displayName || ra.identityId, - roleName: ra.roleName, - roleSource, - })) - }, [roleAssignments, roleSource, users, groups]) -} - type SortableUserRow = { identityType: IdentityType; name: string } /** diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 2e31668aa6..33cec7ebed 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -12,15 +12,10 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, queryClient, - roleOrder, useApiMutation, - rolesByIdFromPolicy, - useGroupsByUserId, usePrefetchedQuery, - type Group, type IdentityType, type SiloRole, } from '@oxide/api' @@ -42,7 +37,6 @@ 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 { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' @@ -53,25 +47,11 @@ 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), queryClient.prefetchQuery(userListQ), + queryClient.prefetchQuery(groupListAll), 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 } @@ -82,11 +62,7 @@ type AccessRow = { id: string name: string 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 + role: SiloRole } const colHelper = createColumnHelper() @@ -118,103 +94,30 @@ export default function SiloAccessPage() { }, }) - 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, - }) - } + const userRows: AccessRow[] = siloPolicy.roleAssignments + .filter((ra) => ra.identityType === 'silo_user') + .map((ra) => ({ + id: ra.identityId, + name: userById.get(ra.identityId)?.displayName ?? ra.identityId, + identityType: 'silo_user' as IdentityType, + role: ra.roleName, + })) - // 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, + role: ra.roleName, })) return [...groupRows, ...userRows].sort(byGroupThenName) - }, [siloPolicy, users, groups, groupsByUserId, siloRoleById]) + }, [siloPolicy, users, groups]) const columns = useMemo( () => [ @@ -223,34 +126,16 @@ export default function SiloAccessPage() { header: 'Type', cell: (info) => identityTypeLabel[info.getValue()], }), - colHelper.display({ - id: 'effectiveRole', + colHelper.accessor('role', { 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} - - ))} - - )} -
- ) - }, + cell: (info) => ( + silo.{info.getValue()} + ), }), getActionsCol((row: AccessRow) => [ { label: 'Change role', onActivate: () => setEditingRow(row), - disabled: !row.directRole && 'This identity has no direct role to change', }, { label: 'Remove role', @@ -258,11 +143,10 @@ export default function SiloAccessPage() { doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), label: ( - the {row.directRole} role for {row.name} + the {row.role} role for {row.name} ), }), - disabled: !row.directRole && 'This identity has no direct role to remove', }, ]), ], @@ -313,7 +197,7 @@ export default function SiloAccessPage() { name={editingRow.name} identityId={editingRow.id} identityType={editingRow.identityType} - defaultValues={{ roleName: editingRow.directRole }} + defaultValues={{ roleName: editingRow.role }} /> )} {rows.length === 0 ? ( diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 5af5cb8b0f..57792e7ca0 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -19,9 +19,7 @@ import { roleOrder, useApiMutation, rolesByIdFromPolicy, - useGroupsByUserId, usePrefetchedQuery, - type Group, type IdentityType, type RoleKey, } from '@oxide/api' @@ -59,30 +57,12 @@ 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)), queryClient.prefetchQuery(userListQ), + queryClient.prefetchQuery(groupListAll), 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 } @@ -95,7 +75,6 @@ type AccessRow = { identityType: IdentityType effectiveScope: 'silo' | 'project' effectiveRole: RoleKey - viaGroups: Group[] /** Direct project-level role only — the only one manageable on this page. */ directProjectRole: RoleKey | undefined } @@ -136,13 +115,6 @@ export default function ProjectAccessPage() { 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])) @@ -152,7 +124,6 @@ export default function ProjectAccessPage() { name: string directSiloRole: RoleKey | undefined directProjectRole: RoleKey | undefined - memberOfGroups: Group[] } const intermediateUserRows = new Map() @@ -165,7 +136,6 @@ export default function ProjectAccessPage() { name: userById.get(userId)?.displayName ?? userId, directSiloRole: undefined, directProjectRole: undefined, - memberOfGroups: [], } intermediateUserRows.set(userId, row) return row @@ -179,68 +149,29 @@ export default function ProjectAccessPage() { 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) + // Scope is 'silo' if the direct silo role is at least as strong as the effective role const effectiveScope: 'silo' | 'project' = - effectiveSiloRole !== undefined && - roleOrder[effectiveSiloRole] <= roleOrder[effectiveRole] + row.directSiloRole !== undefined && + roleOrder[row.directSiloRole] <= 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, }) } @@ -273,21 +204,12 @@ export default function ProjectAccessPage() { identityType: 'silo_group' as IdentityType, effectiveScope, effectiveRole, - viaGroups: [], directProjectRole: projectRole, } }) return [...groupRows, ...userRows].sort(byGroupThenName) - }, [ - siloPolicy, - projectPolicy, - users, - groups, - groupsByUserId, - siloRoleById, - projectRoleById, - ]) + }, [siloPolicy, projectPolicy, users, groups, siloRoleById, projectRoleById]) const columns = useMemo( () => [ @@ -298,26 +220,21 @@ export default function ProjectAccessPage() { }), colHelper.display({ id: 'effectiveRole', - header: 'Role', + header: () => ( +
+ Role + + A user or group's effective role for this project is the strongest role + on either the silo or project + +
+ ), cell: ({ row }) => { - const { effectiveScope, effectiveRole, viaGroups } = row.original + const { effectiveScope, effectiveRole } = row.original return ( -
- - {effectiveScope}.{effectiveRole} - - {viaGroups.length > 0 && ( - - via{' '} - {viaGroups.map((g, i) => ( - - {i > 0 && ', '} - {g.displayName} - - ))} - - )} -
+ + {effectiveScope}.{effectiveRole} + ) }, }), diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index ec48e5063b..88762468a9 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -19,7 +19,6 @@ import { queryClient, useApiMutation, usePrefetchedQuery, - useUserRows, type FleetRole, type IdentityType, } from '@oxide/api' @@ -106,17 +105,34 @@ export default function FleetAccessPage() { const navigate = useNavigate() const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) const { data: silos } = usePrefetchedQuery(siloList) - const fleetRows = useUserRows(fleetPolicy.roleAssignments, 'fleet') const rows: AccessRow[] = useMemo(() => { - const assignmentRows: AssignmentRow[] = groupBy(fleetRows, (u) => u.id) - .map(([userId, userAssignments]) => { - const { name, identityType } = userAssignments[0] - // non-null: userAssignments is non-empty (groupBy only creates groups for existing items) + // A user might not appear here if they're not in the current user's silo + // (can happen for cross-silo fleet assignments), so fall back to the raw ID. + // The name column detects the fallback and shows an explanatory tooltip. + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + + const assignmentRows: AssignmentRow[] = groupBy( + fleetPolicy.roleAssignments, + (ra) => ra.identityId + ) + .map(([userId, assignments]) => { + const { identityType } = assignments[0] + // non-null: assignments is non-empty (groupBy only creates groups for existing items) // getEffectiveRole needed because API allows multiple fleet role assignments for the same user, even though that's probably rare - const fleetRole = getEffectiveRole(userAssignments.map((a) => a.roleName))! - return { kind: 'assignment' as const, id: userId, identityType, name, fleetRole } + const fleetRole = getEffectiveRole(assignments.map((a) => a.roleName))! + return { + kind: 'assignment' as const, + id: userId, + identityType, + name: nameById.get(userId) ?? userId, + fleetRole, + } }) .sort(byGroupThenName) @@ -134,7 +150,7 @@ export default function FleetAccessPage() { ) return [...assignmentRows, ...mappingRows] - }, [fleetRows, silos]) + }, [fleetPolicy, users, groups, silos]) const { mutateAsync: updatePolicy } = useApiMutation(api.systemPolicyUpdate, { onSuccess: () => { diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index a47f419f8d..1fecbba079 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -19,8 +19,6 @@ test('Click through silo access page', async ({ page }) => { 'Silo Role': 'silo.collaborator', }) 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 @@ -42,29 +40,3 @@ test('Click through silo access page', async ({ page }) => { await page.click('role=button[name="Confirm"]') await expect(table.locator('role=row', { hasText: 'real-estate-devs' })).toBeHidden() }) - -test('Group role change propagates to user effective role', async ({ page }) => { - await page.goto('/') - await page.click('role=link[name*="Access"]') - - 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' }) - - // 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') - - // 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 page.getByRole('radio', { name: /^Admin / }).click() - await page.click('role=button[name="Update role"]') - - // Jane Austen now shows admin as effective role (inherited via real-estate-devs) - await expectRowVisible(table, { Name: 'Jane Austen', 'Silo Role': 'silo.admin' }) -}) From 7b881e76e196461d60fdb720d076cb7051e18f98 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 30 Apr 2026 18:22:38 -0400 Subject: [PATCH 21/45] Patch up several small issues --- app/api/roles.ts | 33 ++++++++++--------- app/pages/SiloAccessPage.tsx | 12 +++---- app/pages/SiloUsersAndGroupsGroupsTab.tsx | 7 ++++ app/pages/SiloUsersAndGroupsPage.tsx | 3 +- .../project/access/ProjectAccessPage.tsx | 12 +++---- app/pages/system/FleetAccessPage.tsx | 4 +-- .../__snapshots__/path-builder.spec.ts.snap | 8 ++--- app/util/path-builder.spec.ts | 2 +- app/util/path-builder.ts | 3 +- 9 files changed, 45 insertions(+), 39 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 91a1b5d57b..73629e1a5d 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -12,7 +12,7 @@ * it belongs in the API proper. */ import { useQueries } from '@tanstack/react-query' -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import * as R from 'remeda' import { ALL_ISH } from '~/util/consts' @@ -131,8 +131,10 @@ export type Actor = { export function useActorsNotInPolicy( policy: Policy ): Actor[] { - const { data: users } = usePrefetchedQuery(q(api.userList, {})) - const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) + const { data: users } = usePrefetchedQuery(q(api.userList, { query: { limit: ALL_ISH } })) + const { data: groups } = usePrefetchedQuery( + q(api.groupList, { query: { limit: ALL_ISH } }) + ) return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) @@ -189,25 +191,25 @@ export function userScopedRoleEntries( /** * Builds a map from user ID to the list of groups that user belongs to, * firing one query per group to fetch members. Shared between user tabs. + * + * The returned Map is referentially stable between data updates, which keeps + * downstream useMemos (column definitions) from invalidating every render. + * `useQueries` returns a new array reference each render, so we can't put it in + * a useMemo deps array directly — instead we encode the relevant inputs (group + * IDs and per-query updated-at timestamps) into a single version string and + * memoize on that. */ export function useGroupsByUserId(groups: Group[]): Map { const groupMemberQueries = useQueries({ queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })), }) - // 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), + ...groupMemberQueries.map((query) => query.dataUpdatedAt), ].join('|') - if (version !== versionRef.current) { - versionRef.current = version + return useMemo(() => { const map = new Map() groups.forEach((group, i) => { const members = groupMemberQueries[i]?.data?.items ?? [] @@ -217,8 +219,7 @@ export function useGroupsByUserId(groups: Group[]): Map { else map.set(member.id, [group]) }) }) - mapRef.current = map - } - - return mapRef.current + return map + // eslint-disable-next-line react-hooks/exhaustive-deps -- groups and queries are encoded in version + }, [version]) } diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 33cec7ebed..a5595f3123 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -42,15 +42,13 @@ import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' const policyView = q(api.policyView, {}) -const userListQ = q(api.userList, {}) -const groupList = q(api.groupList, {}) -const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { await Promise.all([ queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(userListQ), - queryClient.prefetchQuery(groupListAll), + queryClient.prefetchQuery(userList), queryClient.prefetchQuery(groupList), ]) return null @@ -84,8 +82,8 @@ export default function SiloAccessPage() { const [editingRow, setEditingRow] = useState(null) const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: users } = usePrefetchedQuery(userListQ) - const { data: groups } = usePrefetchedQuery(groupListAll) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloUsersAndGroupsGroupsTab.tsx index 9438828ef3..40899a398c 100644 --- a/app/pages/SiloUsersAndGroupsGroupsTab.tsx +++ b/app/pages/SiloUsersAndGroupsGroupsTab.tsx @@ -29,14 +29,21 @@ import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' const policyView = q(api.policyView, {}) const groupList = getListQFn(api.groupList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { + // prefetch member lists so MemberCountCell renders without an on-mount fetch + const groups = await queryClient.fetchQuery(groupListAll) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(groupList.optionsFn()), + ...groups.items.map((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ), ]) return null } diff --git a/app/pages/SiloUsersAndGroupsPage.tsx b/app/pages/SiloUsersAndGroupsPage.tsx index d7260a6fa9..fec14b08ce 100644 --- a/app/pages/SiloUsersAndGroupsPage.tsx +++ b/app/pages/SiloUsersAndGroupsPage.tsx @@ -9,11 +9,12 @@ import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' +import { makeCrumb } from '~/hooks/use-crumbs' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -export const handle = { crumb: 'Users & Groups' } +export const handle = makeCrumb('Users & Groups', pb.siloUsersAndGroups()) export default function SiloUsersAndGroupsPage() { return ( diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 57792e7ca0..0f14165368 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -51,17 +51,15 @@ import type * as PP from '~/util/path-params' const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) -const userListQ = q(api.userList, {}) -const groupList = q(api.groupList, {}) -const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(projectPolicyView(selector)), - queryClient.prefetchQuery(userListQ), - queryClient.prefetchQuery(groupListAll), + queryClient.prefetchQuery(userList), queryClient.prefetchQuery(groupList), ]) return null @@ -102,8 +100,8 @@ export default function ProjectAccessPage() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const { data: users } = usePrefetchedQuery(userListQ) - const { data: groups } = usePrefetchedQuery(groupListAll) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index 88762468a9..a491d08882 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -62,8 +62,8 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) const systemPolicyView = q(api.systemPolicyView, {}) -const userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) const siloList = q(api.siloList, { query: { limit: ALL_ISH } }) export async function clientLoader() { diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index f00cd290df..5f1b591344 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -733,22 +733,22 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/subnet-pools", }, ], - "siloUsersAndGroups (/users-and-groups)": [ + "siloUsersAndGroups (/users-and-groups/users)": [ { "label": "Users & Groups", - "path": "/users-and-groups", + "path": "/users-and-groups/users", }, ], "siloUsersAndGroupsGroups (/users-and-groups/groups)": [ { "label": "Users & Groups", - "path": "/users-and-groups", + "path": "/users-and-groups/users", }, ], "siloUsersAndGroupsUsers (/users-and-groups/users)": [ { "label": "Users & Groups", - "path": "/users-and-groups", + "path": "/users-and-groups/users", }, ], "siloUtilization (/utilization)": [ diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 785d742d34..2a09a31f51 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -98,7 +98,7 @@ test('path builder', () => { "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", "siloSubnetPools": "/system/silos/s/subnet-pools", - "siloUsersAndGroups": "/users-and-groups", + "siloUsersAndGroups": "/users-and-groups/users", "siloUsersAndGroupsGroups": "/users-and-groups/groups", "siloUsersAndGroupsUsers": "/users-and-groups/users", "siloUtilization": "/utilization", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 9447d588cd..48f4b5e4d5 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -112,9 +112,10 @@ export const pb = { siloUtilization: () => '/utilization', siloAccess: () => '/access', - siloUsersAndGroups: () => '/users-and-groups', siloUsersAndGroupsUsers: () => '/users-and-groups/users', siloUsersAndGroupsGroups: () => '/users-and-groups/groups', + // points to the default tab to avoid bouncing through the parent's redirect + siloUsersAndGroups: () => pb.siloUsersAndGroupsUsers(), siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, From bdd4a757bd16c37f54634c1886f1aac186036a76 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 30 Apr 2026 18:43:46 -0400 Subject: [PATCH 22/45] Add e2e tests --- test/e2e/silo-users-and-groups.e2e.ts | 92 +++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 test/e2e/silo-users-and-groups.e2e.ts diff --git a/test/e2e/silo-users-and-groups.e2e.ts b/test/e2e/silo-users-and-groups.e2e.ts new file mode 100644 index 0000000000..4df2c62d36 --- /dev/null +++ b/test/e2e/silo-users-and-groups.e2e.ts @@ -0,0 +1,92 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { expect, expectRowVisible, expectVisible, test } from './utils' + +test('Users tab shows direct and via-group silo roles', async ({ page }) => { + await page.goto('/users-and-groups') + + // landed on default tab + await expectVisible(page, ['role=heading[name*="Users & Groups"]']) + await expect(page).toHaveURL(/\/users-and-groups\/users$/) + + const table = page.getByRole('table') + + // Hannah has a direct silo.admin assignment; Groups column shows the first + // group (kernel-devs) and "+1" for web-devs + await expectRowVisible(table, { + Name: 'Hannah Arendt', + 'Silo Role': 'silo.admin', + Groups: 'kernel-devs+1', + }) + + // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs + await expectRowVisible(table, { + Name: 'Hans Jonas', + 'Silo Role': 'silo.collaborator', + Groups: 'real-estate-devs', + }) + + // Jacob Klein has no silo role and no groups + await expectRowVisible(table, { Name: 'Jacob Klein', 'Silo Role': '—', Groups: '—' }) +}) + +test('User details side modal shows assigned + via-group roles and group list', async ({ + page, +}) => { + await page.goto('/users-and-groups/users') + + // Open Hannah's details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // Direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toBeVisible() + await expect(roleRow).toContainText('Assigned') + + // Group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs' })).toBeVisible() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + + // Hans Jonas inherits silo.collaborator via real-estate-devs + await page.getByRole('button', { name: 'Hans Jonas' }).click() + await expect(modal).toBeVisible() + const viaRow = modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await expect(viaRow).toContainText('via real-estate-devs') +}) + +test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { + await page.goto('/users-and-groups/users') + + // Switch to Groups tab via the tablist + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/users-and-groups\/groups$/) + + const table = page.getByRole('table') + + await expectRowVisible(table, { + Name: 'real-estate-devs', + 'Silo Role': 'silo.collaborator', + Users: '2', + }) + await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': '—', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', 'Silo Role': '—', Users: '1' }) + + // Open the real-estate-devs group modal + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) From 0db41023528854afae13c24cf9080109661bc823 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 4 May 2026 17:40:02 -0400 Subject: [PATCH 23/45] Consolidate Users and Groups into Silo Access page --- app/api/roles.ts | 6 +- app/forms/silo-access.tsx | 70 +----- app/layouts/SiloLayout.tsx | 5 - ...sGroupsTab.tsx => SiloAccessGroupsTab.tsx} | 82 +++++-- app/pages/SiloAccessPage.tsx | 191 ++-------------- ...upsUsersTab.tsx => SiloAccessUsersTab.tsx} | 92 ++++++-- app/pages/SiloUsersAndGroupsPage.tsx | 37 --- app/routes.tsx | 10 +- .../__snapshots__/path-builder.spec.ts.snap | 34 ++- app/util/path-builder.spec.ts | 7 +- app/util/path-builder.ts | 7 +- test/e2e/silo-access.e2e.ts | 210 ++++++++++++++++-- test/e2e/silo-users-and-groups.e2e.ts | 92 -------- 13 files changed, 388 insertions(+), 455 deletions(-) rename app/pages/{SiloUsersAndGroupsGroupsTab.tsx => SiloAccessGroupsTab.tsx} (56%) rename app/pages/{SiloUsersAndGroupsUsersTab.tsx => SiloAccessUsersTab.tsx} (61%) delete mode 100644 app/pages/SiloUsersAndGroupsPage.tsx delete mode 100644 test/e2e/silo-users-and-groups.e2e.ts diff --git a/app/api/roles.ts b/app/api/roles.ts index 73629e1a5d..980765f577 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -151,11 +151,11 @@ export function useActorsNotInPolicy( }, [users, groups, policy]) } -export function userRoleFromPolicies( +export function userRoleFromPolicies( user: { id: string }, groups: { id: string }[], - policy: Policy -): RoleKey | null { + policy: Policy +): Role | null { const myIds = new Set([user.id, ...groups.map((g) => g.id)]) const myRoles = policy.roleAssignments .filter((ra) => myIds.has(ra.identityId)) diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 5896900c2a..448e8128df 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -7,76 +7,15 @@ */ import { useForm } from 'react-hook-form' -import { - api, - queryClient, - updateRole, - useActorsNotInPolicy, - useApiMutation, -} from '@oxide/api' +import { api, queryClient, updateRole, useApiMutation } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' -import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { ResourceLabel } from '~/ui/lib/SideModal' import { docLinks } from '~/util/links' -import { - actorToItem, - defaultValues, - RoleRadioField, - type AddRoleModalProps, - type EditRoleModalProps, -} from './access-util' - -export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { - const actors = useActorsNotInPolicy(policy) - - const updatePolicy = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - onDismiss() - }, - }) - - const form = useForm({ defaultValues }) - - return ( - { - updatePolicy.reset() // clear API error state so it doesn't persist on next open - onDismiss() - }} - onSubmit={({ identityId, roleName }) => { - // TODO: DRY logic - // actor is guaranteed to be in the list because it came from there - const identityType = actors.find((a) => a.id === identityId)!.identityType - - updatePolicy.mutate({ - body: updateRole({ identityId, identityType, roleName }, policy), - }) - }} - loading={updatePolicy.isPending} - submitError={updatePolicy.error} - > - - - - - ) -} +import { RoleRadioField, type EditRoleModalProps } from './access-util' export function SiloAccessEditUserSideModal({ onDismiss, @@ -86,6 +25,7 @@ export function SiloAccessEditUserSideModal({ policy, defaultValues, }: EditRoleModalProps) { + const isAssigning = !defaultValues.roleName const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -97,9 +37,9 @@ export function SiloAccessEditUserSideModal({ return ( {name} diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 079148549a..13d1e49dce 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,7 +12,6 @@ import { Folder16Icon, Images16Icon, Metrics16Icon, - Person16Icon, } from '@oxide/design-system/icons/react' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' @@ -35,7 +34,6 @@ 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) @@ -65,9 +63,6 @@ export default function SiloLayout() { Utilization - - Users & Groups - Silo Access diff --git a/app/pages/SiloUsersAndGroupsGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx similarity index 56% rename from app/pages/SiloUsersAndGroupsGroupsTab.tsx rename to app/pages/SiloAccessGroupsTab.tsx index 40899a398c..e0ae3a2eb7 100644 --- a/app/pages/SiloUsersAndGroupsGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -6,47 +6,41 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { api, + deleteRole, getListQFn, q, queryClient, rolesByIdFromPolicy, + useApiMutation, usePrefetchedQuery, type Group, + type SiloRole, } 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 { HL } from '~/components/HL' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' +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 { roleColor } from '~/util/access' -import { ALL_ISH } from '~/util/consts' +// data fetching for both tabs lives in the parent SiloAccessPage loader const policyView = q(api.policyView, {}) const groupList = getListQFn(api.groupList, {}) -const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) - -export async function clientLoader() { - // prefetch member lists so MemberCountCell renders without an on-mount fetch - const groups = await queryClient.fetchQuery(groupListAll) - await Promise.all([ - queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(groupList.optionsFn()), - ...groups.items.map((g) => - queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) - ), - ]) - return null -} export const handle = titleCrumb('Groups') @@ -60,13 +54,23 @@ const GroupEmptyState = () => ( /> ) -export default function SiloUsersAndGroupsGroupsTab() { +type EditingState = { group: Group; defaultRole: SiloRole | undefined } + +export default function SiloAccessGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) const { data: siloPolicy } = usePrefetchedQuery(policyView) const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) + const siloRoleCol = useMemo( () => colHelper.display({ @@ -101,7 +105,39 @@ export default function SiloUsersAndGroupsGroupsTab() { [siloRoleCol] ) - const columns = staticColumns + const makeActions = useCallback( + (group: Group): MenuAction[] => { + const directRole = siloRoleById.get(group.id) + if (!directRole) { + return [ + { + label: 'Assign role', + onActivate: () => setEditingGroup({ group, defaultRole: undefined }), + }, + ] + } + return [ + { + label: 'Change role', + onActivate: () => setEditingGroup({ group, defaultRole: directRole }), + }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), + label: ( + + the {directRole} role for {group.displayName} + + ), + }), + }, + ] + }, + [siloRoleById, siloPolicy, updatePolicy] + ) + + const columns = useColsWithActions(staticColumns, makeActions) const { table } = useQueryTable({ query: groupList, @@ -112,6 +148,16 @@ export default function SiloUsersAndGroupsGroupsTab() { return ( <> {table} + {editingGroup && ( + setEditingGroup(null)} + policy={siloPolicy} + name={editingGroup.group.displayName} + identityId={editingGroup.group.id} + identityType="silo_group" + defaultValues={{ roleName: editingGroup.defaultRole }} + /> + )} {selectedGroup && ( + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ), ]) return null } -export const handle = { crumb: 'Silo Access' } - -type AccessRow = { - id: string - name: string - identityType: IdentityType - role: SiloRole -} - -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 const handle = makeCrumb('Silo Access', pb.siloAccess()) export default function SiloAccessPage() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingRow, setEditingRow] = useState(null) - - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: users } = usePrefetchedQuery(userList) - const { data: groups } = usePrefetchedQuery(groupList) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - addToast({ content: 'Access removed' }) - }, - }) - - 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])) - - const userRows: AccessRow[] = siloPolicy.roleAssignments - .filter((ra) => ra.identityType === 'silo_user') - .map((ra) => ({ - id: ra.identityId, - name: userById.get(ra.identityId)?.displayName ?? ra.identityId, - identityType: 'silo_user' as IdentityType, - role: ra.roleName, - })) - - 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, - role: ra.roleName, - })) - - return [...groupRows, ...userRows].sort(byGroupThenName) - }, [siloPolicy, users, groups]) - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('role', { - header: 'Silo Role', - cell: (info) => ( - silo.{info.getValue()} - ), - }), - getActionsCol((row: AccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingRow(row), - }, - { - label: 'Remove role', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), - label: ( - - the {row.role} role for {row.name} - - ), - }), - }, - ]), - ], - [siloPolicy, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - useQuickActions( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) - return ( <> @@ -179,30 +52,10 @@ export default function SiloAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - setAddModalOpen(true)}>Add user or group - - {addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {editingRow && ( - setEditingRow(null)} - policy={siloPolicy} - name={editingRow.name} - identityId={editingRow.id} - identityType={editingRow.identityType} - defaultValues={{ roleName: editingRow.role }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( - - )} + + Users + Groups + ) } diff --git a/app/pages/SiloUsersAndGroupsUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx similarity index 61% rename from app/pages/SiloUsersAndGroupsUsersTab.tsx rename to app/pages/SiloAccessUsersTab.tsx index 2f9e6297e9..c0a7f7163e 100644 --- a/app/pages/SiloUsersAndGroupsUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -6,28 +6,36 @@ * Copyright Oxide Computer Company */ import { createColumnHelper } from '@tanstack/react-table' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { api, + deleteRole, getListQFn, q, queryClient, roleOrder, rolesByIdFromPolicy, + useApiMutation, useGroupsByUserId, usePrefetchedQuery, userRoleFromPolicies, + type SiloRole, 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 { 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 { 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' @@ -35,22 +43,11 @@ import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' +// data fetching for both tabs lives in the parent SiloAccessPage loader 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() @@ -65,8 +62,11 @@ const EmptyState = () => ( /> ) -export default function SiloUsersAndGroupsUsersTab() { +type EditingState = { user: User; defaultRole: SiloRole | undefined } + +export default function SiloAccessUsersTab() { const [selectedUser, setSelectedUser] = useState(null) + const [editingUser, setEditingUser] = useState(null) const { data: siloPolicy } = usePrefetchedQuery(policyView) const { data: groups } = usePrefetchedQuery(groupListAll) @@ -75,6 +75,13 @@ export default function SiloUsersAndGroupsUsersTab() { const groupsByUserId = useGroupsByUserId(groups.items) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) + const siloRoleCol = useMemo( () => colHelper.display({ @@ -134,7 +141,7 @@ export default function SiloUsersAndGroupsUsersTab() { [groupsByUserId] ) - const columns = useMemo( + const staticColumns = useMemo( () => [ colHelper.accessor('displayName', { header: 'Name', @@ -151,11 +158,66 @@ export default function SiloUsersAndGroupsUsersTab() { [siloRoleCol, groupsCol] ) + const makeActions = useCallback( + (user: User): MenuAction[] => { + const directRole = siloRoleById.get(user.id) + const userGroups = groupsByUserId.get(user.id) ?? [] + const effectiveRole = userRoleFromPolicies(user, userGroups, siloPolicy) + // Only show "Assign role" when there's no role at all — direct or inherited. + // If there's any role in the badge, we show Change/Remove (Remove is + // disabled for inherited-only since there's no direct assignment to delete). + if (!effectiveRole) { + return [ + { + label: 'Assign role', + onActivate: () => setEditingUser({ user, defaultRole: undefined }), + }, + ] + } + return [ + { + label: 'Change role', + // pre-fill with the inherited role when there's no direct assignment, + // so the modal opens in 'edit' mode rather than 'assign' mode and the + // user can see what role is currently in effect + onActivate: () => + setEditingUser({ user, defaultRole: directRole ?? effectiveRole }), + }, + { + label: 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), + label: ( + + the {directRole} role for {user.displayName} + + ), + }), + disabled: + !directRole && 'Role is inherited from a group; modify the group to revoke', + }, + ] + }, + [siloRoleById, siloPolicy, updatePolicy, groupsByUserId] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ query: userList, columns, emptyState: }) return ( <> {table} + {editingUser && ( + setEditingUser(null)} + policy={siloPolicy} + name={editingUser.user.displayName} + identityId={editingUser.user.id} + identityType="silo_user" + defaultValues={{ roleName: editingUser.defaultRole }} + /> + )} {selectedUser && ( - - }>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/routes.tsx b/app/routes.tsx index 66d951c7cc..3ed7db0f88 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -308,19 +308,15 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)} /> - import('./pages/SiloUsersAndGroupsPage').then(convert)} - > + import('./pages/SiloAccessPage').then(convert)}> } /> import('./pages/SiloUsersAndGroupsUsersTab').then(convert)} + lazy={() => import('./pages/SiloAccessUsersTab').then(convert)} /> import('./pages/SiloUsersAndGroupsGroupsTab').then(convert)} + lazy={() => import('./pages/SiloAccessGroupsTab').then(convert)} /> diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 5f1b591344..7d760fd65e 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -617,10 +617,22 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/idps", }, ], - "siloAccess (/access)": [ + "siloAccess (/access/users)": [ { "label": "Silo Access", - "path": "/access", + "path": "/access/users", + }, + ], + "siloAccessGroups (/access/groups)": [ + { + "label": "Silo Access", + "path": "/access/users", + }, + ], + "siloAccessUsers (/access/users)": [ + { + "label": "Silo Access", + "path": "/access/users", }, ], "siloFleetRoles (/system/silos/s/fleet-roles)": [ @@ -733,24 +745,6 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/subnet-pools", }, ], - "siloUsersAndGroups (/users-and-groups/users)": [ - { - "label": "Users & Groups", - "path": "/users-and-groups/users", - }, - ], - "siloUsersAndGroupsGroups (/users-and-groups/groups)": [ - { - "label": "Users & Groups", - "path": "/users-and-groups/users", - }, - ], - "siloUsersAndGroupsUsers (/users-and-groups/users)": [ - { - "label": "Users & Groups", - "path": "/users-and-groups/users", - }, - ], "siloUtilization (/utilization)": [ { "label": "Utilization", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 2a09a31f51..a3a2128b2e 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -88,7 +88,9 @@ test('path builder', () => { "samlIdp": "/system/silos/s/idps/saml/pr", "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", - "siloAccess": "/access", + "siloAccess": "/access/users", + "siloAccessGroups": "/access/groups", + "siloAccessUsers": "/access/users", "siloFleetRoles": "/system/silos/s/fleet-roles", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", @@ -98,9 +100,6 @@ test('path builder', () => { "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", "siloSubnetPools": "/system/silos/s/subnet-pools", - "siloUsersAndGroups": "/users-and-groups/users", - "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 48f4b5e4d5..ebcc02e476 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -111,11 +111,10 @@ export const pb = { `${pb.antiAffinityGroup(params)}/edit`, siloUtilization: () => '/utilization', - siloAccess: () => '/access', - siloUsersAndGroupsUsers: () => '/users-and-groups/users', - siloUsersAndGroupsGroups: () => '/users-and-groups/groups', + siloAccessUsers: () => '/access/users', + siloAccessGroups: () => '/access/groups', // points to the default tab to avoid bouncing through the parent's redirect - siloUsersAndGroups: () => pb.siloUsersAndGroupsUsers(), + siloAccess: () => pb.siloAccessUsers(), siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 1fecbba079..7721e3933c 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -7,36 +7,214 @@ */ import { expect, expectRowVisible, expectVisible, test } from './utils' -test('Click through silo access page', async ({ page }) => { +test('Access page lands on Users tab and shows direct + via-group silo roles', async ({ + page, +}) => { await page.goto('/') await page.click('role=link[name*="Access"]') + await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page).toHaveURL(/\/access\/users$/) + + const table = page.getByRole('table') + + // Hannah has a direct silo.admin assignment; Groups column shows the first + // group (kernel-devs) and "+1" for web-devs + await expectRowVisible(table, { + Name: 'Hannah Arendt', + 'Silo Role': 'silo.admin', + Groups: 'kernel-devs+1', + }) + + // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs + await expectRowVisible(table, { + Name: 'Hans Jonas', + 'Silo Role': 'silo.collaborator', + Groups: 'real-estate-devs', + }) + + // Jacob Klein has no silo role and no groups + await expectRowVisible(table, { Name: 'Jacob Klein', 'Silo Role': '—', Groups: '—' }) +}) + +test('User details side modal shows assigned + via-group roles and group list', async ({ + page, +}) => { + await page.goto('/access/users') + + // Open Hannah's details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // Direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toBeVisible() + await expect(roleRow).toContainText('Assigned') + + // Group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs' })).toBeVisible() + + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + + // Hans Jonas inherits silo.collaborator via real-estate-devs + await page.getByRole('button', { name: 'Hans Jonas' }).click() + await expect(modal).toBeVisible() + const viaRow = modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) + await expect(viaRow).toContainText('via real-estate-devs') +}) + +test('Change and remove a user role from the Users tab', async ({ page }) => { + await page.goto('/access/users') + const table = page.getByRole('table') + + // Hannah has a direct silo.admin role; change it to viewer + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Admin / })).toBeChecked() + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': 'silo.viewer' }) + + // Remove Hannah's direct role; she still inherits via groups so the row stays + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Remove role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + // After removal, Hannah no longer has a direct silo role, so her displayed role + // reflects whatever she inherits via her groups (kernel-devs, web-devs have no + // silo role assignments by default in mock data). + await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': '—' }) +}) + +test('Assign role to a user with no direct role from the row action', async ({ page }) => { + await page.goto('/access/users') + const table = page.getByRole('table') + + // Jacob Klein has no direct or inherited role + await table + .getByRole('row', { name: 'Jacob Klein', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + // unassigned users show only "Assign role" — no Change/Remove + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Assign role' }).click() + + // Modal opens with the user already targeted (no listbox), and no role pre-selected + await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('button', { name: 'User or group' })).toBeHidden() + await expect(page.getByRole('dialog')).toContainText('Jacob Klein') + + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + await expectRowVisible(table, { + Name: 'Jacob Klein', + 'Silo Role': 'silo.collaborator', + }) +}) + +test('Inherited-only role shows Change/Remove with Remove disabled', async ({ page }) => { + await page.goto('/access/users') + const table = page.getByRole('table') + + // Hans Jonas has no direct silo role but inherits silo.collaborator via + // real-estate-devs, so the badge shows but Remove can't act on a direct role + await table + .getByRole('row', { name: 'Hans Jonas', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Assign role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeDisabled() + + // Change role opens the edit modal with the inherited role pre-selected + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() +}) + +test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { + await page.goto('/access/users') + + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/access\/groups$/) + + const table = page.getByRole('table') - // Mixed table: groups first, then users - const table = page.locator('role=table') await expectRowVisible(table, { Name: 'real-estate-devs', 'Silo Role': 'silo.collaborator', + Users: '2', }) - await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': 'silo.admin' }) + await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': '—', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', 'Silo Role': '—', Users: '1' }) + + // Open the real-estate-devs group modal + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) + +test('Change and remove a group role from the Groups tab', async ({ page }) => { + await page.goto('/access/groups') + const table = page.getByRole('table') - // Change real-estate-devs role from collaborator to viewer + // real-estate-devs has silo.collaborator; change to viewer await table - .locator('role=row', { hasText: 'real-estate-devs' }) - .locator('role=button[name="Row actions"]') + .getByRole('row', { name: 'real-estate-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') + await page.getByRole('menuitem', { name: 'Change role' }).click() await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { Name: 'real-estate-devs', 'Silo Role': 'silo.viewer' }) + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { + Name: 'real-estate-devs', + 'Silo Role': 'silo.viewer', + }) - // Remove real-estate-devs role; row disappears since it now has no silo role + // Remove the role await table - .locator('role=row', { hasText: 'real-estate-devs' }) - .locator('role=button[name="Row actions"]') + .getByRole('row', { name: 'real-estate-devs', exact: false }) + .getByRole('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.getByRole('menuitem', { name: 'Remove role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Name: 'real-estate-devs', 'Silo Role': '—' }) +}) + +test('Assign a role to a group with no direct role from the row action', async ({ + page, +}) => { + await page.goto('/access/groups') + const table = page.getByRole('table') + + // kernel-devs has no direct silo role + await table + .getByRole('row', { name: 'kernel-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Assign role' }).click() + await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('dialog')).toContainText('kernel-devs') + + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': 'silo.viewer' }) }) diff --git a/test/e2e/silo-users-and-groups.e2e.ts b/test/e2e/silo-users-and-groups.e2e.ts deleted file mode 100644 index 4df2c62d36..0000000000 --- a/test/e2e/silo-users-and-groups.e2e.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 { expect, expectRowVisible, expectVisible, test } from './utils' - -test('Users tab shows direct and via-group silo roles', async ({ page }) => { - await page.goto('/users-and-groups') - - // landed on default tab - await expectVisible(page, ['role=heading[name*="Users & Groups"]']) - await expect(page).toHaveURL(/\/users-and-groups\/users$/) - - const table = page.getByRole('table') - - // Hannah has a direct silo.admin assignment; Groups column shows the first - // group (kernel-devs) and "+1" for web-devs - await expectRowVisible(table, { - Name: 'Hannah Arendt', - 'Silo Role': 'silo.admin', - Groups: 'kernel-devs+1', - }) - - // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs - await expectRowVisible(table, { - Name: 'Hans Jonas', - 'Silo Role': 'silo.collaborator', - Groups: 'real-estate-devs', - }) - - // Jacob Klein has no silo role and no groups - await expectRowVisible(table, { Name: 'Jacob Klein', 'Silo Role': '—', Groups: '—' }) -}) - -test('User details side modal shows assigned + via-group roles and group list', async ({ - page, -}) => { - await page.goto('/users-and-groups/users') - - // Open Hannah's details - await page.getByRole('button', { name: 'Hannah Arendt' }).click() - const modal = page.getByRole('dialog') - await expect(modal).toBeVisible() - await expect(modal.getByText('Hannah Arendt')).toBeVisible() - - // Direct silo.admin assignment - const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) - await expect(roleRow).toBeVisible() - await expect(roleRow).toContainText('Assigned') - - // Group memberships - await expect(modal.getByRole('cell', { name: 'kernel-devs' })).toBeVisible() - await expect(modal.getByRole('cell', { name: 'web-devs' })).toBeVisible() - - await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() - await expect(modal).toBeHidden() - - // Hans Jonas inherits silo.collaborator via real-estate-devs - await page.getByRole('button', { name: 'Hans Jonas' }).click() - await expect(modal).toBeVisible() - const viaRow = modal.getByRole('row').filter({ hasText: 'silo.collaborator' }) - await expect(viaRow).toContainText('via real-estate-devs') -}) - -test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { - await page.goto('/users-and-groups/users') - - // Switch to Groups tab via the tablist - await page.getByRole('tab', { name: 'Groups' }).click() - await expect(page).toHaveURL(/\/users-and-groups\/groups$/) - - const table = page.getByRole('table') - - await expectRowVisible(table, { - Name: 'real-estate-devs', - 'Silo Role': 'silo.collaborator', - Users: '2', - }) - await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': '—', Users: '1' }) - await expectRowVisible(table, { Name: 'web-devs', 'Silo Role': '—', Users: '1' }) - - // Open the real-estate-devs group modal - await page.getByRole('button', { name: 'real-estate-devs' }).click() - const modal = page.getByRole('dialog') - await expect(modal).toBeVisible() - await expect(modal.getByText('silo.collaborator')).toBeVisible() - await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() - await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() -}) From 719608979d6c9cc3d9c1b63367c994a86b430895 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 5 May 2026 10:49:37 -0400 Subject: [PATCH 24/45] Align Project Access page design with Silo Access; put Groups as first tab; add tests --- app/api/roles.ts | 43 ++- app/components/access/AccessGroupsTab.tsx | 225 ++++++++++++++ app/components/access/AccessUsersTab.tsx | 287 +++++++++++++++++ .../access/GroupMembersSideModal.tsx | 32 +- .../access/UserDetailsSideModal.tsx | 19 +- app/forms/project-access.tsx | 78 +---- app/pages/SiloAccessGroupsTab.tsx | 142 +-------- app/pages/SiloAccessPage.tsx | 2 +- app/pages/SiloAccessUsersTab.tsx | 203 +----------- .../project/access/ProjectAccessGroupsTab.tsx | 52 ++++ .../project/access/ProjectAccessPage.tsx | 289 ++---------------- .../project/access/ProjectAccessUsersTab.tsx | 52 ++++ app/routes.tsx | 26 +- .../__snapshots__/path-builder.spec.ts.snap | 38 ++- app/util/path-builder.spec.ts | 6 +- app/util/path-builder.ts | 7 +- test/e2e/project-access.e2e.ts | 185 ++++++++++- test/e2e/silo-access.e2e.ts | 29 +- 18 files changed, 977 insertions(+), 738 deletions(-) create mode 100644 app/components/access/AccessGroupsTab.tsx create mode 100644 app/components/access/AccessUsersTab.tsx create mode 100644 app/pages/project/access/ProjectAccessGroupsTab.tsx create mode 100644 app/pages/project/access/ProjectAccessUsersTab.tsx diff --git a/app/api/roles.ts b/app/api/roles.ts index 980765f577..5ba0787a37 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -163,31 +163,60 @@ export function userRoleFromPolicies( return getEffectiveRole(myRoles) || null } +export type AccessScope = 'silo' | 'project' +export type ScopedPolicy = { scope: AccessScope; policy: Policy } + export type ScopedRoleEntry = { roleName: RoleKey + scope: AccessScope source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } } } /** * Enumerate all role assignments relevant to a user — one entry per direct - * assignment and one per group assignment — from the silo policy. + * assignment and one per group assignment — across the given policies. Each + * entry is tagged with the scope of the policy it came from. * Callers are responsible for sorting and any display-layer merging. */ export function userScopedRoleEntries( userId: string, userGroups: { id: string; displayName: string }[], - policy: Policy + scopedPolicies: ScopedPolicy[] ): ScopedRoleEntry[] { const entries: ScopedRoleEntry[] = [] - const direct = policy.roleAssignments.find((ra) => ra.identityId === userId) - if (direct) entries.push({ 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({ roleName: via.roleName, source: { type: 'group', group } }) + for (const { scope, policy } of scopedPolicies) { + const direct = policy.roleAssignments.find((ra) => ra.identityId === userId) + if (direct) { + entries.push({ roleName: direct.roleName, scope, source: { type: 'direct' } }) + } + for (const group of userGroups) { + const via = policy.roleAssignments.find((ra) => ra.identityId === group.id) + if (via) { + entries.push({ roleName: via.roleName, scope, source: { type: 'group', group } }) + } + } } return entries } +/** + * Pick the strongest role across entries. Ties go to silo scope, since silo + * roles cascade into projects. + */ +export function effectiveScopedRole( + entries: ScopedRoleEntry[] +): { role: RoleKey; scope: AccessScope } | null { + if (entries.length === 0) return null + // strongest role overall + const strongest = R.firstBy(entries, (e) => roleOrder[e.roleName])! + const role = strongest.roleName + // prefer silo scope when silo has a role at least as strong + const siloDominates = entries.some( + (e) => e.scope === 'silo' && roleOrder[e.roleName] <= roleOrder[role] + ) + return { role, scope: siloDominates ? 'silo' : 'project' } +} + /** * Builds a map from user ID to the list of groups that user belongs to, * firing one query per group to fetch members. Shared between user tabs. diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx new file mode 100644 index 0000000000..ba1e003575 --- /dev/null +++ b/app/components/access/AccessGroupsTab.tsx @@ -0,0 +1,225 @@ +/* + * 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 { useCallback, useMemo, useState, type ComponentType } from 'react' + +import { + api, + deleteRole, + effectiveScopedRole, + getListQFn, + rolesByIdFromPolicy, + type AccessScope, + type Group, + type Policy, + type RoleKey, + type ScopedPolicy, +} from '@oxide/api' +import { PersonGroup24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { type EditRoleModalProps } from '~/forms/access-util' +import { confirmDelete } from '~/stores/confirm-delete' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { MemberCountCell } from '~/table/cells/MemberCountCell' +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 { roleColor } from '~/util/access' + +import { GroupMembersSideModal } from './GroupMembersSideModal' + +const groupList = getListQFn(api.groupList, {}) + +const colHelper = createColumnHelper() + +const GroupEmptyState = () => ( + } + title="No groups" + body="No groups have been added to this silo" + /> +) + +type EditingState = { group: Group; defaultRole: RoleKey | undefined } + +type Props = { + /** Policies that contribute to a group's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this tab — its direct roles are assignable/removable. */ + managedScope: AccessScope + /** Modal for assigning/editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise +} + +export function AccessGroupsTab({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, +}: Props) { + const [selectedGroup, setSelectedGroup] = useState(null) + const [editingGroup, setEditingGroup] = useState(null) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + + const roleCol = useMemo( + () => + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + // groups never inherit roles, so each scoped policy contributes at + // most one direct entry + const entries = scopedPolicies.flatMap(({ scope, policy }) => { + const ra = policy.roleAssignments.find((r) => r.identityId === row.original.id) + return ra + ? [{ scope, roleName: ra.roleName, source: { type: 'direct' as const } }] + : [] + }) + const effective = effectiveScopedRole(entries) + if (!effective) return + return ( + + {effective.scope}.{effective.role} + + ) + }, + }), + [scopedPolicies] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedGroup(info.row.original)}> + {info.getValue()} + + ), + }), + roleCol, + colHelper.display({ + id: 'memberCount', + header: 'Users', + cell: ({ row }) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [roleCol] + ) + + const isProject = managedScope === 'project' + const assignLabel = isProject ? 'Assign project role' : 'Assign role' + const changeLabel = isProject ? 'Change project role' : 'Change role' + const removeLabel = isProject ? 'Remove project role' : 'Remove role' + + const makeActions = useCallback( + (group: Group): MenuAction[] => { + const directManagedRole = managedRoleById.get(group.id) + const entries = scopedPolicies.flatMap(({ scope, policy }) => { + const ra = policy.roleAssignments.find((r) => r.identityId === group.id) + return ra + ? [{ scope, roleName: ra.roleName, source: { type: 'direct' as const } }] + : [] + }) + const effective = effectiveScopedRole(entries) + const removeAction = { + label: directManagedRole ? removeLabel : 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), + label: ( + + the {directManagedRole} role for {group.displayName} + + ), + }), + disabled: + !directManagedRole && + `Role is inherited from another scope; modify it there to revoke`, + } + if (!effective) { + return [ + { + label: assignLabel, + onActivate: () => setEditingGroup({ group, defaultRole: undefined }), + }, + ] + } + // On the project tab, a group's silo role isn't a project assignment to + // change — frame it as assigning a project role. + if (isProject && !directManagedRole) { + return [ + { + label: assignLabel, + onActivate: () => setEditingGroup({ group, defaultRole: undefined }), + }, + removeAction, + ] + } + const defaultRole = directManagedRole ?? effective.role + return [ + { + label: changeLabel, + onActivate: () => setEditingGroup({ group, defaultRole }), + }, + removeAction, + ] + }, + [ + managedRoleById, + managedPolicy, + updateManagedPolicy, + scopedPolicies, + isProject, + assignLabel, + changeLabel, + removeLabel, + ] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ + query: groupList, + columns, + emptyState: , + }) + + return ( + <> + {table} + {editingGroup && ( + setEditingGroup(null)} + policy={managedPolicy} + name={editingGroup.group.displayName} + identityId={editingGroup.group.id} + identityType="silo_group" + defaultValues={{ roleName: editingGroup.defaultRole }} + /> + )} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={scopedPolicies} + /> + )} + + ) +} diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx new file mode 100644 index 0000000000..5846205cae --- /dev/null +++ b/app/components/access/AccessUsersTab.tsx @@ -0,0 +1,287 @@ +/* + * 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 { useCallback, useMemo, useState, type ComponentType } from 'react' + +import { + api, + deleteRole, + effectiveScopedRole, + getListQFn, + q, + roleOrder, + rolesByIdFromPolicy, + useGroupsByUserId, + usePrefetchedQuery, + userScopedRoleEntries, + type AccessScope, + type Policy, + type RoleKey, + type ScopedPolicy, + type User, +} from '@oxide/api' +import { Person24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { type EditRoleModalProps } from '~/forms/access-util' +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 { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +import { UserDetailsSideModal } from './UserDetailsSideModal' + +const userList = getListQFn(api.userList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +const colHelper = createColumnHelper() + +const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) + +const EmptyState = () => ( + } + title="No users" + body="No users have been added to this silo" + /> +) + +type EditingState = { user: User; defaultRole: RoleKey | undefined } + +type Props = { + /** Policies that contribute to a user's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this tab — its direct roles are assignable/removable. */ + managedScope: AccessScope + /** Modal for assigning/editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise +} + +export function AccessUsersTab({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, +}: Props) { + const [selectedUser, setSelectedUser] = useState(null) + const [editingUser, setEditingUser] = useState(null) + + const { data: groups } = usePrefetchedQuery(groupListAll) + const groupsByUserId = useGroupsByUserId(groups.items) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + + const roleCol = useMemo( + () => + colHelper.display({ + id: 'role', + header: 'Role', + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + const entries = userScopedRoleEntries(row.original.id, userGroups, scopedPolicies) + const effective = effectiveScopedRole(entries) + if (!effective) return + // show "via groups" tooltip when the displayed role+scope isn't + // covered by a direct assignment in that scope. (a direct project + // role doesn't suppress the tooltip if the displayed badge is the + // silo scope coming via a group, since silo wins ties.) + const displayedScopeHasDirect = entries.some( + (e) => + e.source.type === 'direct' && + e.scope === effective.scope && + roleOrder[e.roleName] <= roleOrder[effective.role] + ) + const viaGroupsMap = new Map() + if (!displayedScopeHasDirect) { + for (const e of entries) { + if ( + e.source.type === 'group' && + e.scope === effective.scope && + roleOrder[e.roleName] <= roleOrder[effective.role] + ) { + viaGroupsMap.set(e.source.group.id, e.source.group) + } + } + } + const viaGroups = [...viaGroupsMap.values()] + return ( +
+ + {effective.scope}.{effective.role} + + {viaGroups.length > 0 && ( + + via{' '} + {viaGroups.map((g, i) => ( + + {i > 0 && ', '} + {g.displayName} + + ))} + + )} +
+ ) + }, + }), + [groupsByUserId, scopedPolicies] + ) + + const groupsCol = useMemo( + () => + colHelper.display({ + id: 'groups', + header: 'Groups', + cell: ({ row }) => { + const userGroups = groupsByUserId.get(row.original.id) ?? [] + return ( + + {userGroups.map((g) => ( + {g.displayName} + ))} + + ) + }, + }), + [groupsByUserId] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('displayName', { + header: 'Name', + cell: (info) => ( + setSelectedUser(info.row.original)}> + {info.getValue()} + + ), + }), + roleCol, + groupsCol, + timeCreatedCol, + ], + [roleCol, groupsCol] + ) + + const isProject = managedScope === 'project' + const assignLabel = isProject ? 'Assign project role' : 'Assign role' + const changeLabel = isProject ? 'Change project role' : 'Change role' + const removeLabel = isProject ? 'Remove project role' : 'Remove role' + + const makeActions = useCallback( + (user: User): MenuAction[] => { + const directManagedRole = managedRoleById.get(user.id) + const userGroups = groupsByUserId.get(user.id) ?? [] + const entries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) + const effective = effectiveScopedRole(entries) + const removeAction = { + label: directManagedRole ? removeLabel : 'Remove role', + onActivate: confirmDelete({ + doDelete: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), + label: ( + + the {directManagedRole} role for {user.displayName} + + ), + }), + // a direct role on the managed policy is required to remove anything + disabled: + !directManagedRole && + `Role is inherited; modify the source ${ + entries.find((e) => e.source.type === 'group') ? 'group' : 'silo assignment' + } to revoke`, + } + // No role at all — direct or inherited. + if (!effective) { + return [ + { + label: assignLabel, + onActivate: () => setEditingUser({ user, defaultRole: undefined }), + }, + ] + } + // For the project tab, an inherited silo role doesn't give us anything to + // "change" on the project policy — frame it as assigning a project role. + // For the silo tab, an inherited (via group) role can be promoted to a + // direct silo assignment via "Change role" pre-filled with the effective + // role. + if (isProject && !directManagedRole) { + return [ + { + label: assignLabel, + onActivate: () => setEditingUser({ user, defaultRole: undefined }), + }, + removeAction, + ] + } + // Pre-fill with the direct managed role if any; otherwise the effective + // role so the modal opens in 'edit' mode showing the role currently in + // effect. + const defaultRole = directManagedRole ?? effective.role + return [ + { + label: changeLabel, + onActivate: () => setEditingUser({ user, defaultRole }), + }, + removeAction, + ] + }, + [ + managedRoleById, + managedPolicy, + updateManagedPolicy, + groupsByUserId, + scopedPolicies, + isProject, + assignLabel, + changeLabel, + removeLabel, + ] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + const { table } = useQueryTable({ query: userList, columns, emptyState: }) + + return ( + <> + {table} + {editingUser && ( + setEditingUser(null)} + policy={managedPolicy} + name={editingUser.user.displayName} + identityId={editingUser.user.id} + identityType="silo_user" + defaultValues={{ roleName: editingUser.defaultRole }} + /> + )} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={scopedPolicies} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + + ) +} diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx index 46a754cded..4879551220 100644 --- a/app/components/access/GroupMembersSideModal.tsx +++ b/app/components/access/GroupMembersSideModal.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' -import { api, q, type Group, type Policy, type User } from '@oxide/api' +import { api, q, type Group, type ScopedPolicy, type User } from '@oxide/api' import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -23,14 +23,18 @@ import { ALL_ISH } from '~/util/consts' type Props = { group: Group onDismiss: () => void - policy: Policy + scopedPolicies: ScopedPolicy[] } -export function GroupMembersSideModal({ group, onDismiss, policy }: Props) { +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 assignment = policy.roleAssignments.find((ra) => ra.identityId === group.id) + // role assignments for this group across all relevant policies + const assignments = scopedPolicies.flatMap(({ scope, policy }) => { + const ra = policy.roleAssignments.find((ra) => ra.identityId === group.id) + return ra ? [{ scope, roleName: ra.roleName }] : [] + }) return ( - {!assignment ? ( + {assignments.length === 0 ? ( No roles assigned ) : ( - - - - silo.{assignment.roleName} - - - Assigned - + assignments.map(({ scope, roleName }) => ( + + + + {scope}.{roleName} + + + Assigned + + )) )}
diff --git a/app/components/access/UserDetailsSideModal.tsx b/app/components/access/UserDetailsSideModal.tsx index 95e937ed43..f70bb4512f 100644 --- a/app/components/access/UserDetailsSideModal.tsx +++ b/app/components/access/UserDetailsSideModal.tsx @@ -11,7 +11,7 @@ import { roleOrder, userScopedRoleEntries, type Group, - type Policy, + type ScopedPolicy, type User, } from '@oxide/api' import { Person16Icon } from '@oxide/design-system/icons/react' @@ -28,12 +28,17 @@ type Props = { user: User onDismiss: () => void userGroups: Group[] - policy: Policy + scopedPolicies: ScopedPolicy[] } -export function UserDetailsSideModal({ user, onDismiss, userGroups, policy }: Props) { +export function UserDetailsSideModal({ + user, + onDismiss, + userGroups, + scopedPolicies, +}: Props) { const roleEntries = R.sortBy( - userScopedRoleEntries(user.id, userGroups, policy), + userScopedRoleEntries(user.id, userGroups, scopedPolicies), (e) => roleOrder[e.roleName] ) @@ -68,10 +73,12 @@ export function UserDetailsSideModal({ user, onDismiss, userGroups, policy }: Pr
) : ( - roleEntries.map(({ roleName, source }, i) => ( + roleEntries.map(({ roleName, scope, source }, i) => ( - silo.{roleName} + + {scope}.{roleName} + {source.type === 'direct' && 'Assigned'} diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 55e162e65f..8fcee14702 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -7,79 +7,16 @@ */ import { useForm } from 'react-hook-form' -import { - api, - queryClient, - updateRole, - useActorsNotInPolicy, - useApiMutation, -} from '@oxide/api' +import { api, queryClient, updateRole, useApiMutation } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' -import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { ResourceLabel } from '~/ui/lib/SideModal' import { docLinks } from '~/util/links' -import { - actorToItem, - defaultValues, - RoleRadioField, - type AddRoleModalProps, - type EditRoleModalProps, -} from './access-util' - -export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { - const { project } = useProjectSelector() - - const actors = useActorsNotInPolicy(policy) - - const updatePolicy = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - // We don't have the name of the user or group, so we'll just have a generic message - addToast({ content: 'Role assigned' }) - onDismiss() - }, - }) - - const form = useForm({ defaultValues }) - - return ( - { - // actor is guaranteed to be in the list because it came from there - const identityType = actors.find((a) => a.id === identityId)!.identityType - - updatePolicy.mutate({ - path: { project }, - body: updateRole({ identityId, identityType, roleName }, policy), - }) - }} - loading={updatePolicy.isPending} - submitError={updatePolicy.error} - onDismiss={onDismiss} - > - - - - - ) -} +import { RoleRadioField, type EditRoleModalProps } from './access-util' export function ProjectAccessEditUserSideModal({ onDismiss, @@ -90,11 +27,11 @@ export function ProjectAccessEditUserSideModal({ defaultValues, }: EditRoleModalProps) { const { project } = useProjectSelector() + const isAssigning = !defaultValues.roleName const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Role updated' }) onDismiss() }, }) @@ -104,9 +41,9 @@ export function ProjectAccessEditUserSideModal({ return ( {name} @@ -121,7 +58,10 @@ export function ProjectAccessEditUserSideModal({ }} loading={updatePolicy.isPending} submitError={updatePolicy.error} - onDismiss={onDismiss} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} > diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloAccessGroupsTab.tsx index e0ae3a2eb7..867019079b 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloAccessGroupsTab.tsx @@ -5,65 +5,27 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' - import { api, - deleteRole, - getListQFn, q, queryClient, - rolesByIdFromPolicy, useApiMutation, usePrefetchedQuery, - type Group, - type SiloRole, + type Policy, } 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 { HL } from '~/components/HL' +import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' -import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { EmptyCell } from '~/table/cells/EmptyCell' -import { ButtonCell } from '~/table/cells/LinkCell' -import { MemberCountCell } from '~/table/cells/MemberCountCell' -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 { roleColor } from '~/util/access' -// data fetching for both tabs lives in the parent SiloAccessPage loader const policyView = q(api.policyView, {}) -const groupList = getListQFn(api.groupList, {}) export const handle = titleCrumb('Groups') -const colHelper = createColumnHelper() - -const GroupEmptyState = () => ( - } - title="No groups" - body="No groups have been added to this silo" - /> -) - -type EditingState = { group: Group; defaultRole: SiloRole | undefined } - export default function SiloAccessGroupsTab() { - const [selectedGroup, setSelectedGroup] = useState(null) - const [editingGroup, setEditingGroup] = useState(null) - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -71,100 +33,12 @@ export default function SiloAccessGroupsTab() { }, }) - 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 directRole = siloRoleById.get(group.id) - if (!directRole) { - return [ - { - label: 'Assign role', - onActivate: () => setEditingGroup({ group, defaultRole: undefined }), - }, - ] - } - return [ - { - label: 'Change role', - onActivate: () => setEditingGroup({ group, defaultRole: directRole }), - }, - { - label: 'Remove role', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), - label: ( - - the {directRole} role for {group.displayName} - - ), - }), - }, - ] - }, - [siloRoleById, siloPolicy, updatePolicy] - ) - - const columns = useColsWithActions(staticColumns, makeActions) - - const { table } = useQueryTable({ - query: groupList, - columns, - emptyState: , - }) - return ( - <> - {table} - {editingGroup && ( - setEditingGroup(null)} - policy={siloPolicy} - name={editingGroup.group.displayName} - identityId={editingGroup.group.id} - identityType="silo_group" - defaultValues={{ roleName: editingGroup.defaultRole }} - /> - )} - {selectedGroup && ( - setSelectedGroup(null)} - policy={siloPolicy} - /> - )} - + updatePolicy({ body })} + /> ) } diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 2445eee63e..9ae3a3c1ef 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -53,8 +53,8 @@ export default function SiloAccessPage() { /> - Users Groups + Users ) diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloAccessUsersTab.tsx index c0a7f7163e..713cf3a2e2 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloAccessUsersTab.tsx @@ -5,75 +5,26 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' - import { api, - deleteRole, - getListQFn, q, queryClient, - roleOrder, - rolesByIdFromPolicy, useApiMutation, - useGroupsByUserId, usePrefetchedQuery, - userRoleFromPolicies, - type SiloRole, - type User, + type Policy, } 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 { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' +import { AccessUsersTab } from '~/components/access/AccessUsersTab' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' -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 { TipIcon } from '~/ui/lib/TipIcon' -import { roleColor } from '~/util/access' -import { ALL_ISH } from '~/util/consts' -// data fetching for both tabs lives in the parent SiloAccessPage loader const policyView = q(api.policyView, {}) -const userList = getListQFn(api.userList, {}) -const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) 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" - /> -) - -type EditingState = { user: User; defaultRole: SiloRole | undefined } - export default function SiloAccessUsersTab() { - const [selectedUser, setSelectedUser] = useState(null) - const [editingUser, setEditingUser] = useState(null) - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: groups } = usePrefetchedQuery(groupListAll) - - const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - - const groupsByUserId = useGroupsByUserId(groups.items) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { @@ -82,150 +33,12 @@ export default function SiloAccessUsersTab() { }, }) - 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( - () => [ - colHelper.accessor('displayName', { - header: 'Name', - cell: (info) => ( - setSelectedUser(info.row.original)}> - {info.getValue()} - - ), - }), - siloRoleCol, - groupsCol, - timeCreatedCol, - ], - [siloRoleCol, groupsCol] - ) - - const makeActions = useCallback( - (user: User): MenuAction[] => { - const directRole = siloRoleById.get(user.id) - const userGroups = groupsByUserId.get(user.id) ?? [] - const effectiveRole = userRoleFromPolicies(user, userGroups, siloPolicy) - // Only show "Assign role" when there's no role at all — direct or inherited. - // If there's any role in the badge, we show Change/Remove (Remove is - // disabled for inherited-only since there's no direct assignment to delete). - if (!effectiveRole) { - return [ - { - label: 'Assign role', - onActivate: () => setEditingUser({ user, defaultRole: undefined }), - }, - ] - } - return [ - { - label: 'Change role', - // pre-fill with the inherited role when there's no direct assignment, - // so the modal opens in 'edit' mode rather than 'assign' mode and the - // user can see what role is currently in effect - onActivate: () => - setEditingUser({ user, defaultRole: directRole ?? effectiveRole }), - }, - { - label: 'Remove role', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), - label: ( - - the {directRole} role for {user.displayName} - - ), - }), - disabled: - !directRole && 'Role is inherited from a group; modify the group to revoke', - }, - ] - }, - [siloRoleById, siloPolicy, updatePolicy, groupsByUserId] - ) - - const columns = useColsWithActions(staticColumns, makeActions) - - const { table } = useQueryTable({ query: userList, columns, emptyState: }) - return ( - <> - {table} - {editingUser && ( - setEditingUser(null)} - policy={siloPolicy} - name={editingUser.user.displayName} - identityId={editingUser.user.id} - identityType="silo_user" - defaultValues={{ roleName: editingUser.defaultRole }} - /> - )} - {selectedUser && ( - setSelectedUser(null)} - policy={siloPolicy} - userGroups={groupsByUserId.get(selectedUser.id) ?? []} - /> - )} - + updatePolicy({ body })} + /> ) } diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx new file mode 100644 index 0000000000..fcb3236218 --- /dev/null +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -0,0 +1,52 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Policy, +} from '@oxide/api' + +import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' +import { ProjectAccessEditUserSideModal } from '~/forms/project-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' + +const policyView = q(api.policyView, {}) + +export const handle = titleCrumb('Groups') + +export default function ProjectAccessGroupsTab() { + const { project } = useProjectSelector() + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery( + q(api.projectPolicyView, { path: { project } }) + ) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Role removed' }) + }, + }) + + return ( + updatePolicy({ path: { project }, body })} + /> + ) +} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 0f14165368..bdaeb11cdf 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,280 +5,49 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo, useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' -import { - api, - byGroupThenName, - deleteRole, - getEffectiveRole, - q, - queryClient, - roleOrder, - useApiMutation, - rolesByIdFromPolicy, - usePrefetchedQuery, - type IdentityType, - type RoleKey, -} from '@oxide/api' +import { api, getListQFn, q, queryClient } 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 { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' +import { RouteTabs, Tab } from '~/components/RouteTabs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' -import { useQuickActions } from '~/hooks/use-quick-actions' -import { confirmDelete } from '~/stores/confirm-delete' -import { addToast } from '~/stores/toast' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' +// Parent prefetches everything both tabs need so switching between Users and +// Groups doesn't trigger a fetch. const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) -const userList = q(api.userList, { query: { limit: ALL_ISH } }) -const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) +const userList = getListQFn(api.userList, {}) +const groupList = getListQFn(api.groupList, {}) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupListAll) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(projectPolicyView(selector)), - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(userList.optionsFn()), + queryClient.prefetchQuery(groupList.optionsFn()), + ...groups.items.map((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ), ]) return null } export const handle = { crumb: 'Project Access' } -type AccessRow = { - id: string - name: string - identityType: IdentityType - effectiveScope: 'silo' | 'project' - effectiveRole: RoleKey - /** Direct project-level role only — the only one manageable on this page. */ - directProjectRole: RoleKey | undefined -} - -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 [editingRow, setEditingRow] = useState(null) - const projectSelector = useProjectSelector() - const { project } = projectSelector - - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const { data: users } = usePrefetchedQuery(userList) - const { data: groups } = usePrefetchedQuery(groupList) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Access removed' }) - }, - }) - - const siloRoleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - const projectRoleById = useMemo(() => rolesByIdFromPolicy(projectPolicy), [projectPolicy]) - - 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 - } - - 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, - } - 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 - } - - const userRows: AccessRow[] = [] - for (const row of intermediateUserRows.values()) { - const allRoles: RoleKey[] = [ - ...(row.directSiloRole ? [row.directSiloRole] : []), - ...(row.directProjectRole ? [row.directProjectRole] : []), - ] - const effectiveRole = getEffectiveRole(allRoles) - if (!effectiveRole) continue - - // Scope is 'silo' if the direct silo role is at least as strong as the effective role - const effectiveScope: 'silo' | 'project' = - row.directSiloRole !== undefined && - roleOrder[row.directSiloRole] <= roleOrder[effectiveRole] - ? 'silo' - : 'project' - - userRows.push({ - id: row.id, - name: row.name, - identityType: 'silo_user', - effectiveScope, - effectiveRole, - 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, - directProjectRole: projectRole, - } - }) - - return [...groupRows, ...userRows].sort(byGroupThenName) - }, [siloPolicy, projectPolicy, users, groups, siloRoleById, projectRoleById]) - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.display({ - id: 'effectiveRole', - header: () => ( -
- Role - - A user or group's effective role for this project is the strongest role - on either the silo or project - -
- ), - cell: ({ row }) => { - const { effectiveScope, effectiveRole } = row.original - return ( - - {effectiveScope}.{effectiveRole} - - ) - }, - }), - getActionsCol((row: AccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingRow(row), - disabled: - !row.directProjectRole && 'This identity has no direct project role to change', - }, - { - label: 'Remove role', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ path: { project }, body: deleteRole(row.id, projectPolicy) }), - label: ( - - the {row.directProjectRole} role for {row.name} - - ), - }), - disabled: - !row.directProjectRole && 'This identity has no direct project role to remove', - }, - ]), - ], - [projectPolicy, project, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - useQuickActions( - () => [ - { - value: 'Add user or group', - navGroup: 'Actions', - action: () => setAddModalOpen(true), - }, - ], - [] - ) - return ( <> @@ -290,30 +59,10 @@ export default function ProjectAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - setAddModalOpen(true)}>Add user or group - - {addModalOpen && ( - setAddModalOpen(false)} - policy={projectPolicy} - /> - )} - {editingRow && ( - setEditingRow(null)} - policy={projectPolicy} - name={editingRow.name} - identityId={editingRow.id} - identityType={editingRow.identityType} - defaultValues={{ roleName: editingRow.directProjectRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( - - )} + + Groups + Users + ) } diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx new file mode 100644 index 0000000000..10acd04e27 --- /dev/null +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -0,0 +1,52 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Policy, +} from '@oxide/api' + +import { AccessUsersTab } from '~/components/access/AccessUsersTab' +import { ProjectAccessEditUserSideModal } from '~/forms/project-access' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' + +const policyView = q(api.policyView, {}) + +export const handle = titleCrumb('Users') + +export default function ProjectAccessUsersTab() { + const { project } = useProjectSelector() + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery( + q(api.projectPolicyView, { path: { project } }) + ) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Role removed' }) + }, + }) + + return ( + updatePolicy({ path: { project }, body })} + /> + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 3ed7db0f88..743e1bc7ba 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -309,15 +309,15 @@ export const routes = createRoutesFromElements( import('./pages/SiloAccessPage').then(convert)}> - } /> - import('./pages/SiloAccessUsersTab').then(convert)} - /> + } /> import('./pages/SiloAccessGroupsTab').then(convert)} /> + import('./pages/SiloAccessUsersTab').then(convert)} + /> @@ -589,7 +589,21 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} - /> + > + } /> + + import('./pages/project/access/ProjectAccessGroupsTab').then(convert) + } + /> + + import('./pages/project/access/ProjectAccessUsersTab').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 7d760fd65e..2ebf1ff04f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -493,7 +493,35 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, ], - "projectAccess (/projects/p/access)": [ + "projectAccess (/projects/p/access/groups)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "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", @@ -617,22 +645,22 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/idps", }, ], - "siloAccess (/access/users)": [ + "siloAccess (/access/groups)": [ { "label": "Silo Access", - "path": "/access/users", + "path": "/access/groups", }, ], "siloAccessGroups (/access/groups)": [ { "label": "Silo Access", - "path": "/access/users", + "path": "/access/groups", }, ], "siloAccessUsers (/access/users)": [ { "label": "Silo Access", - "path": "/access/users", + "path": "/access/groups", }, ], "siloFleetRoles (/system/silos/s/fleet-roles)": [ diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index a3a2128b2e..6ddecf63da 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -78,7 +78,9 @@ test('path builder', () => { "ipPoolsNew": "/system/networking/ip-pools-new", "profile": "/settings/profile", "project": "/projects/p/instances", - "projectAccess": "/projects/p/access", + "projectAccess": "/projects/p/access/groups", + "projectAccessGroups": "/projects/p/access/groups", + "projectAccessUsers": "/projects/p/access/users", "projectEdit": "/projects/p/edit", "projectImageEdit": "/projects/p/images/im/edit", "projectImages": "/projects/p/images", @@ -88,7 +90,7 @@ test('path builder', () => { "samlIdp": "/system/silos/s/idps/saml/pr", "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", - "siloAccess": "/access/users", + "siloAccess": "/access/groups", "siloAccessGroups": "/access/groups", "siloAccessUsers": "/access/users", "siloFleetRoles": "/system/silos/s/fleet-roles", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index ebcc02e476..30ebec15a4 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -26,7 +26,10 @@ export const pb = { project: (params: PP.Project) => `${projectBase(params)}/instances`, 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`, + // points to the default tab to avoid bouncing through the parent's redirect + projectAccess: (params: PP.Project) => pb.projectAccessGroups(params), projectImages: (params: PP.Project) => `${projectBase(params)}/images`, projectImagesNew: (params: PP.Project) => `${projectBase(params)}/images-new`, projectImageEdit: (params: PP.Image) => @@ -114,7 +117,7 @@ export const pb = { siloAccessUsers: () => '/access/users', siloAccessGroups: () => '/access/groups', // points to the default tab to avoid bouncing through the parent's redirect - siloAccess: () => pb.siloAccessUsers(), + siloAccess: () => pb.siloAccessGroups(), siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index e13c4e480d..3ff387b3d3 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -9,37 +9,192 @@ import { user3 } from '@oxide/api-mocks' import { expect, expectRowVisible, expectVisible, test } from './utils' -test('Click through project access page', async ({ page }) => { +test('Project access lands on Groups tab; Users tab shows effective project roles', async ({ + page, +}) => { await page.goto('/projects/mock-project') await page.click('role=link[name*="Access"]') await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page).toHaveURL(/\/access\/groups$/) - // Mixed table: groups first, then users - const table = page.locator('table') + // Groups tab: kernel-devs has direct project.viewer; real-estate-devs has + // direct silo.collaborator only; web-devs has nothing + let table = page.getByRole('table') + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.viewer' }) + await expectRowVisible(table, { Name: 'real-estate-devs', Role: 'silo.collaborator' }) + await expectRowVisible(table, { Name: 'web-devs', Role: '—' }) + + // Switch to Users tab + await page.getByRole('tab', { name: 'Users' }).click() + await expect(page).toHaveURL(/\/access\/users$/) + + table = page.getByRole('table') + // Hannah has direct silo.admin which dominates over kernel-devs project.viewer await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin' }) + // Jacob has direct project.collaborator await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'project.collaborator' }) + // Herbert has direct project.limited_collaborator await expectRowVisible(table, { Name: 'Herbert Marcuse', Role: 'project.limited_collaborator', }) + // Hans inherits silo.collaborator via real-estate-devs + await expectRowVisible(table, { Name: 'Hans Jonas', Role: 'silo.collaborator' }) +}) - // Change Jacob Klein from collaborator to viewer - await page - .locator('role=row', { hasText: user3.display_name }) - .locator('role=button[name="Row actions"]') +test('Change and remove a user project role from the Users tab', async ({ page }) => { + await page.goto('/projects/mock-project/access/users') + const table = page.getByRole('table') + + // Jacob Klein has direct project.collaborator — change to viewer + await table + .getByRole('row', { name: user3.display_name, exact: false }) + .getByRole('button', { name: 'Row actions' }) .click() - await page.click('role=menuitem[name="Change role"]') + await page.getByRole('menuitem', { name: 'Change project role' }).click() await expectVisible(page, ['role=heading[name*="Edit role"]']) await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Update role"]') + await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: user3.display_name, Role: 'project.viewer' }) - // 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: 'Remove role' }).click() + // Remove Jacob's direct project role; he has no other access so badge becomes — + await table + .getByRole('row', { name: user3.display_name, exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Name: user3.display_name, Role: '—' }) +}) + +test('Inherited silo role on Users tab shows Assign + disabled Remove', async ({ + page, +}) => { + await page.goto('/projects/mock-project/access/users') + const table = page.getByRole('table') + + // Hans Jonas inherits silo.collaborator via real-estate-devs but has no + // direct project role — show "Assign project role", and disabled "Remove role" + await table + .getByRole('row', { name: 'Hans Jonas', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() + await expect( + page.getByRole('menuitem', { name: 'Remove role', exact: true }) + ).toBeDisabled() + + // Assign opens the modal with no role pre-selected + await page.getByRole('menuitem', { name: 'Assign project role' }).click() + await expectVisible(page, ['role=heading[name*="Assign role"]']) +}) + +test('Assign a project role to an unassigned user', async ({ page }) => { + await page.goto('/projects/mock-project/access/users') + const table = page.getByRole('table') + + // Simone de Beauvoir has no roles at all + await table + .getByRole('row', { name: 'Simone de Beauvoir', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Assign project role' }).click() + + await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('dialog')).toContainText('Simone de Beauvoir') + + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + await expectRowVisible(table, { + Name: 'Simone de Beauvoir', + Role: 'project.viewer', + }) +}) + +test('Change and remove a group project role from the Groups tab', async ({ page }) => { + await page.goto('/projects/mock-project/access/groups') + const table = page.getByRole('table') + + // kernel-devs has direct project.viewer — change to collaborator + await table + .getByRole('row', { name: 'kernel-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change project role' }).click() + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Viewer / })).toBeChecked() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.collaborator' }) + + // Remove the direct project role + await table + .getByRole('row', { name: 'kernel-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(table.locator('role=row', { hasText: user3.display_name })).toBeHidden() + await expectRowVisible(table, { Name: 'kernel-devs', Role: '—' }) +}) + +test('Assign stronger project role to a silo-only user, then remove it', async ({ + page, +}) => { + await page.goto('/projects/mock-project/access/users') + const table = page.getByRole('table') + const janeRow = table.getByRole('row', { name: 'Jane Austen', exact: false }) + + // Jane Austen is in real-estate-devs (silo.collaborator) and has no direct project role + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) + + // Assign project.collaborator — same level as the silo role; silo wins ties, + // so the badge should keep showing silo.collaborator + await janeRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Assign project role' }).click() + await expectVisible(page, ['role=heading[name*="Assign role"]']) + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) + + // Now that there's a direct project role, the row action menu should expose + // "Change project role" instead of "Assign project role" + await janeRow.getByRole('button', { name: 'Row actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeHidden() + + // Change the project role to admin — stronger than the silo collaborator + await page.getByRole('menuitem', { name: 'Change project role' }).click() + await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + await page.getByRole('radio', { name: /^Admin / }).click() + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin' }) + + // Remove the project role; the row falls back to the inherited silo role + await janeRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) +}) + +test('Group with only a silo role on Project Groups tab', async ({ page }) => { + await page.goto('/projects/mock-project/access/groups') + const table = page.getByRole('table') + + // real-estate-devs has direct silo.collaborator but no direct project role — + // show "Assign project role", and disabled "Remove role" + await table + .getByRole('row', { name: 'real-estate-devs', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() + await expect( + page.getByRole('menuitem', { name: 'Remove role', exact: true }) + ).toBeDisabled() }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 7721e3933c..9c2466cfdf 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -7,13 +7,16 @@ */ import { expect, expectRowVisible, expectVisible, test } from './utils' -test('Access page lands on Users tab and shows direct + via-group silo roles', async ({ +test('Access page lands on Groups tab; Users tab shows direct + via-group silo roles', async ({ page, }) => { await page.goto('/') await page.click('role=link[name*="Access"]') await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page).toHaveURL(/\/access\/groups$/) + + await page.getByRole('tab', { name: 'Users' }).click() await expect(page).toHaveURL(/\/access\/users$/) const table = page.getByRole('table') @@ -22,19 +25,19 @@ test('Access page lands on Users tab and shows direct + via-group silo roles', a // group (kernel-devs) and "+1" for web-devs await expectRowVisible(table, { Name: 'Hannah Arendt', - 'Silo Role': 'silo.admin', + Role: 'silo.admin', Groups: 'kernel-devs+1', }) // Hans Jonas has no direct role but inherits silo.collaborator from real-estate-devs await expectRowVisible(table, { Name: 'Hans Jonas', - 'Silo Role': 'silo.collaborator', + Role: 'silo.collaborator', Groups: 'real-estate-devs', }) // Jacob Klein has no silo role and no groups - await expectRowVisible(table, { Name: 'Jacob Klein', 'Silo Role': '—', Groups: '—' }) + await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—', Groups: '—' }) }) test('User details side modal shows assigned + via-group roles and group list', async ({ @@ -81,7 +84,7 @@ test('Change and remove a user role from the Users tab', async ({ page }) => { await expect(page.getByRole('radio', { name: /^Admin / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': 'silo.viewer' }) + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.viewer' }) // Remove Hannah's direct role; she still inherits via groups so the row stays await table @@ -93,7 +96,7 @@ test('Change and remove a user role from the Users tab', async ({ page }) => { // After removal, Hannah no longer has a direct silo role, so her displayed role // reflects whatever she inherits via her groups (kernel-devs, web-devs have no // silo role assignments by default in mock data). - await expectRowVisible(table, { Name: 'Hannah Arendt', 'Silo Role': '—' }) + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: '—' }) }) test('Assign role to a user with no direct role from the row action', async ({ page }) => { @@ -120,7 +123,7 @@ test('Assign role to a user with no direct role from the row action', async ({ p await expectRowVisible(table, { Name: 'Jacob Klein', - 'Silo Role': 'silo.collaborator', + Role: 'silo.collaborator', }) }) @@ -154,11 +157,11 @@ test('Groups tab shows roles and member counts; modal lists members', async ({ p await expectRowVisible(table, { Name: 'real-estate-devs', - 'Silo Role': 'silo.collaborator', + Role: 'silo.collaborator', Users: '2', }) - await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': '—', Users: '1' }) - await expectRowVisible(table, { Name: 'web-devs', 'Silo Role': '—', Users: '1' }) + await expectRowVisible(table, { Name: 'kernel-devs', Role: '—', Users: '1' }) + await expectRowVisible(table, { Name: 'web-devs', Role: '—', Users: '1' }) // Open the real-estate-devs group modal await page.getByRole('button', { name: 'real-estate-devs' }).click() @@ -185,7 +188,7 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: 'real-estate-devs', - 'Silo Role': 'silo.viewer', + Role: 'silo.viewer', }) // Remove the role @@ -195,7 +198,7 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { .click() await page.getByRole('menuitem', { name: 'Remove role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { Name: 'real-estate-devs', 'Silo Role': '—' }) + await expectRowVisible(table, { Name: 'real-estate-devs', Role: '—' }) }) test('Assign a role to a group with no direct role from the row action', async ({ @@ -216,5 +219,5 @@ test('Assign a role to a group with no direct role from the row action', async ( await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Assign role' }).click() - await expectRowVisible(table, { Name: 'kernel-devs', 'Silo Role': 'silo.viewer' }) + await expectRowVisible(table, { Name: 'kernel-devs', Role: 'silo.viewer' }) }) From 20066c228733dacb6d43a48d9df92cd1cc609199 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 29 Jun 2026 17:44:44 -0700 Subject: [PATCH 25/45] Fetch all-ish and sort client side --- app/components/access/AccessGroupsTab.tsx | 42 +++++++++++++------ app/components/access/AccessUsersTab.tsx | 39 ++++++++++++----- app/pages/SiloAccessPage.tsx | 10 ++--- .../project/access/ProjectAccessPage.tsx | 10 ++--- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 8bedae818f..55929f7ca9 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -5,15 +5,17 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useMemo, useState, type ComponentType } from 'react' +import * as R from 'remeda' import { api, deleteRole, effectiveScopedRole, - getListQFn, + q, rolesByIdFromPolicy, + usePrefetchedQuery, type AccessScope, type Group, type Policy, @@ -31,22 +33,29 @@ import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' -import { useQueryTable } from '~/table/QueryTable' +import { Table } from '~/table/Table' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' import { roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' -const groupList = getListQFn(api.groupList, {}) +// The API only sorts groups by id, so fetch the full set and sort by name +// client-side. ALL_ISH is the practical ceiling; a silo with more groups than +// that would have its tail dropped in (arbitrary) id order. +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) const colHelper = createColumnHelper() const GroupEmptyState = () => ( - } - title="No groups" - body="No groups have been added to this silo" - /> + + } + title="No groups" + body="No groups have been added to this silo" + /> + ) type EditingState = { group: Group; defaultRole: RoleKey | undefined } @@ -71,6 +80,12 @@ export function AccessGroupsTab({ const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) + const { data: groups } = usePrefetchedQuery(groupListAll) + const sortedGroups = useMemo( + () => R.sortBy(groups.items, (g) => g.displayName.toLowerCase()), + [groups] + ) + // non-null: caller is responsible for including the managed scope const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy @@ -195,15 +210,16 @@ export function AccessGroupsTab({ const columns = useColsWithActions(staticColumns, makeActions) - const { table } = useQueryTable({ - query: groupList, + const table = useReactTable({ columns, - emptyState: , + data: sortedGroups, + getRowId: (group) => group.id, + getCoreRowModel: getCoreRowModel(), }) return ( <> - {table} + {sortedGroups.length === 0 ? :
} {editingGroup && ( setEditingGroup(null)} diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index 0260d41df6..51e190fed8 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -5,14 +5,14 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useMemo, useState, type ComponentType } from 'react' +import * as R from 'remeda' import { api, deleteRole, effectiveScopedRole, - getListQFn, q, roleOrder, rolesByIdFromPolicy, @@ -36,15 +36,19 @@ 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 { Table } from '~/table/Table' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { UserDetailsSideModal } from './UserDetailsSideModal' -const userList = getListQFn(api.userList, {}) +// The API only sorts users by id, so fetch the full set and sort by name +// client-side. ALL_ISH is the practical ceiling; a silo with more users than +// that would have its tail dropped in (arbitrary) id order. +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) const colHelper = createColumnHelper() @@ -52,11 +56,13 @@ const colHelper = createColumnHelper() const timeCreatedCol = colHelper.accessor('timeCreated', Columns.timeCreated) const EmptyState = () => ( - } - title="No users" - body="No users have been added to this silo" - /> + + } + title="No users" + body="No users have been added to this silo" + /> + ) type EditingState = { user: User; defaultRole: RoleKey | undefined } @@ -81,6 +87,12 @@ export function AccessUsersTab({ const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) + const { data: users } = usePrefetchedQuery(userListAll) + const sortedUsers = useMemo( + () => R.sortBy(users.items, (u) => u.displayName.toLowerCase()), + [users] + ) + const { data: groups } = usePrefetchedQuery(groupListAll) const groupsByUserId = useGroupsByUserId(groups.items) @@ -260,11 +272,16 @@ export function AccessUsersTab({ const columns = useColsWithActions(staticColumns, makeActions) - const { table } = useQueryTable({ query: userList, columns, emptyState: }) + const table = useReactTable({ + columns, + data: sortedUsers, + getRowId: (user) => user.id, + getCoreRowModel: getCoreRowModel(), + }) return ( <> - {table} + {sortedUsers.length === 0 ? :
} {editingUser && ( setEditingUser(null)} diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 9ae3a3c1ef..2d3f842cb8 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { api, getListQFn, q, queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -19,9 +19,10 @@ import { pb } from '~/util/path-builder' // Parent prefetches everything both tabs need so switching between Users and // Groups doesn't trigger a fetch. This loader runs once on entry to /access; // react-router won't re-run it when navigating between sibling tab routes. +// Both tabs fetch the full user/group lists so they can be sorted by name +// client-side (the API only sorts by id). const policyView = q(api.policyView, {}) -const userList = getListQFn(api.userList, {}) -const groupList = getListQFn(api.groupList, {}) +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { @@ -29,8 +30,7 @@ export async function clientLoader() { const groups = await queryClient.fetchQuery(groupListAll) await Promise.all([ queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(userList.optionsFn()), - queryClient.prefetchQuery(groupList.optionsFn()), + queryClient.prefetchQuery(userListAll), ...groups.items.map((g) => queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) ), diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index bdaeb11cdf..0053c61f96 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -7,7 +7,7 @@ */ import type { LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, q, queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' @@ -21,11 +21,12 @@ import type * as PP from '~/util/path-params' // Parent prefetches everything both tabs need so switching between Users and // Groups doesn't trigger a fetch. +// Both tabs fetch the full user/group lists so they can be sorted by name +// client-side (the API only sorts by id). const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) -const userList = getListQFn(api.userList, {}) -const groupList = getListQFn(api.groupList, {}) +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -35,8 +36,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(projectPolicyView(selector)), - queryClient.prefetchQuery(userList.optionsFn()), - queryClient.prefetchQuery(groupList.optionsFn()), + queryClient.prefetchQuery(userListAll), ...groups.items.map((g) => queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) ), From bfb58d2cf27bd3abaa921bf2242c4b424ea7c3cc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 30 Jun 2026 11:06:20 -0700 Subject: [PATCH 26/45] test revisions --- app/api/roles.spec.ts | 40 ++++++++++++++++++++++++++++++++++ test/e2e/project-access.e2e.ts | 20 +++++------------ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index abd906176b..3bfb304303 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -11,11 +11,13 @@ import { allRoles, byGroupThenName, deleteRole, + effectiveScopedRole, getEffectiveRole, roleOrder, updateRole, userRoleFromPolicies, type Policy, + type ScopedRoleEntry, } from './roles' describe('getEffectiveRole', () => { @@ -119,6 +121,44 @@ describe('getEffectiveRole', () => { }) }) +describe('effectiveScopedRole', () => { + const direct = { type: 'direct' } as const + const viaGroup = { type: 'group', group: { id: 'g', displayName: 'g' } } as const + const entry = ( + roleName: ScopedRoleEntry['roleName'], + scope: ScopedRoleEntry['scope'], + source: ScopedRoleEntry['source'] = direct + ): ScopedRoleEntry => ({ roleName, scope, source }) + + it('returns null when there are no entries', () => { + expect(effectiveScopedRole([])).toBeNull() + }) + + it('picks the strongest role regardless of scope', () => { + expect( + effectiveScopedRole([entry('viewer', 'silo'), entry('admin', 'project')]) + ).toEqual({ role: 'admin', scope: 'project' }) + }) + + it('gives ties to silo scope, since silo roles cascade into projects', () => { + expect( + effectiveScopedRole([entry('collaborator', 'project'), entry('collaborator', 'silo')]) + ).toEqual({ role: 'collaborator', scope: 'silo' }) + }) + + it('gives ties to silo even if permission comes via a group', () => { + expect( + effectiveScopedRole([entry('admin', 'project'), entry('admin', 'silo', viaGroup)]) + ).toEqual({ role: 'admin', scope: 'silo' }) + }) + + it('keeps project scope when the project role is strictly stronger', () => { + expect( + effectiveScopedRole([entry('admin', 'project'), entry('viewer', 'silo')]) + ).toEqual({ role: 'admin', scope: 'project' }) + }) +}) + test('byGroupThenName sorts as expected', () => { const a = { identityType: 'silo_group' as const, name: 'a' } const b = { identityType: 'silo_group' as const, name: 'b' } diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 3ff387b3d3..7dfcd6f4c1 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -152,14 +152,14 @@ test('Assign stronger project role to a silo-only user, then remove it', async ( // Jane Austen is in real-estate-devs (silo.collaborator) and has no direct project role await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) - // Assign project.collaborator — same level as the silo role; silo wins ties, - // so the badge should keep showing silo.collaborator + // Assign project.admin — stronger than her inherited silo.collaborator, so the + // badge updates to project.admin (the role change gives us a sync point) await janeRow.getByRole('button', { name: 'Row actions' }).click() await page.getByRole('menuitem', { name: 'Assign project role' }).click() await expectVisible(page, ['role=heading[name*="Assign role"]']) - await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('radio', { name: /^Admin / }).click() await page.getByRole('button', { name: 'Assign role' }).click() - await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) + await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin' }) // Now that there's a direct project role, the row action menu should expose // "Change project role" instead of "Assign project role" @@ -167,16 +167,8 @@ test('Assign stronger project role to a silo-only user, then remove it', async ( await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeEnabled() await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeHidden() - // Change the project role to admin — stronger than the silo collaborator - await page.getByRole('menuitem', { name: 'Change project role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) - await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() - await page.getByRole('radio', { name: /^Admin / }).click() - await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin' }) - - // Remove the project role; the row falls back to the inherited silo role - await janeRow.getByRole('button', { name: 'Row actions' }).click() + // Remove the project role from the same menu; the row falls back to the + // inherited silo role await page.getByRole('menuitem', { name: 'Remove project role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) From 1ad9c31276407df1fe4ba49d7b5e74c40777cf10 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 2 Jul 2026 17:08:41 -0700 Subject: [PATCH 27/45] Refactor API calls; update tests; remove dead code --- app/api/roles.spec.ts | 45 ------------------- app/api/roles.ts | 12 ----- app/pages/SiloAccessPage.tsx | 10 +++-- .../project/access/ProjectAccessPage.tsx | 10 +++-- test/e2e/project-access.e2e.ts | 14 +++--- test/e2e/silo-access.e2e.ts | 14 +++--- 6 files changed, 28 insertions(+), 77 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index 3bfb304303..2a497f3b5a 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -15,7 +15,6 @@ import { getEffectiveRole, roleOrder, updateRole, - userRoleFromPolicies, type Policy, type ScopedRoleEntry, } from './roles' @@ -77,50 +76,6 @@ describe('deleteRole', () => { }) }) -const user1 = { - id: 'user1', -} - -const groups = [{ id: 'group1' }, { id: 'group2' }] - -describe('getEffectiveRole', () => { - it('returns null when there are no roles', () => { - expect(userRoleFromPolicies(user1, groups, { roleAssignments: [] })).toBe(null) - }) - - it('returns role if user matches directly', () => { - expect( - userRoleFromPolicies(user1, groups, { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'admin' }, - ], - }) - ).toEqual('admin') - }) - - it('returns strongest role if both group and user match', () => { - expect( - userRoleFromPolicies(user1, groups, { - roleAssignments: [ - { identityId: 'user1', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group1', identityType: 'silo_group', roleName: 'collaborator' }, - ], - }) - ).toEqual('collaborator') - }) - - it('ignores groups and users that do not match', () => { - expect( - userRoleFromPolicies(user1, groups, { - roleAssignments: [ - { identityId: 'other', identityType: 'silo_user', roleName: 'viewer' }, - { identityId: 'group3', identityType: 'silo_group', roleName: 'viewer' }, - ], - }) - ).toEqual(null) - }) -}) - describe('effectiveScopedRole', () => { const direct = { type: 'direct' } as const const viaGroup = { type: 'group', group: { id: 'g', displayName: 'g' } } as const diff --git a/app/api/roles.ts b/app/api/roles.ts index 5ba0787a37..9f3de36061 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -151,18 +151,6 @@ export function useActorsNotInPolicy( }, [users, groups, policy]) } -export function userRoleFromPolicies( - user: { id: string }, - groups: { id: string }[], - policy: Policy -): Role | null { - const myIds = new Set([user.id, ...groups.map((g) => g.id)]) - const myRoles = policy.roleAssignments - .filter((ra) => myIds.has(ra.identityId)) - .map((ra) => ra.roleName) - return getEffectiveRole(myRoles) || null -} - export type AccessScope = 'silo' | 'project' export type ScopedPolicy = { scope: AccessScope; policy: Policy } diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 2d3f842cb8..b2372eff67 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -28,12 +28,16 @@ const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { // groups must resolve before fanning out per-group member fetches const groups = await queryClient.fetchQuery(groupListAll) + // Fire per-group member prefetches but don't await them: the tabs read these + // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the + // per-user group lists fill in as they resolve instead of blocking the page + // on one request per group. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(userListAll), - ...groups.items.map((g) => - queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) - ), ]) return null } diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 0053c61f96..197e495479 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -33,13 +33,17 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) // groups must resolve before fanning out per-group member fetches const groups = await queryClient.fetchQuery(groupListAll) + // Fire per-group member prefetches but don't await them: the tabs read these + // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the + // per-user group lists fill in as they resolve instead of blocking the page + // on one request per group. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(projectPolicyView(selector)), queryClient.prefetchQuery(userListAll), - ...groups.items.map((g) => - queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) - ), ]) return null } diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 7dfcd6f4c1..38a6581ecb 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -7,14 +7,14 @@ */ import { user3 } from '@oxide/api-mocks' -import { expect, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, test } from './utils' test('Project access lands on Groups tab; Users tab shows effective project roles', async ({ page, }) => { await page.goto('/projects/mock-project') await page.click('role=link[name*="Access"]') - await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() await expect(page).toHaveURL(/\/access\/groups$/) // Groups tab: kernel-devs has direct project.viewer; real-estate-devs has @@ -52,7 +52,7 @@ test('Change and remove a user project role from the Users tab', async ({ page } .getByRole('button', { name: 'Row actions' }) .click() await page.getByRole('menuitem', { name: 'Change project role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() @@ -88,7 +88,7 @@ test('Inherited silo role on Users tab shows Assign + disabled Remove', async ({ // Assign opens the modal with no role pre-selected await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() }) test('Assign a project role to an unassigned user', async ({ page }) => { @@ -104,7 +104,7 @@ test('Assign a project role to an unassigned user', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() await expect(page.getByRole('dialog')).toContainText('Simone de Beauvoir') await page.getByRole('radio', { name: /^Viewer / }).click() @@ -126,7 +126,7 @@ test('Change and remove a group project role from the Groups tab', async ({ page .getByRole('button', { name: 'Row actions' }) .click() await page.getByRole('menuitem', { name: 'Change project role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Viewer / })).toBeChecked() await page.getByRole('radio', { name: /^Collaborator / }).click() await page.getByRole('button', { name: 'Update role' }).click() @@ -156,7 +156,7 @@ test('Assign stronger project role to a silo-only user, then remove it', async ( // badge updates to project.admin (the role change gives us a sync point) await janeRow.getByRole('button', { name: 'Row actions' }).click() await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() await page.getByRole('radio', { name: /^Admin / }).click() await page.getByRole('button', { name: 'Assign role' }).click() await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin' }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 9c2466cfdf..e3990efe2e 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { expect, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectRowVisible, test } from './utils' test('Access page lands on Groups tab; Users tab shows direct + via-group silo roles', async ({ page, @@ -13,7 +13,7 @@ test('Access page lands on Groups tab; Users tab shows direct + via-group silo r await page.goto('/') await page.click('role=link[name*="Access"]') - await expectVisible(page, ['role=heading[name*="Access"]']) + await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() await expect(page).toHaveURL(/\/access\/groups$/) await page.getByRole('tab', { name: 'Users' }).click() @@ -80,7 +80,7 @@ test('Change and remove a user role from the Users tab', async ({ page }) => { .getByRole('button', { name: 'Row actions' }) .click() await page.getByRole('menuitem', { name: 'Change role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Admin / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() @@ -114,7 +114,7 @@ test('Assign role to a user with no direct role from the row action', async ({ p await page.getByRole('menuitem', { name: 'Assign role' }).click() // Modal opens with the user already targeted (no listbox), and no role pre-selected - await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() await expect(page.getByRole('button', { name: 'User or group' })).toBeHidden() await expect(page.getByRole('dialog')).toContainText('Jacob Klein') @@ -143,7 +143,7 @@ test('Inherited-only role shows Change/Remove with Remove disabled', async ({ pa // Change role opens the edit modal with the inherited role pre-selected await page.getByRole('menuitem', { name: 'Change role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() }) @@ -182,7 +182,7 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { .getByRole('button', { name: 'Row actions' }) .click() await page.getByRole('menuitem', { name: 'Change role' }).click() - await expectVisible(page, ['role=heading[name*="Edit role"]']) + await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() @@ -213,7 +213,7 @@ test('Assign a role to a group with no direct role from the row action', async ( .getByRole('button', { name: 'Row actions' }) .click() await page.getByRole('menuitem', { name: 'Assign role' }).click() - await expectVisible(page, ['role=heading[name*="Assign role"]']) + await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() await expect(page.getByRole('dialog')).toContainText('kernel-devs') await page.getByRole('radio', { name: /^Viewer / }).click() From f46e988a70301a516387c8eaaf3b09e70f832cc7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 2 Jul 2026 17:22:40 -0700 Subject: [PATCH 28/45] More refactoring and shared code extracting --- app/components/access/AccessGroupsTab.tsx | 88 +++------------ app/components/access/AccessUsersTab.tsx | 77 +++----------- app/components/access/DetailTables.tsx | 100 ++++++++++++++++++ .../access/GroupMembersSideModal.tsx | 78 +++----------- .../access/UserDetailsSideModal.tsx | 84 ++------------- app/components/access/roleActions.tsx | 92 ++++++++++++++++ 6 files changed, 243 insertions(+), 276 deletions(-) create mode 100644 app/components/access/DetailTables.tsx create mode 100644 app/components/access/roleActions.tsx diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 55929f7ca9..412a3867ed 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -16,6 +16,7 @@ import { q, rolesByIdFromPolicy, usePrefetchedQuery, + userScopedRoleEntries, type AccessScope, type Group, type Policy, @@ -25,9 +26,7 @@ import { import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { HL } from '~/components/HL' import { type EditRoleModalProps } from '~/forms/access-util' -import { confirmDelete } from '~/stores/confirm-delete' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' @@ -40,6 +39,7 @@ import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' +import { buildRoleActions } from './roleActions' // The API only sorts groups by id, so fetch the full set and sort by name // client-side. ALL_ISH is the practical ceiling; a silo with more groups than @@ -97,14 +97,8 @@ export function AccessGroupsTab({ id: 'role', header: 'Role', cell: ({ row }) => { - // groups never inherit roles, so each scoped policy contributes at - // most one direct entry - const entries = scopedPolicies.flatMap(({ scope, policy }) => { - const ra = policy.roleAssignments.find((r) => r.identityId === row.original.id) - return ra - ? [{ scope, roleName: ra.roleName, source: { type: 'direct' as const } }] - : [] - }) + // groups never inherit, so passing no groups yields direct roles only + const entries = userScopedRoleEntries(row.original.id, [], scopedPolicies) const effective = effectiveScopedRole(entries) if (!effective) return return ( @@ -138,74 +132,22 @@ export function AccessGroupsTab({ [roleCol] ) - const isProject = managedScope === 'project' - const assignLabel = isProject ? 'Assign project role' : 'Assign role' - const changeLabel = isProject ? 'Change project role' : 'Change role' - const removeLabel = isProject ? 'Remove project role' : 'Remove role' - const makeActions = useCallback( (group: Group): MenuAction[] => { const directManagedRole = managedRoleById.get(group.id) - const entries = scopedPolicies.flatMap(({ scope, policy }) => { - const ra = policy.roleAssignments.find((r) => r.identityId === group.id) - return ra - ? [{ scope, roleName: ra.roleName, source: { type: 'direct' as const } }] - : [] - }) + const entries = userScopedRoleEntries(group.id, [], scopedPolicies) const effective = effectiveScopedRole(entries) - const removeAction = { - label: directManagedRole ? removeLabel : 'Remove role', - onActivate: confirmDelete({ - doDelete: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), - label: ( - - the {directManagedRole} role for {group.displayName} - - ), - resourceKind: 'role assignment', - }), - disabled: - !directManagedRole && - `Role is inherited from another scope; modify it there to revoke`, - } - if (!effective) { - return [ - { - label: assignLabel, - onActivate: () => setEditingGroup({ group, defaultRole: undefined }), - }, - ] - } - // On the project tab, a group's silo role isn't a project assignment to - // change — frame it as assigning a project role. - if (isProject && !directManagedRole) { - return [ - { - label: assignLabel, - onActivate: () => setEditingGroup({ group, defaultRole: undefined }), - }, - removeAction, - ] - } - const defaultRole = directManagedRole ?? effective.role - return [ - { - label: changeLabel, - onActivate: () => setEditingGroup({ group, defaultRole }), - }, - removeAction, - ] + return buildRoleActions({ + name: group.displayName, + managedScope, + directManagedRole, + effective, + inheritedReason: 'Role is inherited from another scope; modify it there to revoke', + openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), + doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), + }) }, - [ - managedRoleById, - managedPolicy, - updateManagedPolicy, - scopedPolicies, - isProject, - assignLabel, - changeLabel, - removeLabel, - ] + [managedRoleById, managedPolicy, updateManagedPolicy, scopedPolicies, managedScope] ) const columns = useColsWithActions(staticColumns, makeActions) diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index 51e190fed8..3883e0ecb3 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -28,10 +28,8 @@ import { 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 { type EditRoleModalProps } from '~/forms/access-util' -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' @@ -43,6 +41,7 @@ import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' +import { buildRoleActions } from './roleActions' import { UserDetailsSideModal } from './UserDetailsSideModal' // The API only sorts users by id, so fetch the full set and sort by name @@ -193,69 +192,24 @@ export function AccessUsersTab({ [roleCol, groupsCol] ) - const isProject = managedScope === 'project' - const assignLabel = isProject ? 'Assign project role' : 'Assign role' - const changeLabel = isProject ? 'Change project role' : 'Change role' - const removeLabel = isProject ? 'Remove project role' : 'Remove role' - const makeActions = useCallback( (user: User): MenuAction[] => { const directManagedRole = managedRoleById.get(user.id) const userGroups = groupsByUserId.get(user.id) ?? [] const entries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) const effective = effectiveScopedRole(entries) - const removeAction = { - label: directManagedRole ? removeLabel : 'Remove role', - onActivate: confirmDelete({ - doDelete: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), - label: ( - - the {directManagedRole} role for {user.displayName} - - ), - resourceKind: 'role assignment', - }), - // a direct role on the managed policy is required to remove anything - disabled: - !directManagedRole && - `Role is inherited; modify the source ${ - entries.find((e) => e.source.type === 'group') ? 'group' : 'silo assignment' - } to revoke`, - } - // No role at all — direct or inherited. - if (!effective) { - return [ - { - label: assignLabel, - onActivate: () => setEditingUser({ user, defaultRole: undefined }), - }, - ] - } - // For the project tab, an inherited silo role doesn't give us anything to - // "change" on the project policy — frame it as assigning a project role. - // For the silo tab, an inherited (via group) role can be promoted to a - // direct silo assignment via "Change role" pre-filled with the effective - // role. - if (isProject && !directManagedRole) { - return [ - { - label: assignLabel, - onActivate: () => setEditingUser({ user, defaultRole: undefined }), - }, - removeAction, - ] - } - // Pre-fill with the direct managed role if any; otherwise the effective - // role so the modal opens in 'edit' mode showing the role currently in - // effect. - const defaultRole = directManagedRole ?? effective.role - return [ - { - label: changeLabel, - onActivate: () => setEditingUser({ user, defaultRole }), - }, - removeAction, - ] + const inheritedReason = `Role is inherited; modify the source ${ + entries.find((e) => e.source.type === 'group') ? 'group' : 'silo assignment' + } to revoke` + return buildRoleActions({ + name: user.displayName, + managedScope, + directManagedRole, + effective, + inheritedReason, + openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), + doRemove: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), + }) }, [ managedRoleById, @@ -263,10 +217,7 @@ export function AccessUsersTab({ updateManagedPolicy, groupsByUserId, scopedPolicies, - isProject, - assignLabel, - changeLabel, - removeLabel, + managedScope, ] ) diff --git a/app/components/access/DetailTables.tsx b/app/components/access/DetailTables.tsx new file mode 100644 index 0000000000..ee3d42ea85 --- /dev/null +++ b/app/components/access/DetailTables.tsx @@ -0,0 +1,100 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import * as R from 'remeda' + +import { roleOrder, type ScopedRoleEntry } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { RowActions } from '~/table/columns/action-col' +import { Table } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' + +/** + * Role/Source table shared by the user and group detail side modals. Entries + * come from `userScopedRoleEntries` (groups pass an empty groups list, so their + * entries are all direct). Sorted strongest-first. + */ +export function RoleAssignmentsTable({ entries }: { entries: ScopedRoleEntry[] }) { + const sorted = R.sortBy(entries, (e) => roleOrder[e.roleName]) + return ( +
+ + + Role + Source + + + + {sorted.length === 0 ? ( + + + No roles assigned + + + ) : ( + sorted.map(({ roleName, scope, source }, i) => ( + + + + {scope}.{roleName} + + + + {source.type === 'direct' && 'Assigned'} + {source.type === 'group' && `via ${source.group.displayName}`} + + + )) + )} + +
+ ) +} + +/** + * List of related identities (a group's members, or a user's groups) with a + * Copy ID row action. Shared by the user and group detail side modals. + */ +export function IdentityListTable({ + label, + items, + emptyText, +}: { + label: string + items: { id: string; displayName: string }[] + emptyText: string +}) { + return ( + + + + {label} + + + + + {items.length === 0 ? ( + + + {emptyText} + + + ) : ( + items.map((item) => ( + + {item.displayName} + + + + + )) + )} + +
+ ) +} diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx index 4879551220..7db1c2d174 100644 --- a/app/components/access/GroupMembersSideModal.tsx +++ b/app/components/access/GroupMembersSideModal.tsx @@ -7,19 +7,16 @@ */ import { useQuery } from '@tanstack/react-query' -import { api, q, type Group, type ScopedPolicy, type User } from '@oxide/api' -import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' +import { api, q, userScopedRoleEntries, type Group, type ScopedPolicy } from '@oxide/api' +import { PersonGroup16Icon } from '@oxide/design-system/icons/react' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' -import { 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' +import { IdentityListTable, RoleAssignmentsTable } from './DetailTables' + type Props = { group: Group onDismiss: () => void @@ -30,11 +27,8 @@ export function GroupMembersSideModal({ group, onDismiss, scopedPolicies }: Prop const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) const members = data?.items ?? [] - // role assignments for this group across all relevant policies - const assignments = scopedPolicies.flatMap(({ scope, policy }) => { - const ra = policy.roleAssignments.find((ra) => ra.identityId === group.id) - return ra ? [{ scope, roleName: ra.roleName }] : [] - }) + // groups never inherit, so passing no groups yields the group's direct roles + const roleEntries = userScopedRoleEntries(group.id, [], scopedPolicies) return (
- - - - Role - Source - - - - {assignments.length === 0 ? ( - - - No roles assigned - - - ) : ( - assignments.map(({ scope, roleName }) => ( - - - - {scope}.{roleName} - - - Assigned - - )) - )} - -
+
- {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 index f70bb4512f..72a5738b59 100644 --- a/app/components/access/UserDetailsSideModal.tsx +++ b/app/components/access/UserDetailsSideModal.tsx @@ -5,24 +5,14 @@ * * Copyright Oxide Computer Company */ -import * as R from 'remeda' - -import { - roleOrder, - userScopedRoleEntries, - type Group, - type ScopedPolicy, - type User, -} from '@oxide/api' +import { userScopedRoleEntries, type Group, type ScopedPolicy, 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' + +import { IdentityListTable, RoleAssignmentsTable } from './DetailTables' type Props = { user: User @@ -37,10 +27,7 @@ export function UserDetailsSideModal({ userGroups, scopedPolicies, }: Props) { - const roleEntries = R.sortBy( - userScopedRoleEntries(user.id, userGroups, scopedPolicies), - (e) => roleOrder[e.roleName] - ) + const roleEntries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) return (
- - - - Role - Source - - - - {roleEntries.length === 0 ? ( - - - No roles assigned - - - ) : ( - roleEntries.map(({ roleName, scope, 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/components/access/roleActions.tsx b/app/components/access/roleActions.tsx new file mode 100644 index 0000000000..d587f5a61f --- /dev/null +++ b/app/components/access/roleActions.tsx @@ -0,0 +1,92 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { type AccessScope, type RoleKey } from '@oxide/api' + +import { HL } from '~/components/HL' +import { confirmDelete } from '~/stores/confirm-delete' +import { type MenuAction } from '~/table/columns/action-col' + +/** Verb labels for the row actions, scoped so the project tab reads "project role". */ +function roleActionLabels(managedScope: AccessScope) { + const isProject = managedScope === 'project' + return { + assign: isProject ? 'Assign project role' : 'Assign role', + change: isProject ? 'Change project role' : 'Change role', + remove: isProject ? 'Remove project role' : 'Remove role', + } +} + +type BuildRoleActionsArgs = { + /** Display name of the user or group, used in the confirm-delete copy. */ + name: string + /** Scope managed by this tab; determines labels and project-specific framing. */ + managedScope: AccessScope + /** Direct role on the managed policy, if any. Required to remove a role. */ + directManagedRole: RoleKey | undefined + /** Effective role across all scopes, or null if the identity has no role. */ + effective: { role: RoleKey } | null + /** Disabled reason shown when there's no direct managed role to remove. */ + inheritedReason: string + /** Open the edit modal, pre-filled with the given role (undefined = assign). */ + openEditModal: (defaultRole: RoleKey | undefined) => void + /** Remove the direct managed role. */ + doRemove: () => Promise +} + +/** + * Row-action menu for a user or group in the access tabs. Identical logic for + * both; callers supply how the effective role was computed and the + * inherited-role message (which differs because a user can inherit via a group + * or the silo, while a group only inherits from another scope). + */ +export function buildRoleActions({ + name, + managedScope, + directManagedRole, + effective, + inheritedReason, + openEditModal, + doRemove, +}: BuildRoleActionsArgs): MenuAction[] { + const labels = roleActionLabels(managedScope) + const removeAction: MenuAction = { + label: directManagedRole ? labels.remove : 'Remove role', + onActivate: confirmDelete({ + doDelete: doRemove, + label: ( + + the {directManagedRole} role for {name} + + ), + resourceKind: 'role assignment', + }), + // a direct role on the managed policy is required to remove anything + disabled: !directManagedRole && inheritedReason, + } + // No role at all — direct or inherited. + if (!effective) { + return [{ label: labels.assign, onActivate: () => openEditModal(undefined) }] + } + // For the project tab, an inherited silo role doesn't give us anything to + // "change" on the project policy — frame it as assigning a project role. For + // the silo tab, an inherited (via group) role can be promoted to a direct + // silo assignment via "Change role" pre-filled with the effective role. + if (managedScope === 'project' && !directManagedRole) { + return [ + { label: labels.assign, onActivate: () => openEditModal(undefined) }, + removeAction, + ] + } + // Pre-fill with the direct managed role if any; otherwise the effective role + // so the modal opens in 'edit' mode showing the role currently in effect. + const defaultRole = directManagedRole ?? effective.role + return [ + { label: labels.change, onActivate: () => openEditModal(defaultRole) }, + removeAction, + ] +} From 2fefc696f2a5fa1003f115b5651b0590f8583de0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 8 Jul 2026 20:53:39 -0700 Subject: [PATCH 29/45] Users and Groups have own page on silo view --- app/components/Sidebar.tsx | 7 +- app/forms/project-access.tsx | 70 ++++- app/forms/silo-access.tsx | 64 +++- app/layouts/SiloLayout.tsx | 12 +- app/pages/SiloAccessPage.tsx | 196 ++++++++++-- ...oAccessGroupsTab.tsx => SiloGroupsTab.tsx} | 2 +- app/pages/SiloUsersGroupsPage.tsx | 65 ++++ ...iloAccessUsersTab.tsx => SiloUsersTab.tsx} | 2 +- .../project/access/ProjectAccessGroupsTab.tsx | 52 ---- .../project/access/ProjectAccessPage.tsx | 288 +++++++++++++++++- .../project/access/ProjectAccessUsersTab.tsx | 52 ---- app/routes.tsx | 31 +- .../__snapshots__/path-builder.spec.ts.snap | 58 +--- app/util/path-builder.spec.ts | 10 +- app/util/path-builder.ts | 12 +- test/e2e/project-access.e2e.ts | 244 ++++++--------- test/e2e/silo-access.e2e.ts | 82 ++++- 17 files changed, 844 insertions(+), 403 deletions(-) rename app/pages/{SiloAccessGroupsTab.tsx => SiloGroupsTab.tsx} (96%) create mode 100644 app/pages/SiloUsersGroupsPage.tsx rename app/pages/{SiloAccessUsersTab.tsx => SiloUsersTab.tsx} (96%) delete mode 100644 app/pages/project/access/ProjectAccessGroupsTab.tsx delete mode 100644 app/pages/project/access/ProjectAccessUsersTab.tsx diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 16d1b92c50..bb23274cac 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -102,6 +102,9 @@ type NavLinkProps = { disabled?: boolean // Only for cases where we want to spoof the path and pretend 'isActive' activePrefix?: string + // Override the computed active state. Needed when one nav item represents a + // section spanning multiple sibling paths (e.g. Users & Groups → /users, /groups) + isActive?: boolean } export const NavLinkItem = ({ @@ -110,12 +113,14 @@ export const NavLinkItem = ({ end, disabled, activePrefix, + isActive: isActiveOverride, }: NavLinkProps) => { // If the current page is the create form for this NavLinkItem's resource, highlight the NavLink in the sidebar const currentPathIsCreateForm = useLocation().pathname.startsWith(`${to}-new`) // We aren't using NavLink, as we need to occasionally use an activePrefix to create an active state for matching root paths // so we also recreate the isActive logic here - const isActive = useIsActivePath({ to: activePrefix || to, end }) + const computedActive = useIsActivePath({ to: activePrefix || to, end }) + const isActive = isActiveOverride ?? computedActive return (
  • { + queryClient.invalidateEndpoint('projectPolicyView') + // We don't have the name of the user or group, so use a generic message + addToast({ content: 'Role assigned' }) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + // actor is guaranteed to be in the list because it came from there + const identityType = actors.find((a) => a.id === identityId)!.identityType + + updatePolicy.mutate({ + path: { project }, + body: updateRole({ identityId, identityType, roleName }, policy), + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + onDismiss={() => { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + > + + + + + ) +} export function ProjectAccessEditUserSideModal({ onDismiss, diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 448e8128df..e0c786534b 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -7,15 +7,75 @@ */ import { useForm } from 'react-hook-form' -import { api, queryClient, updateRole, useApiMutation } from '@oxide/api' +import { + api, + queryClient, + updateRole, + useActorsNotInPolicy, + useApiMutation, +} from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' +import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { ResourceLabel } from '~/ui/lib/SideModal' import { docLinks } from '~/util/links' -import { RoleRadioField, type EditRoleModalProps } from './access-util' +import { + actorToItem, + defaultValues, + RoleRadioField, + type AddRoleModalProps, + type EditRoleModalProps, +} from './access-util' + +export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { + const actors = useActorsNotInPolicy(policy) + + const updatePolicy = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + updatePolicy.reset() // clear API error state so it doesn't persist on next open + onDismiss() + }} + onSubmit={({ identityId, roleName }) => { + // actor is guaranteed to be in the list because it came from there + const identityType = actors.find((a) => a.id === identityId)!.identityType + + updatePolicy.mutate({ + body: updateRole({ identityId, identityType, roleName }, policy), + }) + }} + loading={updatePolicy.isPending} + submitError={updatePolicy.error} + > + + + + + ) +} export function SiloAccessEditUserSideModal({ onDismiss, diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 13d1e49dce..faacb44198 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -12,6 +12,7 @@ import { Folder16Icon, Images16Icon, Metrics16Icon, + PersonGroup16Icon, } from '@oxide/design-system/icons/react' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' @@ -34,6 +35,7 @@ export default function SiloLayout() { { value: 'Images', path: pb.siloImages() }, { value: 'Utilization', path: pb.siloUtilization() }, { value: 'Silo Access', path: pb.siloAccess() }, + { value: 'Users & Groups', path: pb.siloUsers() }, ] // filter out the entry for the path we're currently on .filter((i) => i.path !== pathname) @@ -45,6 +47,11 @@ export default function SiloLayout() { [pathname, me.siloName] ) + // Users & Groups spans two sibling routes, so highlight the nav item on both + const inUsersGroups = [pb.siloUsers(), pb.siloGroups()].some( + (p) => pathname === p || pathname.startsWith(`${p}/`) + ) + return ( @@ -63,9 +70,12 @@ export default function SiloLayout() { Utilization - + Silo Access + + Users & Groups + diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index b2372eff67..c47743daca 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,46 +5,175 @@ * * Copyright Oxide Computer Company */ -import { api, q, queryClient } from '@oxide/api' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IdentityType, + type SiloRole, +} 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 { RouteTabs, Tab } from '~/components/RouteTabs' +import { HL } from '~/components/HL' +import { + SiloAccessAddUserSideModal, + SiloAccessEditUserSideModal, +} from '~/forms/silo-access' import { makeCrumb } from '~/hooks/use-crumbs' +import { useCurrentUser } from '~/hooks/use-current-user' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { identityTypeLabel, roleColor } from '~/util/access' +import { groupBy } from '~/util/array' import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -// Parent prefetches everything both tabs need so switching between Users and -// Groups doesn't trigger a fetch. This loader runs once on entry to /access; -// react-router won't re-run it when navigating between sibling tab routes. -// Both tabs fetch the full user/group lists so they can be sorted by name -// client-side (the API only sorts by id). +const 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 userListAll = q(api.userList, { query: { limit: ALL_ISH } }) -const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +// full lists to resolve names; the API only sorts by id +const userList = q(api.userList, { query: { limit: ALL_ISH } }) +const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { - // groups must resolve before fanning out per-group member fetches - const groups = await queryClient.fetchQuery(groupListAll) - // Fire per-group member prefetches but don't await them: the tabs read these - // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the - // per-user group lists fill in as they resolve instead of blocking the page - // on one request per group. - groups.items.forEach((g) => - queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) - ) await Promise.all([ queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(userListAll), + queryClient.prefetchQuery(userList), + queryClient.prefetchQuery(groupList), ]) return null } export const handle = makeCrumb('Silo Access', pb.siloAccess()) +type UserRow = { + id: string + identityType: IdentityType + name: string + siloRole: SiloRole +} + +const colHelper = createColumnHelper() + export default function SiloAccessPage() { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingUserRow, setEditingUserRow] = useState(null) + + const { me } = useCurrentUser() + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: users } = usePrefetchedQuery(userList) + const { data: groups } = usePrefetchedQuery(groupList) + + const rows = useMemo(() => { + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + return groupBy(siloPolicy.roleAssignments, (ra) => ra.identityId) + .map(([userId, assignments]) => { + const { identityType } = assignments[0] + // getEffectiveRole because the API allows multiple assignments per + // identity; non-null because groupBy only creates groups for existing items + const siloRole = getEffectiveRole(assignments.map((a) => a.roleName))! + return { + id: userId, + identityType, + name: nameById.get(userId) ?? userId, + siloRole, + } satisfies UserRow + }) + .sort(byGroupThenName) + }, [siloPolicy, users, groups]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Access removed' }) + }, + }) + + 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 silo.{role} + }, + }), + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), + label: ( + + the {row.siloRole} role for {row.name} + + ), + resourceKind: 'role assignment', + extraContent: + row.id === me.id ? 'This will remove your own silo access.' : undefined, + }), + }, + ]), + ], + [siloPolicy, updatePolicy, me] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + useQuickActions( + () => [ + { + value: 'Add user or group', + navGroup: 'Actions', + action: () => setAddModalOpen(true), + }, + ], + [] + ) + return ( <> @@ -56,10 +185,31 @@ export default function SiloAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - Groups - Users - + + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={siloPolicy} + /> + )} + {editingUserRow && ( + setEditingUserRow(null)} + policy={siloPolicy} + name={editingUserRow.name} + identityId={editingUserRow.id} + identityType={editingUserRow.identityType} + defaultValues={{ roleName: editingUserRow.siloRole }} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( + + )} ) } diff --git a/app/pages/SiloAccessGroupsTab.tsx b/app/pages/SiloGroupsTab.tsx similarity index 96% rename from app/pages/SiloAccessGroupsTab.tsx rename to app/pages/SiloGroupsTab.tsx index 867019079b..03b20b8798 100644 --- a/app/pages/SiloAccessGroupsTab.tsx +++ b/app/pages/SiloGroupsTab.tsx @@ -23,7 +23,7 @@ const policyView = q(api.policyView, {}) export const handle = titleCrumb('Groups') -export default function SiloAccessGroupsTab() { +export default function SiloGroupsTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { diff --git a/app/pages/SiloUsersGroupsPage.tsx b/app/pages/SiloUsersGroupsPage.tsx new file mode 100644 index 0000000000..6df5614f73 --- /dev/null +++ b/app/pages/SiloUsersGroupsPage.tsx @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { api, q, queryClient } from '@oxide/api' +import { PersonGroup16Icon, PersonGroup24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { makeCrumb } from '~/hooks/use-crumbs' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +// Parent prefetches everything both tabs need so switching between Users and +// Groups doesn't trigger a fetch. This loader runs once on entry; react-router +// won't re-run it when navigating between sibling tab routes. Both tabs fetch +// the full user/group lists so they can be sorted by name client-side (the API +// only sorts by id). +const policyView = q(api.policyView, {}) +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupListAll) + // Fire per-group member prefetches but don't await them: the tabs read these + // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the + // per-user group lists fill in as they resolve instead of blocking the page + // on one request per group. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) + await Promise.all([ + queryClient.prefetchQuery(policyView), + queryClient.prefetchQuery(userListAll), + ]) + return null +} + +export const handle = makeCrumb('Users & Groups', pb.siloUsers()) + +export default function SiloUsersGroupsPage() { + return ( + <> + + }>Users & Groups + } + summary="Users and groups come from the silo's identity provider. Assign silo and project roles to control what they can view, edit, or administer." + links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} + /> + + + Users + Groups + + + ) +} diff --git a/app/pages/SiloAccessUsersTab.tsx b/app/pages/SiloUsersTab.tsx similarity index 96% rename from app/pages/SiloAccessUsersTab.tsx rename to app/pages/SiloUsersTab.tsx index 713cf3a2e2..2fc1c12db9 100644 --- a/app/pages/SiloAccessUsersTab.tsx +++ b/app/pages/SiloUsersTab.tsx @@ -23,7 +23,7 @@ const policyView = q(api.policyView, {}) export const handle = titleCrumb('Users') -export default function SiloAccessUsersTab() { +export default function SiloUsersTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx deleted file mode 100644 index fcb3236218..0000000000 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type Policy, -} from '@oxide/api' - -import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' -import { ProjectAccessEditUserSideModal } from '~/forms/project-access' -import { titleCrumb } from '~/hooks/use-crumbs' -import { useProjectSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' - -const policyView = q(api.policyView, {}) - -export const handle = titleCrumb('Groups') - -export default function ProjectAccessGroupsTab() { - const { project } = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: projectPolicy } = usePrefetchedQuery( - q(api.projectPolicyView, { path: { project } }) - ) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Role removed' }) - }, - }) - - return ( - updatePolicy({ path: { project }, body })} - /> - ) -} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 197e495479..505aaea713 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,38 +5,82 @@ * * 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, q, queryClient } from '@oxide/api' +import { + api, + byGroupThenName, + deleteRole, + q, + queryClient, + roleOrder, + useApiMutation, + useGroupsByUserId, + usePrefetchedQuery, + type Group, + type IdentityType, + type RoleKey, + type ScopedPolicy, + type User, +} from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' +import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' +import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' import { DocsPopover } from '~/components/DocsPopover' -import { RouteTabs, Tab } from '~/components/RouteTabs' +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { + ProjectAccessAddUserSideModal, + ProjectAccessEditUserSideModal, +} from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { ButtonCell } from '~/table/cells/LinkCell' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { 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 { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -// Parent prefetches everything both tabs need so switching between Users and -// Groups doesn't trigger a fetch. -// Both tabs fetch the full user/group lists so they can be sorted by name -// client-side (the API only sorts by id). const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) +// full lists to resolve names and back the detail side modals; the API only +// sorts by id const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +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) // groups must resolve before fanning out per-group member fetches const groups = await queryClient.fetchQuery(groupListAll) - // Fire per-group member prefetches but don't await them: the tabs read these - // via useQuery/useQueries (not usePrefetchedQuery), so member counts and the - // per-user group lists fill in as they resolve instead of blocking the page - // on one request per group. + // Fire per-group member prefetches but don't await them: they back the user + // details modal's group list and fill in as they resolve. groups.items.forEach((g) => queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) ) @@ -50,8 +94,186 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { 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 [selectedUser, setSelectedUser] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) const projectSelector = useProjectSelector() + + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: users } = usePrefetchedQuery(userListAll) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const groupsByUserId = useGroupsByUserId(groups.items) + + const usersById = useMemo(() => new Map(users.items.map((u) => [u.id, u])), [users]) + const groupsById = useMemo(() => new Map(groups.items.map((g) => [g.id, g])), [groups]) + + const scopedPolicies = useMemo( + () => + [ + { scope: 'silo', policy: siloPolicy }, + { scope: 'project', policy: projectPolicy }, + ] satisfies ScopedPolicy[], + [siloPolicy, projectPolicy] + ) + + const rows = useMemo(() => { + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + // an identity appears here if it has a direct role on the silo or the project + const identities = new Map() + for (const ra of [...siloPolicy.roleAssignments, ...projectPolicy.roleAssignments]) { + identities.set(ra.identityId, ra.identityType) + } + + return [...identities.entries()] + .map(([id, identityType]) => { + const siloRole = siloPolicy.roleAssignments.find( + (ra) => ra.identityId === id + )?.roleName + const projectRole = projectPolicy.roleAssignments.find( + (ra) => ra.identityId === id + )?.roleName + + const roleBadges = R.sortBy( + [ + siloRole ? { roleSource: 'silo', roleName: siloRole } : undefined, + projectRole ? { roleSource: 'project', roleName: projectRole } : undefined, + ].filter((r) => !!r), + (r) => roleOrder[r.roleName] // strongest role first + ) + + return { + id, + identityType, + name: nameById.get(id) ?? id, + projectRole, + roleBadges, + } satisfies UserRow + }) + .sort(byGroupThenName) + }, [siloPolicy, projectPolicy, users, groups]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { + header: 'Name', + cell: (info) => { + const row = info.row.original + const user = row.identityType === 'silo_user' ? usersById.get(row.id) : undefined + const group = + row.identityType === 'silo_group' ? groupsById.get(row.id) : undefined + if (user) { + return ( + setSelectedUser(user)}> + {info.getValue()} + + ) + } + if (group) { + return ( + setSelectedGroup(group)}> + {info.getValue()} + + ) + } + // identity isn't in this silo's user/group list (e.g. cross-silo), so + // there's no detail to show + return info.getValue() + }, + }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + colHelper.accessor('roleBadges', { + header: () => ( + + 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} + + ))} + + ), + }), + + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: + !row.projectRole && "You don't have permission to change this user's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + path: { project: projectSelector.project }, + body: deleteRole(row.id, projectPolicy), + }), + label: ( + + the {row.projectRole} role for {row.name} + + ), + resourceKind: 'role assignment', + }), + disabled: !row.projectRole && "You don't have permission to delete this user", + }, + ]), + ], + [projectPolicy, projectSelector.project, updatePolicy, usersById, groupsById] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + useQuickActions( + () => [ + { + value: 'Add user or group', + navGroup: 'Actions', + action: () => setAddModalOpen(true), + }, + ], + [] + ) + return ( <> @@ -63,10 +285,46 @@ export default function ProjectAccessPage() { links={[docLinks.keyConceptsIam, docLinks.access, docLinks.identityProviders]} /> - - Groups - Users - + + + setAddModalOpen(true)}>Add user or group + + {addModalOpen && ( + setAddModalOpen(false)} + policy={projectPolicy} + /> + )} + {editingUserRow?.projectRole && ( + setEditingUserRow(null)} + policy={projectPolicy} + name={editingUserRow.name} + identityId={editingUserRow.id} + identityType={editingUserRow.identityType} + defaultValues={{ roleName: editingUserRow.projectRole }} + /> + )} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={scopedPolicies} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={scopedPolicies} + /> + )} + {rows.length === 0 ? ( + setAddModalOpen(true)} /> + ) : ( +
    + )} ) } diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx deleted file mode 100644 index 10acd04e27..0000000000 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type Policy, -} from '@oxide/api' - -import { AccessUsersTab } from '~/components/access/AccessUsersTab' -import { ProjectAccessEditUserSideModal } from '~/forms/project-access' -import { titleCrumb } from '~/hooks/use-crumbs' -import { useProjectSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' - -const policyView = q(api.policyView, {}) - -export const handle = titleCrumb('Users') - -export default function ProjectAccessUsersTab() { - const { project } = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: projectPolicy } = usePrefetchedQuery( - q(api.projectPolicyView, { path: { project } }) - ) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Role removed' }) - }, - }) - - return ( - updatePolicy({ path: { project }, body })} - /> - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 4ff6a7afc2..cf1cac106b 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -317,16 +317,11 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)}> - } /> - import('./pages/SiloAccessGroupsTab').then(convert)} - /> - import('./pages/SiloAccessUsersTab').then(convert)} - /> + import('./pages/SiloAccessPage').then(convert)} /> + {/* Users and Groups are sibling routes sharing one "Users & Groups" page */} + import('./pages/SiloUsersGroupsPage').then(convert)}> + import('./pages/SiloUsersTab').then(convert)} /> + import('./pages/SiloGroupsTab').then(convert)} /> @@ -600,21 +595,7 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} - > - } /> - - import('./pages/project/access/ProjectAccessGroupsTab').then(convert) - } - /> - - import('./pages/project/access/ProjectAccessUsersTab').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 56c8c06d7c..3c4a6f79bc 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -493,35 +493,7 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, ], - "projectAccess (/projects/p/access/groups)": [ - { - "label": "Projects", - "path": "/projects", - }, - { - "label": "p", - "path": "/projects/p/instances", - }, - { - "label": "Project Access", - "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)": [ + "projectAccess (/projects/p/access)": [ { "label": "Projects", "path": "/projects", @@ -645,22 +617,10 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/idps", }, ], - "siloAccess (/access/groups)": [ - { - "label": "Silo Access", - "path": "/access/groups", - }, - ], - "siloAccessGroups (/access/groups)": [ + "siloAccess (/access)": [ { "label": "Silo Access", - "path": "/access/groups", - }, - ], - "siloAccessUsers (/access/users)": [ - { - "label": "Silo Access", - "path": "/access/groups", + "path": "/access", }, ], "siloFleetRoles (/system/silos/s/fleet-roles)": [ @@ -677,6 +637,12 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/fleet-roles", }, ], + "siloGroups (/groups)": [ + { + "label": "Users & Groups", + "path": "/users", + }, + ], "siloIdps (/system/silos/s/idps)": [ { "label": "Silos", @@ -773,6 +739,12 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/subnet-pools", }, ], + "siloUsers (/users)": [ + { + "label": "Users & Groups", + "path": "/users", + }, + ], "siloUtilization (/utilization)": [ { "label": "Utilization", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 38915d7562..3f5f8dcc2f 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -78,9 +78,7 @@ test('path builder', () => { "ipPoolsNew": "/system/networking/ip-pools-new", "profile": "/settings/profile", "project": "/projects/p/instances", - "projectAccess": "/projects/p/access/groups", - "projectAccessGroups": "/projects/p/access/groups", - "projectAccessUsers": "/projects/p/access/users", + "projectAccess": "/projects/p/access", "projectEdit": "/projects/p/edit", "projectImage": "/projects/p/images/im", "projectImages": "/projects/p/images", @@ -90,10 +88,9 @@ test('path builder', () => { "samlIdp": "/system/silos/s/idps/saml/pr", "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", - "siloAccess": "/access/groups", - "siloAccessGroups": "/access/groups", - "siloAccessUsers": "/access/users", + "siloAccess": "/access", "siloFleetRoles": "/system/silos/s/fleet-roles", + "siloGroups": "/groups", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", "siloImage": "/images/im", @@ -102,6 +99,7 @@ test('path builder', () => { "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", "siloSubnetPools": "/system/silos/s/subnet-pools", + "siloUsers": "/users", "siloUtilization": "/utilization", "silos": "/system/silos", "silosNew": "/system/silos-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 069741adb0..b9e660719f 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -26,10 +26,7 @@ export const pb = { project: (params: PP.Project) => `${projectBase(params)}/instances`, projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, - projectAccessUsers: (params: PP.Project) => `${projectBase(params)}/access/users`, - projectAccessGroups: (params: PP.Project) => `${projectBase(params)}/access/groups`, - // points to the default tab to avoid bouncing through the parent's redirect - projectAccess: (params: PP.Project) => pb.projectAccessGroups(params), + projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, projectImages: (params: PP.Project) => `${projectBase(params)}/images`, projectImagesNew: (params: PP.Project) => `${projectBase(params)}/images-new`, projectImage: (params: PP.Image) => `${pb.projectImages(params)}/${params.image}`, @@ -113,10 +110,9 @@ export const pb = { `${pb.antiAffinityGroup(params)}/edit`, siloUtilization: () => '/utilization', - siloAccessUsers: () => '/access/users', - siloAccessGroups: () => '/access/groups', - // points to the default tab to avoid bouncing through the parent's redirect - siloAccess: () => pb.siloAccessGroups(), + siloAccess: () => '/access', + siloUsers: () => '/users', + siloGroups: () => '/groups', siloImages: () => '/images', siloImage: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}`, diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 38a6581ecb..606aec36cb 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -5,188 +5,122 @@ * * Copyright Oxide Computer Company */ -import { user3 } from '@oxide/api-mocks' +import { user3, user4 } from '@oxide/api-mocks' import { expect, expectRowVisible, test } from './utils' -test('Project access lands on Groups tab; Users tab shows effective project roles', async ({ - page, -}) => { +test('Project access shows and edits project role assignments', async ({ page }) => { await page.goto('/projects/mock-project') - await page.click('role=link[name*="Access"]') - await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() - await expect(page).toHaveURL(/\/access\/groups$/) + await page.getByRole('link', { name: 'Project Access' }).click() + await expect(page).toHaveURL(/\/projects\/mock-project\/access$/) + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() - // Groups tab: kernel-devs has direct project.viewer; real-estate-devs has - // direct silo.collaborator only; web-devs has nothing - let table = page.getByRole('table') - await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.viewer' }) - await expectRowVisible(table, { Name: 'real-estate-devs', Role: 'silo.collaborator' }) - await expectRowVisible(table, { Name: 'web-devs', Role: '—' }) - - // Switch to Users tab - await page.getByRole('tab', { name: 'Users' }).click() - await expect(page).toHaveURL(/\/access\/users$/) - - table = page.getByRole('table') - // Hannah has direct silo.admin which dominates over kernel-devs project.viewer - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin' }) - // Jacob has direct project.collaborator - await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'project.collaborator' }) - // Herbert has direct project.limited_collaborator + // identities with a direct silo or project role appear; the effective role is + // the strongest across silo and project + const table = page.getByRole('table') + await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', Role: 'silo.admin' }) + await expectRowVisible(table, { + Name: 'Jacob Klein', + Type: 'User', + Role: 'project.collaborator', + }) await expectRowVisible(table, { Name: 'Herbert Marcuse', + Type: 'User', Role: 'project.limited_collaborator', }) - // Hans inherits silo.collaborator via real-estate-devs - await expectRowVisible(table, { Name: 'Hans Jonas', Role: 'silo.collaborator' }) -}) + await expectRowVisible(table, { + Name: 'real-estate-devs', + Type: 'Group', + Role: 'silo.collaborator', + }) + await expectRowVisible(table, { + Name: 'kernel-devs', + Type: 'Group', + Role: 'project.viewer', + }) -test('Change and remove a user project role from the Users tab', async ({ page }) => { - await page.goto('/projects/mock-project/access/users') - const table = page.getByRole('table') + // identities with only an inherited (via-group) role or no role don't appear + await expect(table.getByRole('cell', { name: 'Hans Jonas' })).toBeHidden() + await expect(table.getByRole('cell', { name: 'Simone de Beauvoir' })).toBeHidden() + + // add Simone as collaborator + await page.getByRole('button', { name: 'Add user or group' }).click() + await expect(page.getByRole('heading', { name: 'Add user or group' })).toBeVisible() + await page.getByRole('button', { name: 'User or group' }).click() + // already-assigned identities aren't offered + await expect(page.getByRole('option', { name: 'Jacob Klein' })).toBeHidden() + await page.getByRole('option', { name: 'Simone de Beauvoir' }).click() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + await expectRowVisible(table, { + Name: 'Simone de Beauvoir', + Type: 'User', + Role: 'project.collaborator', + }) - // Jacob Klein has direct project.collaborator — change to viewer - await table - .getByRole('row', { name: user3.display_name, exact: false }) + // change Simone's role from collaborator to viewer + await page + .getByRole('row', { name: user4.display_name, exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change project role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expect(page.getByRole('heading', { name: 'Edit role' })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: user3.display_name, Role: 'project.viewer' }) + await expectRowVisible(table, { Name: user4.display_name, Role: 'project.viewer' }) - // Remove Jacob's direct project role; he has no other access so badge becomes — - await table - .getByRole('row', { name: user3.display_name, exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Remove project role' }).click() + // delete Jacob's project role + const jacobRow = page.getByRole('row', { name: user3.display_name, exact: false }) + await jacobRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { Name: user3.display_name, Role: '—' }) -}) - -test('Inherited silo role on Users tab shows Assign + disabled Remove', async ({ - page, -}) => { - await page.goto('/projects/mock-project/access/users') - const table = page.getByRole('table') - - // Hans Jonas inherits silo.collaborator via real-estate-devs but has no - // direct project role — show "Assign project role", and disabled "Remove role" - await table - .getByRole('row', { name: 'Hans Jonas', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() - await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() - await expect( - page.getByRole('menuitem', { name: 'Remove role', exact: true }) - ).toBeDisabled() - - // Assign opens the modal with no role pre-selected - await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() -}) - -test('Assign a project role to an unassigned user', async ({ page }) => { - await page.goto('/projects/mock-project/access/users') - const table = page.getByRole('table') - - // Simone de Beauvoir has no roles at all - await table - .getByRole('row', { name: 'Simone de Beauvoir', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() - await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() - await page.getByRole('menuitem', { name: 'Assign project role' }).click() - - await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() - await expect(page.getByRole('dialog')).toContainText('Simone de Beauvoir') - + await expect(jacobRow).toBeHidden() + + // add a project role to Hannah, who has only a silo role. Because we show the + // effective (strongest) role first, the badge stays silo.admin with a +1 for + // the added project role + await page.getByRole('button', { name: 'Add user or group' }).click() + await page.getByRole('button', { name: 'User or group' }).click() + await page.getByRole('option', { name: 'Hannah Arendt' }).click() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Assign role' }).click() - await expectRowVisible(table, { - Name: 'Simone de Beauvoir', - Role: 'project.viewer', + Name: 'Hannah Arendt', + Type: 'User', + Role: 'silo.admin+1', }) }) -test('Change and remove a group project role from the Groups tab', async ({ page }) => { - await page.goto('/projects/mock-project/access/groups') - const table = page.getByRole('table') - - // kernel-devs has direct project.viewer — change to collaborator - await table - .getByRole('row', { name: 'kernel-devs', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Change project role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() - await expect(page.getByRole('radio', { name: /^Viewer / })).toBeChecked() - await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: 'kernel-devs', Role: 'project.collaborator' }) - - // Remove the direct project role - await table - .getByRole('row', { name: 'kernel-devs', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Remove project role' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { Name: 'kernel-devs', Role: '—' }) -}) - -test('Assign stronger project role to a silo-only user, then remove it', async ({ - page, -}) => { - await page.goto('/projects/mock-project/access/users') - const table = page.getByRole('table') - const janeRow = table.getByRole('row', { name: 'Jane Austen', exact: false }) - - // Jane Austen is in real-estate-devs (silo.collaborator) and has no direct project role - await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) +test('Project access user details side modal', async ({ page }) => { + await page.goto('/projects/mock-project') + await page.getByRole('link', { name: 'Project Access' }).click() - // Assign project.admin — stronger than her inherited silo.collaborator, so the - // badge updates to project.admin (the role change gives us a sync point) - await janeRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() - await page.getByRole('radio', { name: /^Admin / }).click() - await page.getByRole('button', { name: 'Assign role' }).click() - await expectRowVisible(table, { Name: 'Jane Austen', Role: 'project.admin' }) + // clicking a user opens their details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() - // Now that there's a direct project role, the row action menu should expose - // "Change project role" instead of "Assign project role" - await janeRow.getByRole('button', { name: 'Row actions' }).click() - await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeEnabled() - await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeHidden() + // direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toContainText('Assigned') - // Remove the project role from the same menu; the row falls back to the - // inherited silo role - await page.getByRole('menuitem', { name: 'Remove project role' }).click() - await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { Name: 'Jane Austen', Role: 'silo.collaborator' }) + // group memberships (exact, since a role row also reads "via kernel-devs") + await expect(modal.getByRole('cell', { name: 'kernel-devs', exact: true })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs', exact: true })).toBeVisible() }) -test('Group with only a silo role on Project Groups tab', async ({ page }) => { - await page.goto('/projects/mock-project/access/groups') - const table = page.getByRole('table') - - // real-estate-devs has direct silo.collaborator but no direct project role — - // show "Assign project role", and disabled "Remove role" - await table - .getByRole('row', { name: 'real-estate-devs', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() - await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() - await expect( - page.getByRole('menuitem', { name: 'Remove role', exact: true }) - ).toBeDisabled() +test('Project access group members side modal', async ({ page }) => { + await page.goto('/projects/mock-project') + await page.getByRole('link', { name: 'Project Access' }).click() + + // clicking a group opens its members and roles + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index e3990efe2e..d547951b5d 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -7,17 +7,63 @@ */ import { expect, expectRowVisible, test } from './utils' -test('Access page lands on Groups tab; Users tab shows direct + via-group silo roles', async ({ - page, -}) => { +test('Silo Access page shows and edits silo role assignments', async ({ page }) => { await page.goto('/') - await page.click('role=link[name*="Access"]') + await page.getByRole('link', { name: 'Silo Access' }).click() + await expect(page).toHaveURL(/\/access$/) + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() + + // only identities with a direct silo role appear + const table = page.getByRole('table') + await expectRowVisible(table, { + Name: 'real-estate-devs', + Type: 'Group', + Role: 'silo.collaborator', + }) + await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', Role: 'silo.admin' }) + + // add Jacob Klein as collaborator + await page.getByRole('button', { name: 'Add user or group' }).click() + await expect(page.getByRole('heading', { name: 'Add user or group' })).toBeVisible() + await page.getByRole('button', { name: 'User or group' }).click() + // already-assigned identities aren't offered + await expect(page.getByRole('option', { name: 'Hannah Arendt' })).toBeHidden() + await page.getByRole('option', { name: 'Jacob Klein' }).click() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + await expectRowVisible(table, { + Name: 'Jacob Klein', + Type: 'User', + Role: 'silo.collaborator', + }) + + // change Jacob's role to viewer + await table + .getByRole('row', { name: 'Jacob Klein', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await page.getByRole('menuitem', { name: 'Change role' }).click() + await expect(page.getByRole('heading', { name: 'Edit role' })).toBeVisible() + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Update role' }).click() + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.viewer' }) - await expect(page.getByRole('heading', { name: /Access/ })).toBeVisible() - await expect(page).toHaveURL(/\/access\/groups$/) + // delete Jacob's role + const jacobRow = page.getByRole('row', { name: 'Jacob Klein', exact: false }) + await jacobRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(jacobRow).toBeHidden() +}) - await page.getByRole('tab', { name: 'Users' }).click() - await expect(page).toHaveURL(/\/access\/users$/) +test('Users & Groups page lands on Users tab; shows direct + via-group silo roles', async ({ + page, +}) => { + await page.goto('/') + await page.getByRole('link', { name: 'Users & Groups' }).click() + await expect(page).toHaveURL(/\/users$/) + await expect(page.getByRole('heading', { name: 'Users & Groups' })).toBeVisible() const table = page.getByRole('table') @@ -38,12 +84,16 @@ test('Access page lands on Groups tab; Users tab shows direct + via-group silo r // Jacob Klein has no silo role and no groups await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—', Groups: '—' }) + + // Groups tab comes second + await page.getByRole('tab', { name: 'Groups' }).click() + await expect(page).toHaveURL(/\/groups$/) }) test('User details side modal shows assigned + via-group roles and group list', async ({ page, }) => { - await page.goto('/access/users') + await page.goto('/users') // Open Hannah's details await page.getByRole('button', { name: 'Hannah Arendt' }).click() @@ -71,7 +121,7 @@ test('User details side modal shows assigned + via-group roles and group list', }) test('Change and remove a user role from the Users tab', async ({ page }) => { - await page.goto('/access/users') + await page.goto('/users') const table = page.getByRole('table') // Hannah has a direct silo.admin role; change it to viewer @@ -100,7 +150,7 @@ test('Change and remove a user role from the Users tab', async ({ page }) => { }) test('Assign role to a user with no direct role from the row action', async ({ page }) => { - await page.goto('/access/users') + await page.goto('/users') const table = page.getByRole('table') // Jacob Klein has no direct or inherited role @@ -128,7 +178,7 @@ test('Assign role to a user with no direct role from the row action', async ({ p }) test('Inherited-only role shows Change/Remove with Remove disabled', async ({ page }) => { - await page.goto('/access/users') + await page.goto('/users') const table = page.getByRole('table') // Hans Jonas has no direct silo role but inherits silo.collaborator via @@ -148,10 +198,10 @@ test('Inherited-only role shows Change/Remove with Remove disabled', async ({ pa }) test('Groups tab shows roles and member counts; modal lists members', async ({ page }) => { - await page.goto('/access/users') + await page.goto('/users') await page.getByRole('tab', { name: 'Groups' }).click() - await expect(page).toHaveURL(/\/access\/groups$/) + await expect(page).toHaveURL(/\/groups$/) const table = page.getByRole('table') @@ -173,7 +223,7 @@ test('Groups tab shows roles and member counts; modal lists members', async ({ p }) test('Change and remove a group role from the Groups tab', async ({ page }) => { - await page.goto('/access/groups') + await page.goto('/groups') const table = page.getByRole('table') // real-estate-devs has silo.collaborator; change to viewer @@ -204,7 +254,7 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { test('Assign a role to a group with no direct role from the row action', async ({ page, }) => { - await page.goto('/access/groups') + await page.goto('/groups') const table = page.getByRole('table') // kernel-devs has no direct silo role From 9561020c85694b5e23b4541d2df644e36aa2ca01 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 9 Jul 2026 17:24:47 -0700 Subject: [PATCH 30/45] sort users and groups by display name in dropdown --- app/api/roles.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 9f3de36061..b687524ee0 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -138,16 +138,16 @@ export function useActorsNotInPolicy( return useMemo(() => { // IDs are UUIDs, so no need to include identity type in set value to disambiguate const actorsInPolicy = new Set(policy?.roleAssignments.map((ra) => ra.identityId) || []) - const allGroups = groups.items.map((g) => ({ - ...g, - identityType: 'silo_group' as IdentityType, - })) - const allUsers = users.items.map((u) => ({ - ...u, - identityType: 'silo_user' as IdentityType, - })) - // groups go before users - return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || [] + // groups first, then users; each sorted alphabetically by display name + const allGroups = R.sortBy( + groups.items.map((g) => ({ ...g, identityType: 'silo_group' as IdentityType })), + (g) => g.displayName.toLowerCase() + ) + const allUsers = R.sortBy( + users.items.map((u) => ({ ...u, identityType: 'silo_user' as IdentityType })), + (u) => u.displayName.toLowerCase() + ) + return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) }, [users, groups, policy]) } From b9dad64508150309327e4cc8afdc56e11387d0c0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 9 Jul 2026 20:01:42 -0700 Subject: [PATCH 31/45] Add sidebars to silo access page --- app/components/access/AccessRolesTable.tsx | 287 ++++++++++++++++++ app/pages/SiloAccessPage.tsx | 151 ++------- .../project/access/ProjectAccessPage.tsx | 228 +------------- test/e2e/silo-access.e2e.ts | 30 ++ 4 files changed, 345 insertions(+), 351 deletions(-) create mode 100644 app/components/access/AccessRolesTable.tsx diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx new file mode 100644 index 0000000000..e95b52f7c3 --- /dev/null +++ b/app/components/access/AccessRolesTable.tsx @@ -0,0 +1,287 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState, type ComponentType } from 'react' + +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + roleOrder, + useGroupsByUserId, + usePrefetchedQuery, + type AccessScope, + type Group, + type IdentityType, + type Policy, + type RoleKey, + type ScopedPolicy, + type User, +} from '@oxide/api' +import { Access24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { type EditRoleModalProps } from '~/forms/access-util' +import { useCurrentUser } from '~/hooks/use-current-user' +import { confirmDelete } from '~/stores/confirm-delete' +import { ButtonCell } from '~/table/cells/LinkCell' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { identityTypeLabel, roleColor } from '~/util/access' +import { ALL_ISH } from '~/util/consts' + +import { GroupMembersSideModal } from './GroupMembersSideModal' +import { UserDetailsSideModal } from './UserDetailsSideModal' + +// full lists to resolve names and back the detail side modals; the API only +// sorts by id +const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) +const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) + +type AccessRow = { + id: string + identityType: IdentityType + name: string + /** Direct role in the managed scope, if any (drives edit/remove). */ + managedRole: RoleKey | undefined + /** One badge per scope where the identity has a direct role, strongest first. */ + roleBadges: { scope: AccessScope; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +type Props = { + /** Policies that contribute to an identity's effective role on this page. */ + scopedPolicies: ScopedPolicy[] + /** Scope managed by this page — its direct roles are editable/removable. */ + managedScope: AccessScope + /** Modal for editing a role on the managed policy. */ + EditModal: ComponentType + /** Update the managed policy. Called when removing a role. */ + updateManagedPolicy: (newPolicy: Policy) => Promise + /** Open the add-user-or-group modal (from the empty state and header). */ + onAddClick: () => void +} + +/** + * Table of identities with a direct role in one of the given scopes. Clicking a + * name opens a read-only detail side modal; row actions edit or remove the role + * in the managed scope. Shared by the Silo Access and Project Access pages. + */ +export function AccessRolesTable({ + scopedPolicies, + managedScope, + EditModal, + updateManagedPolicy, + onAddClick, +}: Props) { + const [editingRow, setEditingRow] = useState(null) + const [selectedUser, setSelectedUser] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) + + const { me } = useCurrentUser() + const { data: users } = usePrefetchedQuery(userListAll) + const { data: groups } = usePrefetchedQuery(groupListAll) + + const groupsByUserId = useGroupsByUserId(groups.items) + const usersById = useMemo(() => new Map(users.items.map((u) => [u.id, u])), [users]) + const groupsById = useMemo(() => new Map(groups.items.map((g) => [g.id, g])), [groups]) + + // non-null: caller is responsible for including the managed scope + const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + + const rows = useMemo(() => { + const nameById = new Map( + [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) + ) + const roleIn = (policy: Policy, id: string) => + getEffectiveRole( + policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) + ) + + // an identity appears if it has a direct role in any of the scoped policies + const identities = new Map() + for (const { policy } of scopedPolicies) { + for (const ra of policy.roleAssignments) + identities.set(ra.identityId, ra.identityType) + } + + return [...identities.entries()] + .map(([id, identityType]) => { + const roleBadges = scopedPolicies + .map(({ scope, policy }) => { + const roleName = roleIn(policy, id) + return roleName ? { scope, roleName } : undefined + }) + .filter((b) => !!b) + .sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) // strongest first + + return { + id, + identityType, + name: nameById.get(id) ?? id, + managedRole: roleIn(managedPolicy, id), + roleBadges, + } satisfies AccessRow + }) + .sort(byGroupThenName) + }, [scopedPolicies, managedPolicy, users, groups]) + + const multiScope = scopedPolicies.length > 1 + + const columns = useMemo( + () => [ + colHelper.accessor('name', { + header: 'Name', + cell: (info) => { + const row = info.row.original + const user = row.identityType === 'silo_user' ? usersById.get(row.id) : undefined + const group = + row.identityType === 'silo_group' ? groupsById.get(row.id) : undefined + if (user) { + return ( + setSelectedUser(user)}> + {info.getValue()} + + ) + } + if (group) { + return ( + setSelectedGroup(group)}> + {info.getValue()} + + ) + } + // identity isn't in this silo's user/group list (e.g. cross-silo), so + // there's no detail to show + return info.getValue() + }, + }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + colHelper.accessor('roleBadges', { + header: () => + multiScope ? ( + + Role + + A user or group's effective role is the strongest role across the silo + and this project + + + ) : ( + 'Role' + ), + cell: (info) => ( + + {info.getValue().map(({ scope, roleName }) => ( + + {scope}.{roleName} + + ))} + + ), + }), + getActionsCol((row: AccessRow) => [ + { + label: 'Change role', + onActivate: () => setEditingRow(row), + disabled: !row.managedRole && "You don't have permission to change this role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), + label: ( + + the {row.managedRole} role for {row.name} + + ), + resourceKind: 'role assignment', + extraContent: + row.id === me.id + ? `This will remove your own ${managedScope} access.` + : undefined, + }), + disabled: !row.managedRole && "You don't have permission to delete this role", + }, + ]), + ], + [ + managedPolicy, + managedScope, + updateManagedPolicy, + me, + usersById, + groupsById, + multiScope, + ] + ) + + const table = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + Add user or group + + {editingRow?.managedRole && ( + setEditingRow(null)} + policy={managedPolicy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.managedRole }} + /> + )} + {selectedUser && ( + setSelectedUser(null)} + scopedPolicies={scopedPolicies} + userGroups={groupsByUserId.get(selectedUser.id) ?? []} + /> + )} + {selectedGroup && ( + setSelectedGroup(null)} + scopedPolicies={scopedPolicies} + /> + )} + {rows.length === 0 ? ( + + } + title="No authorized users" + body={`Give permission to view, edit, or administer this ${managedScope}`} + buttonText="Add user or group" + onClick={onAddClick} + /> + + ) : ( +
    + )} + + ) +} diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index c47743daca..a2bea0d9c7 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,112 +5,50 @@ * * Copyright Oxide Computer Company */ -import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo, useState } from 'react' +import { useState } from 'react' -import { - api, - byGroupThenName, - deleteRole, - getEffectiveRole, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type IdentityType, - type SiloRole, -} from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' +import { AccessRolesTable } from '~/components/access/AccessRolesTable' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' import { makeCrumb } from '~/hooks/use-crumbs' -import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions } from '~/hooks/use-quick-actions' -import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { identityTypeLabel, roleColor } from '~/util/access' -import { groupBy } from '~/util/array' import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -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, {}) -// full lists to resolve names; the API only sorts by id const userList = q(api.userList, { query: { limit: ALL_ISH } }) const groupList = q(api.groupList, { query: { limit: ALL_ISH } }) export async function clientLoader() { + // groups must resolve before fanning out per-group member fetches + const groups = await queryClient.fetchQuery(groupList) + // Fire per-group member prefetches but don't await them: they back the user + // details modal's group list and fill in as they resolve. + groups.items.forEach((g) => + queryClient.prefetchQuery(q(api.userList, { query: { group: g.id, limit: ALL_ISH } })) + ) await Promise.all([ queryClient.prefetchQuery(policyView), queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), ]) return null } export const handle = makeCrumb('Silo Access', pb.siloAccess()) -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: SiloRole -} - -const colHelper = createColumnHelper() - export default function SiloAccessPage() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - const { me } = useCurrentUser() const { data: siloPolicy } = usePrefetchedQuery(policyView) - const { data: users } = usePrefetchedQuery(userList) - const { data: groups } = usePrefetchedQuery(groupList) - - const rows = useMemo(() => { - const nameById = new Map( - [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) - ) - return groupBy(siloPolicy.roleAssignments, (ra) => ra.identityId) - .map(([userId, assignments]) => { - const { identityType } = assignments[0] - // getEffectiveRole because the API allows multiple assignments per - // identity; non-null because groupBy only creates groups for existing items - const siloRole = getEffectiveRole(assignments.map((a) => a.roleName))! - return { - id: userId, - identityType, - name: nameById.get(userId) ?? userId, - siloRole, - } satisfies UserRow - }) - .sort(byGroupThenName) - }, [siloPolicy, users, groups]) const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { @@ -119,50 +57,6 @@ export default function SiloAccessPage() { }, }) - 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 silo.{role} - }, - }), - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, siloPolicy) }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id ? 'This will remove your own silo access.' : undefined, - }), - }, - ]), - ], - [siloPolicy, updatePolicy, me] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - useQuickActions( () => [ { @@ -186,30 +80,19 @@ export default function SiloAccessPage() { /> - - setAddModalOpen(true)}>Add user or group - {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {editingUserRow && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( -
    - )} + updatePolicy({ body })} + onAddClick={() => setAddModalOpen(true)} + /> ) } diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 505aaea713..c5780828df 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,52 +5,30 @@ * * 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, - useGroupsByUserId, usePrefetchedQuery, - type Group, - type IdentityType, - type RoleKey, + type Policy, type ScopedPolicy, - type User, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' -import { GroupMembersSideModal } from '~/components/access/GroupMembersSideModal' -import { UserDetailsSideModal } from '~/components/access/UserDetailsSideModal' +import { AccessRolesTable } from '~/components/access/AccessRolesTable' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' -import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { ButtonCell } from '~/table/cells/LinkCell' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { 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 { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import type * as PP from '~/util/path-params' @@ -58,23 +36,9 @@ import type * as PP from '~/util/path-params' const policyView = q(api.policyView, {}) const projectPolicyView = ({ project }: PP.Project) => q(api.projectPolicyView, { path: { project } }) -// full lists to resolve names and back the detail side modals; the API only -// sorts by id const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) -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) // groups must resolve before fanning out per-group member fetches @@ -94,32 +58,12 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { 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 [selectedUser, setSelectedUser] = useState(null) - const [selectedGroup, setSelectedGroup] = useState(null) const projectSelector = useProjectSelector() const { data: siloPolicy } = usePrefetchedQuery(policyView) const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const { data: users } = usePrefetchedQuery(userListAll) - const { data: groups } = usePrefetchedQuery(groupListAll) - - const groupsByUserId = useGroupsByUserId(groups.items) - - const usersById = useMemo(() => new Map(users.items.map((u) => [u.id, u])), [users]) - const groupsById = useMemo(() => new Map(groups.items.map((g) => [g.id, g])), [groups]) const scopedPolicies = useMemo( () => @@ -130,44 +74,6 @@ export default function ProjectAccessPage() { [siloPolicy, projectPolicy] ) - const rows = useMemo(() => { - const nameById = new Map( - [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) - ) - // an identity appears here if it has a direct role on the silo or the project - const identities = new Map() - for (const ra of [...siloPolicy.roleAssignments, ...projectPolicy.roleAssignments]) { - identities.set(ra.identityId, ra.identityType) - } - - return [...identities.entries()] - .map(([id, identityType]) => { - const siloRole = siloPolicy.roleAssignments.find( - (ra) => ra.identityId === id - )?.roleName - const projectRole = projectPolicy.roleAssignments.find( - (ra) => ra.identityId === id - )?.roleName - - const roleBadges = R.sortBy( - [ - siloRole ? { roleSource: 'silo', roleName: siloRole } : undefined, - projectRole ? { roleSource: 'project', roleName: projectRole } : undefined, - ].filter((r) => !!r), - (r) => roleOrder[r.roleName] // strongest role first - ) - - return { - id, - identityType, - name: nameById.get(id) ?? id, - projectRole, - roleBadges, - } satisfies UserRow - }) - .sort(byGroupThenName) - }, [siloPolicy, projectPolicy, users, groups]) - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') @@ -175,94 +81,6 @@ export default function ProjectAccessPage() { }, }) - const columns = useMemo( - () => [ - colHelper.accessor('name', { - header: 'Name', - cell: (info) => { - const row = info.row.original - const user = row.identityType === 'silo_user' ? usersById.get(row.id) : undefined - const group = - row.identityType === 'silo_group' ? groupsById.get(row.id) : undefined - if (user) { - return ( - setSelectedUser(user)}> - {info.getValue()} - - ) - } - if (group) { - return ( - setSelectedGroup(group)}> - {info.getValue()} - - ) - } - // identity isn't in this silo's user/group list (e.g. cross-silo), so - // there's no detail to show - return info.getValue() - }, - }), - colHelper.accessor('identityType', { - header: 'Type', - cell: (info) => identityTypeLabel[info.getValue()], - }), - colHelper.accessor('roleBadges', { - header: () => ( - - 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} - - ))} - - ), - }), - - getActionsCol((row: UserRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.projectRole && "You don't have permission to change this user's role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - path: { project: projectSelector.project }, - body: deleteRole(row.id, projectPolicy), - }), - label: ( - - the {row.projectRole} role for {row.name} - - ), - resourceKind: 'role assignment', - }), - disabled: !row.projectRole && "You don't have permission to delete this user", - }, - ]), - ], - [projectPolicy, projectSelector.project, updatePolicy, usersById, groupsById] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - useQuickActions( () => [ { @@ -286,45 +104,21 @@ export default function ProjectAccessPage() { /> - - setAddModalOpen(true)}>Add user or group - {addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {editingUserRow?.projectRole && ( - setEditingUserRow(null)} - policy={projectPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.projectRole }} - /> - )} - {selectedUser && ( - setSelectedUser(null)} - scopedPolicies={scopedPolicies} - userGroups={groupsByUserId.get(selectedUser.id) ?? []} - /> - )} - {selectedGroup && ( - setSelectedGroup(null)} - scopedPolicies={scopedPolicies} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( -
    - )} + + updatePolicy({ path: { project: projectSelector.project }, body }) + } + onAddClick={() => setAddModalOpen(true)} + /> ) } diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index d547951b5d..c3f0812ab2 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -57,6 +57,36 @@ test('Silo Access page shows and edits silo role assignments', async ({ page }) await expect(jacobRow).toBeHidden() }) +test('Silo Access user details side modal', async ({ page }) => { + await page.goto('/access') + + // clicking a user opens their details + await page.getByRole('button', { name: 'Hannah Arendt' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('Hannah Arendt')).toBeVisible() + + // direct silo.admin assignment + const roleRow = modal.getByRole('row').filter({ hasText: 'silo.admin' }) + await expect(roleRow).toContainText('Assigned') + + // group memberships + await expect(modal.getByRole('cell', { name: 'kernel-devs', exact: true })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'web-devs', exact: true })).toBeVisible() +}) + +test('Silo Access group members side modal', async ({ page }) => { + await page.goto('/access') + + // clicking a group opens its members and roles + await page.getByRole('button', { name: 'real-estate-devs' }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + await expect(modal.getByText('silo.collaborator')).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Hans Jonas' })).toBeVisible() + await expect(modal.getByRole('cell', { name: 'Jane Austen' })).toBeVisible() +}) + test('Users & Groups page lands on Users tab; shows direct + via-group silo roles', async ({ page, }) => { From 54ada7508dbd800c49085aa4c6e8548d645c2bdf Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 10 Jul 2026 17:13:50 -0700 Subject: [PATCH 32/45] Fix a few issues with memoization, inherited roles, etc. --- app/api/roles.spec.ts | 41 +++++++++++ app/api/roles.ts | 18 +++-- app/components/access/AccessRolesTable.tsx | 82 ++++++++++++++-------- app/pages/SiloAccessPage.tsx | 18 ++++- app/pages/SiloGroupsTab.tsx | 10 ++- app/pages/SiloUsersTab.tsx | 10 ++- test/e2e/project-access.e2e.ts | 26 +++++++ 7 files changed, 164 insertions(+), 41 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index 2a497f3b5a..fa0efbe681 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -15,6 +15,7 @@ import { getEffectiveRole, roleOrder, updateRole, + userScopedRoleEntries, type Policy, type ScopedRoleEntry, } from './roles' @@ -124,6 +125,46 @@ test('byGroupThenName sorts as expected', () => { expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) }) +describe('userScopedRoleEntries', () => { + it('collapses multiple assignments for the same identity to the strongest role', () => { + // API permits multiple assignments for one identity in a single policy + const policy: Policy = { + roleAssignments: [ + { identityId: 'u', identityType: 'silo_user', roleName: 'viewer' }, + { identityId: 'u', identityType: 'silo_user', roleName: 'admin' }, + ], + } + expect(userScopedRoleEntries('u', [], [{ scope: 'silo', policy }])).toEqual([ + { roleName: 'admin', scope: 'silo', source: { type: 'direct' } }, + ]) + }) + + it('emits one entry per direct assignment and per group, tagged by scope', () => { + const group = { id: 'g', displayName: 'g' } + const silo: Policy = { + roleAssignments: [ + { identityId: 'g', identityType: 'silo_group', roleName: 'viewer' }, + ], + } + const project: Policy = { + roleAssignments: [{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' }], + } + expect( + userScopedRoleEntries( + 'u', + [group], + [ + { scope: 'silo', policy: silo }, + { scope: 'project', policy: project }, + ] + ) + ).toEqual([ + { roleName: 'viewer', scope: 'silo', source: { type: 'group', group } }, + { roleName: 'admin', scope: 'project', source: { type: 'direct' } }, + ]) + }) +}) + test('allRoles', () => { expect(allRoles).toEqual(['admin', 'collaborator', 'limited_collaborator', 'viewer']) }) diff --git a/app/api/roles.ts b/app/api/roles.ts index b687524ee0..83f6e240b5 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -160,10 +160,18 @@ export type ScopedRoleEntry = { source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } } } +/** Strongest role assigned to an identity in a policy, if any. */ +const roleForId = (policy: Policy, id: string) => + getEffectiveRole( + policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) + ) + /** * Enumerate all role assignments relevant to a user — one entry per direct * assignment and one per group assignment — across the given policies. Each - * entry is tagged with the scope of the policy it came from. + * entry is tagged with the scope of the policy it came from. Since the API + * permits multiple assignments for the same identity in one policy, each entry + * collapses those to the strongest role (see `getEffectiveRole`). * Callers are responsible for sorting and any display-layer merging. */ export function userScopedRoleEntries( @@ -173,14 +181,14 @@ export function userScopedRoleEntries( ): ScopedRoleEntry[] { const entries: ScopedRoleEntry[] = [] for (const { scope, policy } of scopedPolicies) { - const direct = policy.roleAssignments.find((ra) => ra.identityId === userId) + const direct = roleForId(policy, userId) if (direct) { - entries.push({ roleName: direct.roleName, scope, source: { type: 'direct' } }) + entries.push({ roleName: direct, scope, source: { type: 'direct' } }) } for (const group of userGroups) { - const via = policy.roleAssignments.find((ra) => ra.identityId === group.id) + const via = roleForId(policy, group.id) if (via) { - entries.push({ roleName: via.roleName, scope, source: { type: 'group', group } }) + entries.push({ roleName: via, scope, source: { type: 'group', group } }) } } } diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index e95b52f7c3..6069ff8b32 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -88,7 +88,10 @@ export function AccessRolesTable({ updateManagedPolicy, onAddClick, }: Props) { - const [editingRow, setEditingRow] = useState(null) + const [editing, setEditing] = useState<{ + row: AccessRow + defaultRole: RoleKey | undefined + } | null>(null) const [selectedUser, setSelectedUser] = useState(null) const [selectedGroup, setSelectedGroup] = useState(null) @@ -197,30 +200,47 @@ export function AccessRolesTable({ ), }), - getActionsCol((row: AccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingRow(row), - disabled: !row.managedRole && "You don't have permission to change this role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), - label: ( - - the {row.managedRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id - ? `This will remove your own ${managedScope} access.` - : undefined, - }), - disabled: !row.managedRole && "You don't have permission to delete this role", - }, - ]), + getActionsCol((row: AccessRow) => { + // A row can appear here because of a role in another scope (silo roles + // show on the project page) without a direct role in the managed scope. + // There's nothing to change or remove here, but a managed-scope role can + // still be assigned. + if (!row.managedRole) { + return [ + { + label: managedScope === 'project' ? 'Assign project role' : 'Assign role', + onActivate: () => setEditing({ row, defaultRole: undefined }), + }, + { + label: 'Delete', + onActivate: () => {}, + disabled: 'Role is inherited from another scope; modify it there to revoke', + }, + ] + } + return [ + { + label: 'Change role', + onActivate: () => setEditing({ row, defaultRole: row.managedRole }), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), + label: ( + + the {row.managedRole} role for {row.name} + + ), + resourceKind: 'role assignment', + extraContent: + row.id === me.id + ? `This will remove your own ${managedScope} access.` + : undefined, + }), + }, + ] + }), ], [ managedPolicy, @@ -244,14 +264,14 @@ export function AccessRolesTable({ Add user or group - {editingRow?.managedRole && ( + {editing && ( setEditingRow(null)} + onDismiss={() => setEditing(null)} policy={managedPolicy} - name={editingRow.name} - identityId={editingRow.id} - identityType={editingRow.identityType} - defaultValues={{ roleName: editingRow.managedRole }} + name={editing.row.name} + identityId={editing.row.id} + identityType={editing.row.identityType} + defaultValues={{ roleName: editing.defaultRole }} /> )} {selectedUser && ( diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index a2bea0d9c7..670e8e2e57 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,9 +5,16 @@ * * Copyright Oxide Computer Company */ -import { useState } from 'react' +import { useMemo, useState } from 'react' -import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type ScopedPolicy, +} from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { AccessRolesTable } from '~/components/access/AccessRolesTable' @@ -50,6 +57,11 @@ export default function SiloAccessPage() { const { data: siloPolicy } = usePrefetchedQuery(policyView) + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -87,7 +99,7 @@ export default function SiloAccessPage() { /> )} updatePolicy({ body })} diff --git a/app/pages/SiloGroupsTab.tsx b/app/pages/SiloGroupsTab.tsx index 03b20b8798..215ed7f387 100644 --- a/app/pages/SiloGroupsTab.tsx +++ b/app/pages/SiloGroupsTab.tsx @@ -5,6 +5,8 @@ * * Copyright Oxide Computer Company */ +import { useMemo } from 'react' + import { api, q, @@ -12,6 +14,7 @@ import { useApiMutation, usePrefetchedQuery, type Policy, + type ScopedPolicy, } from '@oxide/api' import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' @@ -26,6 +29,11 @@ export const handle = titleCrumb('Groups') export default function SiloGroupsTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -35,7 +43,7 @@ export default function SiloGroupsTab() { return ( updatePolicy({ body })} diff --git a/app/pages/SiloUsersTab.tsx b/app/pages/SiloUsersTab.tsx index 2fc1c12db9..00c0de4c91 100644 --- a/app/pages/SiloUsersTab.tsx +++ b/app/pages/SiloUsersTab.tsx @@ -5,6 +5,8 @@ * * Copyright Oxide Computer Company */ +import { useMemo } from 'react' + import { api, q, @@ -12,6 +14,7 @@ import { useApiMutation, usePrefetchedQuery, type Policy, + type ScopedPolicy, } from '@oxide/api' import { AccessUsersTab } from '~/components/access/AccessUsersTab' @@ -26,6 +29,11 @@ export const handle = titleCrumb('Users') export default function SiloUsersTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -35,7 +43,7 @@ export default function SiloUsersTab() { return ( updatePolicy({ body })} diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 606aec36cb..e80ff74721 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -93,6 +93,32 @@ test('Project access shows and edits project role assignments', async ({ page }) }) }) +test('Inherited-only row offers Assign project role, not a disabled Change', async ({ + page, +}) => { + await page.goto('/projects/mock-project/access') + const table = page.getByRole('table') + + // Hannah has only a silo role, so there's no project role to change or remove, + // but a project role can still be assigned from the row action + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() + + await page.getByRole('menuitem', { name: 'Assign project role' }).click() + await expect(page.getByRole('heading', { name: 'Assign role' })).toBeVisible() + await expect(page.getByRole('dialog')).toContainText('Hannah Arendt') + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.getByRole('button', { name: 'Assign role' }).click() + + // effective role is still silo.admin, with a +1 badge for the new project role + await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin+1' }) +}) + test('Project access user details side modal', async ({ page }) => { await page.goto('/projects/mock-project') await page.getByRole('link', { name: 'Project Access' }).click() From 5cf57ed6dcddf541c88393536f601d301dfe551f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 14 Jul 2026 16:43:43 -0700 Subject: [PATCH 33/45] Pull in logic and copy from PR 3263 --- app/components/access/AccessGroupsTab.tsx | 13 ++- app/components/access/AccessRolesTable.tsx | 38 +++--- app/components/access/AccessUsersTab.tsx | 5 + app/components/access/roleActions.tsx | 47 +++++--- app/components/access/use-can-edit-policy.ts | 43 +++++++ app/forms/project-access.tsx | 2 +- app/forms/silo-access.tsx | 2 +- test/e2e/project-access.e2e.ts | 63 +++++++--- test/e2e/silo-access.e2e.ts | 117 ++++++++++++------- 9 files changed, 238 insertions(+), 92 deletions(-) create mode 100644 app/components/access/use-can-edit-policy.ts diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 412a3867ed..8d2d30067e 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -40,6 +40,7 @@ import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' import { buildRoleActions } from './roleActions' +import { useCanEditPolicy } from './use-can-edit-policy' // The API only sorts groups by id, so fetch the full set and sort by name // client-side. ALL_ISH is the practical ceiling; a silo with more groups than @@ -91,6 +92,8 @@ export function AccessGroupsTab({ const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + const canEdit = useCanEditPolicy(scopedPolicies, managedScope) + const roleCol = useMemo( () => colHelper.display({ @@ -143,11 +146,19 @@ export function AccessGroupsTab({ directManagedRole, effective, inheritedReason: 'Role is inherited from another scope; modify it there to revoke', + canEdit, openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), }) }, - [managedRoleById, managedPolicy, updateManagedPolicy, scopedPolicies, managedScope] + [ + managedRoleById, + managedPolicy, + updateManagedPolicy, + scopedPolicies, + managedScope, + canEdit, + ] ) const columns = useColsWithActions(staticColumns, makeActions) diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index 6069ff8b32..7555874a57 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -44,6 +44,7 @@ import { identityTypeLabel, roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' +import { useCanEditPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' // full lists to resolve names and back the detail side modals; the API only @@ -106,6 +107,8 @@ export function AccessRolesTable({ // non-null: caller is responsible for including the managed scope const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + const canEditRoles = useCanEditPolicy(scopedPolicies, managedScope) + const rows = useMemo(() => { const nameById = new Map( [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) @@ -203,28 +206,22 @@ export function AccessRolesTable({ getActionsCol((row: AccessRow) => { // A row can appear here because of a role in another scope (silo roles // show on the project page) without a direct role in the managed scope. - // There's nothing to change or remove here, but a managed-scope role can - // still be assigned. - if (!row.managedRole) { - return [ - { - label: managedScope === 'project' ? 'Assign project role' : 'Assign role', - onActivate: () => setEditing({ row, defaultRole: undefined }), - }, - { - label: 'Delete', - onActivate: () => {}, - disabled: 'Role is inherited from another scope; modify it there to revoke', - }, - ] - } + // There's nothing to change or remove in that case, but a managed-scope + // role can still be added. + const editVerb = row.managedRole ? 'Change' : 'Add' return [ { - label: 'Change role', + label: `${editVerb} ${managedScope} role`, onActivate: () => setEditing({ row, defaultRole: row.managedRole }), + disabled: + !canEditRoles && + `You don't have permission to ${editVerb.toLowerCase()} ${managedScope} roles`, }, { - label: 'Delete', + // renamed from "Delete", so the auto destructive styling (keyed on + // the label "delete") no longer applies — set it explicitly + label: `Remove ${managedScope} role`, + className: 'destructive', onActivate: confirmDelete({ doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), label: ( @@ -238,11 +235,18 @@ export function AccessRolesTable({ ? `This will remove your own ${managedScope} access.` : undefined, }), + disabled: !canEditRoles + ? `You don't have permission to remove ${managedScope} roles` + : // no direct role in this scope to remove — it's inherited from the silo + !row.managedRole + ? 'This role is inherited from the silo' + : undefined, }, ] }), ], [ + canEditRoles, managedPolicy, managedScope, updateManagedPolicy, diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index 3883e0ecb3..af1c09920c 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -42,6 +42,7 @@ import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { buildRoleActions } from './roleActions' +import { useCanEditPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' // The API only sorts users by id, so fetch the full set and sort by name @@ -100,6 +101,8 @@ export function AccessUsersTab({ const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + const canEdit = useCanEditPolicy(scopedPolicies, managedScope) + const roleCol = useMemo( () => colHelper.display({ @@ -207,6 +210,7 @@ export function AccessUsersTab({ directManagedRole, effective, inheritedReason, + canEdit, openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), doRemove: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), }) @@ -218,6 +222,7 @@ export function AccessUsersTab({ groupsByUserId, scopedPolicies, managedScope, + canEdit, ] ) diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index d587f5a61f..ea0dd497f1 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -11,13 +11,12 @@ import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { type MenuAction } from '~/table/columns/action-col' -/** Verb labels for the row actions, scoped so the project tab reads "project role". */ +/** Verb labels for the row actions, scoped so they read e.g. "project role". */ function roleActionLabels(managedScope: AccessScope) { - const isProject = managedScope === 'project' return { - assign: isProject ? 'Assign project role' : 'Assign role', - change: isProject ? 'Change project role' : 'Change role', - remove: isProject ? 'Remove project role' : 'Remove role', + add: `Add ${managedScope} role`, + change: `Change ${managedScope} role`, + remove: `Remove ${managedScope} role`, } } @@ -32,6 +31,8 @@ type BuildRoleActionsArgs = { effective: { role: RoleKey } | null /** Disabled reason shown when there's no direct managed role to remove. */ inheritedReason: string + /** Whether the current user can add/change/remove roles in the managed scope. */ + canEdit: boolean /** Open the edit modal, pre-filled with the given role (undefined = assign). */ openEditModal: (defaultRole: RoleKey | undefined) => void /** Remove the direct managed role. */ @@ -50,12 +51,21 @@ export function buildRoleActions({ directManagedRole, effective, inheritedReason, + canEdit, openEditModal, doRemove, }: BuildRoleActionsArgs): MenuAction[] { const labels = roleActionLabels(managedScope) + const addAction: MenuAction = { + label: labels.add, + onActivate: () => openEditModal(undefined), + disabled: !canEdit && `You don't have permission to add ${managedScope} roles`, + } const removeAction: MenuAction = { - label: directManagedRole ? labels.remove : 'Remove role', + // renamed from "Delete", so the auto destructive styling (keyed on the label + // "delete") no longer applies — set it explicitly + label: labels.remove, + className: 'destructive', onActivate: confirmDelete({ doDelete: doRemove, label: ( @@ -65,28 +75,33 @@ export function buildRoleActions({ ), resourceKind: 'role assignment', }), - // a direct role on the managed policy is required to remove anything - disabled: !directManagedRole && inheritedReason, + disabled: !canEdit + ? `You don't have permission to remove ${managedScope} roles` + : // a direct role on the managed policy is required to remove anything + !directManagedRole + ? inheritedReason + : undefined, } // No role at all — direct or inherited. if (!effective) { - return [{ label: labels.assign, onActivate: () => openEditModal(undefined) }] + return [addAction] } // For the project tab, an inherited silo role doesn't give us anything to - // "change" on the project policy — frame it as assigning a project role. For + // "change" on the project policy — frame it as adding a project role. For // the silo tab, an inherited (via group) role can be promoted to a direct - // silo assignment via "Change role" pre-filled with the effective role. + // silo assignment via the change action, pre-filled with the effective role. if (managedScope === 'project' && !directManagedRole) { - return [ - { label: labels.assign, onActivate: () => openEditModal(undefined) }, - removeAction, - ] + return [addAction, removeAction] } // Pre-fill with the direct managed role if any; otherwise the effective role // so the modal opens in 'edit' mode showing the role currently in effect. const defaultRole = directManagedRole ?? effective.role return [ - { label: labels.change, onActivate: () => openEditModal(defaultRole) }, + { + label: labels.change, + onActivate: () => openEditModal(defaultRole), + disabled: !canEdit && `You don't have permission to change ${managedScope} roles`, + }, removeAction, ] } diff --git a/app/components/access/use-can-edit-policy.ts b/app/components/access/use-can-edit-policy.ts new file mode 100644 index 0000000000..15282027d8 --- /dev/null +++ b/app/components/access/use-can-edit-policy.ts @@ -0,0 +1,43 @@ +/* + * 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 { + getEffectiveRole, + userScopedRoleEntries, + type AccessScope, + type ScopedPolicy, +} from '@oxide/api' + +import { useCurrentUser } from '~/hooks/use-current-user' + +/** + * Whether the current user can add, change, or remove role assignments in the + * managed scope. Editing a scope's policy requires `modify` on that resource: + * silo modify comes from the silo admin role; project modify comes from the + * project admin role or a silo collaborator/admin role (a silo collaborator is + * an admin on every project in the silo). + * + * Fleet roles also grant modify, but they aren't present in these policies, so a + * fleet admin/collaborator may see actions disabled here that would in fact + * succeed. That's the same limitation the access pages have always had. + * https://github.com/oxidecomputer/omicron/blob/main/nexus/auth/src/authz/omicron.polar + */ +export function useCanEditPolicy( + scopedPolicies: ScopedPolicy[], + managedScope: AccessScope +): boolean { + const { me, myGroups } = useCurrentUser() + const entries = userScopedRoleEntries(me.id, myGroups.items, scopedPolicies) + const roleInScope = (scope: AccessScope) => + getEffectiveRole(entries.filter((e) => e.scope === scope).map((e) => e.roleName)) + const siloRole = roleInScope('silo') + return managedScope === 'silo' + ? siloRole === 'admin' + : roleInScope('project') === 'admin' || + siloRole === 'admin' || + siloRole === 'collaborator' +} diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 9ce524754e..6aa79d99da 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -109,7 +109,7 @@ export function ProjectAccessEditUserSideModal({ form={form} formType={isAssigning ? 'create' : 'edit'} resourceName="role" - title={isAssigning ? 'Assign role' : 'Edit role'} + title={isAssigning ? 'Add project role' : 'Edit project role'} subtitle={ {name} diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index e0c786534b..78d61b71ef 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -99,7 +99,7 @@ export function SiloAccessEditUserSideModal({ form={form} formType={isAssigning ? 'create' : 'edit'} resourceName="role" - title={isAssigning ? 'Assign role' : 'Edit role'} + title={isAssigning ? 'Add silo role' : 'Edit silo role'} subtitle={ {name} diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index e80ff74721..427f9d20d8 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -7,7 +7,7 @@ */ import { user3, user4 } from '@oxide/api-mocks' -import { expect, expectRowVisible, test } from './utils' +import { expect, expectRowVisible, getPageAsUser, test } from './utils' test('Project access shows and edits project role assignments', async ({ page }) => { await page.goto('/projects/mock-project') @@ -64,17 +64,17 @@ test('Project access shows and edits project role assignments', async ({ page }) .getByRole('row', { name: user4.display_name, exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() - await expect(page.getByRole('heading', { name: 'Edit role' })).toBeVisible() + await page.getByRole('menuitem', { name: 'Change project role' }).click() + await expect(page.getByRole('heading', { name: 'Edit project role' })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: user4.display_name, Role: 'project.viewer' }) - // delete Jacob's project role + // remove Jacob's project role const jacobRow = page.getByRole('row', { name: user3.display_name, exact: false }) await jacobRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove project role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expect(jacobRow).toBeHidden() @@ -93,32 +93,65 @@ test('Project access shows and edits project role assignments', async ({ page }) }) }) -test('Inherited-only row offers Assign project role, not a disabled Change', async ({ +test('Inherited-only row offers Add project role, not a disabled Change', async ({ page, }) => { await page.goto('/projects/mock-project/access') const table = page.getByRole('table') - // Hannah has only a silo role, so there's no project role to change or remove, - // but a project role can still be assigned from the row action + // Hannah has only a silo role, so there's no project role to change, but a + // project role can still be added from the row action. Remove is disabled + // because the inherited silo role can only be changed on the silo page. await table .getByRole('row', { name: 'Hannah Arendt', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() - await expect(page.getByRole('menuitem', { name: 'Assign project role' })).toBeEnabled() - await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() - - await page.getByRole('menuitem', { name: 'Assign project role' }).click() - await expect(page.getByRole('heading', { name: 'Assign role' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Change project role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Add project role' })).toBeEnabled() + const removeItem = page.getByRole('menuitem', { name: 'Remove project role' }) + await expect(removeItem).toBeDisabled() + await removeItem.hover() + await expect(page.getByRole('tooltip')).toHaveText('This role is inherited from the silo') + + await page.getByRole('menuitem', { name: 'Add project role' }).click() + await expect(page.getByRole('heading', { name: 'Add project role' })).toBeVisible() await expect(page.getByRole('dialog')).toContainText('Hannah Arendt') await page.getByRole('radio', { name: /^Viewer / }).click() - await page.getByRole('button', { name: 'Assign role' }).click() + await page.getByRole('button', { name: 'Add project role' }).click() // effective role is still silo.admin, with a +1 badge for the new project role await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.admin+1' }) }) +test('Non-admin cannot change or remove project roles', async ({ browser }) => { + // Jacob Klein is only a project collaborator (not project admin or silo + // collaborator/admin), so he lacks `modify` on the project and can't edit role + // assignments. Both row actions should be disabled with an explanation. + const page = await getPageAsUser(browser, 'Jacob Klein') + await page.goto('/projects/mock-project/access') + + await expect(page.getByRole('heading', { name: 'Project Access' })).toBeVisible() + + await page + .getByRole('row', { name: 'Herbert Marcuse', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + + const changeRole = page.getByRole('menuitem', { name: 'Change project role' }) + await expect(changeRole).toBeDisabled() + await changeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to change project roles" + ) + + const removeRole = page.getByRole('menuitem', { name: 'Remove project role' }) + await expect(removeRole).toBeDisabled() + await removeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to remove project roles" + ) +}) + test('Project access user details side modal', async ({ page }) => { await page.goto('/projects/mock-project') await page.getByRole('link', { name: 'Project Access' }).click() diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index c3f0812ab2..d7371fb47c 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { expect, expectRowVisible, test } from './utils' +import { expect, expectRowVisible, getPageAsUser, test } from './utils' test('Silo Access page shows and edits silo role assignments', async ({ page }) => { await page.goto('/') @@ -42,21 +42,50 @@ test('Silo Access page shows and edits silo role assignments', async ({ page }) .getByRole('row', { name: 'Jacob Klein', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() - await expect(page.getByRole('heading', { name: 'Edit role' })).toBeVisible() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + await expect(page.getByRole('heading', { name: 'Edit silo role' })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.viewer' }) - // delete Jacob's role + // remove Jacob's role const jacobRow = page.getByRole('row', { name: 'Jacob Klein', exact: false }) await jacobRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expect(jacobRow).toBeHidden() }) +test('Non-admin cannot change or remove silo roles', async ({ browser }) => { + // Hans Jonas is only a silo collaborator (via the real-estate-devs group), so + // he lacks `modify` on the silo and can't edit role assignments. Both row + // actions should be disabled with an explanation. + const page = await getPageAsUser(browser, 'Hans Jonas') + await page.goto('/access') + + await expect(page.getByRole('heading', { name: 'Silo Access' })).toBeVisible() + + await page + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + + const changeRole = page.getByRole('menuitem', { name: 'Change silo role' }) + await expect(changeRole).toBeDisabled() + await changeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to change silo roles" + ) + + const removeRole = page.getByRole('menuitem', { name: 'Remove silo role' }) + await expect(removeRole).toBeDisabled() + await removeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to remove silo roles" + ) +}) + test('Silo Access user details side modal', async ({ page }) => { await page.goto('/access') @@ -154,29 +183,35 @@ test('Change and remove a user role from the Users tab', async ({ page }) => { await page.goto('/users') const table = page.getByRole('table') - // Hannah has a direct silo.admin role; change it to viewer - await table - .getByRole('row', { name: 'Hannah Arendt', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() - await expect(page.getByRole('radio', { name: /^Admin / })).toBeChecked() + // Act on Jacob rather than the current user (Hannah, silo admin): demoting or + // removing our own silo role would revoke our permission to keep editing, so + // give Jacob a direct role first, then change and remove it. + const jacobActions = () => + table + .getByRole('row', { name: 'Jacob Klein', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() + + await jacobActions() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + await page.getByRole('radio', { name: /^Collaborator / }).click() + await page.getByRole('button', { name: 'Add silo role' }).click() + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.collaborator' }) + + // change Jacob's role to viewer + await jacobActions() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + await expect(page.getByRole('heading', { name: /Edit silo role/ })).toBeVisible() + await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: 'silo.viewer' }) + await expectRowVisible(table, { Name: 'Jacob Klein', Role: 'silo.viewer' }) - // Remove Hannah's direct role; she still inherits via groups so the row stays - await table - .getByRole('row', { name: 'Hannah Arendt', exact: false }) - .getByRole('button', { name: 'Row actions' }) - .click() - await page.getByRole('menuitem', { name: 'Remove role' }).click() + // remove Jacob's direct role; he has no group-inherited role, so it goes to — + await jacobActions() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() - // After removal, Hannah no longer has a direct silo role, so her displayed role - // reflects whatever she inherits via her groups (kernel-devs, web-devs have no - // silo role assignments by default in mock data). - await expectRowVisible(table, { Name: 'Hannah Arendt', Role: '—' }) + await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—' }) }) test('Assign role to a user with no direct role from the row action', async ({ page }) => { @@ -188,18 +223,18 @@ test('Assign role to a user with no direct role from the row action', async ({ p .getByRole('row', { name: 'Jacob Klein', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - // unassigned users show only "Assign role" — no Change/Remove - await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeHidden() - await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeHidden() - await page.getByRole('menuitem', { name: 'Assign role' }).click() + // unassigned users show only "Add silo role" — no Change/Remove + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeHidden() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() // Modal opens with the user already targeted (no listbox), and no role pre-selected - await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /Add silo role/ })).toBeVisible() await expect(page.getByRole('button', { name: 'User or group' })).toBeHidden() await expect(page.getByRole('dialog')).toContainText('Jacob Klein') await page.getByRole('radio', { name: /^Collaborator / }).click() - await page.getByRole('button', { name: 'Assign role' }).click() + await page.getByRole('button', { name: 'Add silo role' }).click() await expectRowVisible(table, { Name: 'Jacob Klein', @@ -217,13 +252,13 @@ test('Inherited-only role shows Change/Remove with Remove disabled', async ({ pa .getByRole('row', { name: 'Hans Jonas', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await expect(page.getByRole('menuitem', { name: 'Assign role' })).toBeHidden() - await expect(page.getByRole('menuitem', { name: 'Change role' })).toBeEnabled() - await expect(page.getByRole('menuitem', { name: 'Remove role' })).toBeDisabled() + await expect(page.getByRole('menuitem', { name: 'Add silo role' })).toBeHidden() + await expect(page.getByRole('menuitem', { name: 'Change silo role' })).toBeEnabled() + await expect(page.getByRole('menuitem', { name: 'Remove silo role' })).toBeDisabled() // Change role opens the edit modal with the inherited role pre-selected - await page.getByRole('menuitem', { name: 'Change role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + await expect(page.getByRole('heading', { name: /Edit silo role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() }) @@ -261,8 +296,8 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { .getByRole('row', { name: 'real-estate-devs', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await page.getByRole('menuitem', { name: 'Change silo role' }).click() + await expect(page.getByRole('heading', { name: /Edit silo role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() await page.getByRole('button', { name: 'Update role' }).click() @@ -276,7 +311,7 @@ test('Change and remove a group role from the Groups tab', async ({ page }) => { .getByRole('row', { name: 'real-estate-devs', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Remove role' }).click() + await page.getByRole('menuitem', { name: 'Remove silo role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expectRowVisible(table, { Name: 'real-estate-devs', Role: '—' }) }) @@ -292,12 +327,12 @@ test('Assign a role to a group with no direct role from the row action', async ( .getByRole('row', { name: 'kernel-devs', exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Assign role' }).click() - await expect(page.getByRole('heading', { name: /Assign role/ })).toBeVisible() + await page.getByRole('menuitem', { name: 'Add silo role' }).click() + await expect(page.getByRole('heading', { name: /Add silo role/ })).toBeVisible() await expect(page.getByRole('dialog')).toContainText('kernel-devs') await page.getByRole('radio', { name: /^Viewer / }).click() - await page.getByRole('button', { name: 'Assign role' }).click() + await page.getByRole('button', { name: 'Add silo role' }).click() await expectRowVisible(table, { Name: 'kernel-devs', Role: 'silo.viewer' }) }) From 97ede69bb1f464e5860eea90e8fe62e43401ce63 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 15 Jul 2026 11:33:46 -0700 Subject: [PATCH 34/45] Add to Fleet roles page and refactor out duplicated code --- app/components/access/AccessGroupsTab.tsx | 1 + app/components/access/AccessRolesTable.tsx | 45 ++++------ app/components/access/AccessUsersTab.tsx | 4 + app/components/access/roleActions.tsx | 93 ++++++++++++++------ app/components/access/use-can-edit-policy.ts | 17 ++++ app/forms/fleet-access.tsx | 2 +- app/pages/system/FleetAccessPage.tsx | 38 ++++---- test/e2e/fleet-access.e2e.ts | 41 +++++---- 8 files changed, 152 insertions(+), 89 deletions(-) diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 8d2d30067e..71c4830a61 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -147,6 +147,7 @@ export function AccessGroupsTab({ effective, inheritedReason: 'Role is inherited from another scope; modify it there to revoke', canEdit, + isSelf: false, // a group is never the current user openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), }) diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index 7555874a57..f72d735027 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -28,11 +28,9 @@ import { import { Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { type EditRoleModalProps } from '~/forms/access-util' import { useCurrentUser } from '~/hooks/use-current-user' -import { confirmDelete } from '~/stores/confirm-delete' import { ButtonCell } from '~/table/cells/LinkCell' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' @@ -44,6 +42,11 @@ import { identityTypeLabel, roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' +import { + buildRemoveRoleAction, + noRolePermissionReason, + roleActionLabel, +} from './roleActions' import { useCanEditPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' @@ -208,40 +211,26 @@ export function AccessRolesTable({ // show on the project page) without a direct role in the managed scope. // There's nothing to change or remove in that case, but a managed-scope // role can still be added. - const editVerb = row.managedRole ? 'Change' : 'Add' + const editVerb = row.managedRole ? 'change' : 'add' return [ { - label: `${editVerb} ${managedScope} role`, + label: roleActionLabel(managedScope, editVerb), onActivate: () => setEditing({ row, defaultRole: row.managedRole }), - disabled: - !canEditRoles && - `You don't have permission to ${editVerb.toLowerCase()} ${managedScope} roles`, + disabled: !canEditRoles && noRolePermissionReason(managedScope, editVerb), }, - { - // renamed from "Delete", so the auto destructive styling (keyed on - // the label "delete") no longer applies — set it explicitly - label: `Remove ${managedScope} role`, - className: 'destructive', - onActivate: confirmDelete({ - doDelete: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), - label: ( - - the {row.managedRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id - ? `This will remove your own ${managedScope} access.` - : undefined, - }), - disabled: !canEditRoles - ? `You don't have permission to remove ${managedScope} roles` + buildRemoveRoleAction({ + name: row.name, + role: row.managedRole, + scope: managedScope, + isSelf: row.id === me.id, + disabledReason: !canEditRoles + ? noRolePermissionReason(managedScope, 'remove') : // no direct role in this scope to remove — it's inherited from the silo !row.managedRole ? 'This role is inherited from the silo' : undefined, - }, + doRemove: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), + }), ] }), ], diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index af1c09920c..c6b9082805 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -30,6 +30,7 @@ import { Badge } from '@oxide/design-system/ui' import { ListPlusCell } from '~/components/ListPlusCell' import { type EditRoleModalProps } from '~/forms/access-util' +import { useCurrentUser } from '~/hooks/use-current-user' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -102,6 +103,7 @@ export function AccessUsersTab({ const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) const canEdit = useCanEditPolicy(scopedPolicies, managedScope) + const { me } = useCurrentUser() const roleCol = useMemo( () => @@ -211,6 +213,7 @@ export function AccessUsersTab({ effective, inheritedReason, canEdit, + isSelf: user.id === me.id, openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), doRemove: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), }) @@ -223,6 +226,7 @@ export function AccessUsersTab({ scopedPolicies, managedScope, canEdit, + me, ] ) diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index ea0dd497f1..d2372677e7 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -10,13 +10,58 @@ import { type AccessScope, type RoleKey } from '@oxide/api' import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { type MenuAction } from '~/table/columns/action-col' +import { capitalize } from '~/util/str' -/** Verb labels for the row actions, scoped so they read e.g. "project role". */ -function roleActionLabels(managedScope: AccessScope) { +/** Scopes whose role assignments are editable from a row action. */ +export type RoleScope = AccessScope | 'fleet' +type RoleVerb = 'add' | 'change' | 'remove' + +/** Row-action label, scoped so it reads e.g. "Change project role". */ +export const roleActionLabel = (scope: RoleScope, verb: RoleVerb) => + `${capitalize(verb)} ${scope} role` + +/** Disabled-action reason shown when the user can't edit roles in this scope. */ +export const noRolePermissionReason = (scope: RoleScope, verb: RoleVerb) => + `You don't have permission to ${verb} ${scope} roles` + +/** + * The "Remove role" row action: a destructive confirm-delete with the standard + * role-assignment copy and a self-removal warning. `disabledReason` is the + * caller's computed reason (undefined when enabled), because what makes removal + * unavailable differs by surface — no edit permission, or an inherited-only role + * that must be changed at its source. + */ +export function buildRemoveRoleAction({ + name, + role, + scope, + isSelf, + disabledReason, + doRemove, +}: { + name: string + role: RoleKey | undefined + scope: RoleScope + isSelf: boolean + disabledReason: string | undefined + doRemove: () => Promise +}): MenuAction { return { - add: `Add ${managedScope} role`, - change: `Change ${managedScope} role`, - remove: `Remove ${managedScope} role`, + // renamed from "Delete", so the auto destructive styling (keyed on the label + // "delete") no longer applies — set it explicitly + label: roleActionLabel(scope, 'remove'), + className: 'destructive', + onActivate: confirmDelete({ + doDelete: doRemove, + label: ( + + the {role} role for {name} + + ), + resourceKind: 'role assignment', + extraContent: isSelf ? `This will remove your own ${scope} access.` : undefined, + }), + disabled: disabledReason, } } @@ -33,6 +78,8 @@ type BuildRoleActionsArgs = { inheritedReason: string /** Whether the current user can add/change/remove roles in the managed scope. */ canEdit: boolean + /** Whether this row is the current user (shows a self-removal warning). */ + isSelf: boolean /** Open the edit modal, pre-filled with the given role (undefined = assign). */ openEditModal: (defaultRole: RoleKey | undefined) => void /** Remove the direct managed role. */ @@ -52,36 +99,28 @@ export function buildRoleActions({ effective, inheritedReason, canEdit, + isSelf, openEditModal, doRemove, }: BuildRoleActionsArgs): MenuAction[] { - const labels = roleActionLabels(managedScope) const addAction: MenuAction = { - label: labels.add, + label: roleActionLabel(managedScope, 'add'), onActivate: () => openEditModal(undefined), - disabled: !canEdit && `You don't have permission to add ${managedScope} roles`, + disabled: !canEdit && noRolePermissionReason(managedScope, 'add'), } - const removeAction: MenuAction = { - // renamed from "Delete", so the auto destructive styling (keyed on the label - // "delete") no longer applies — set it explicitly - label: labels.remove, - className: 'destructive', - onActivate: confirmDelete({ - doDelete: doRemove, - label: ( - - the {directManagedRole} role for {name} - - ), - resourceKind: 'role assignment', - }), - disabled: !canEdit - ? `You don't have permission to remove ${managedScope} roles` + const removeAction = buildRemoveRoleAction({ + name, + role: directManagedRole, + scope: managedScope, + isSelf, + disabledReason: !canEdit + ? noRolePermissionReason(managedScope, 'remove') : // a direct role on the managed policy is required to remove anything !directManagedRole ? inheritedReason : undefined, - } + doRemove, + }) // No role at all — direct or inherited. if (!effective) { return [addAction] @@ -98,9 +137,9 @@ export function buildRoleActions({ const defaultRole = directManagedRole ?? effective.role return [ { - label: labels.change, + label: roleActionLabel(managedScope, 'change'), onActivate: () => openEditModal(defaultRole), - disabled: !canEdit && `You don't have permission to change ${managedScope} roles`, + disabled: !canEdit && noRolePermissionReason(managedScope, 'change'), }, removeAction, ] diff --git a/app/components/access/use-can-edit-policy.ts b/app/components/access/use-can-edit-policy.ts index 15282027d8..4c4337f374 100644 --- a/app/components/access/use-can-edit-policy.ts +++ b/app/components/access/use-can-edit-policy.ts @@ -9,6 +9,7 @@ import { getEffectiveRole, userScopedRoleEntries, type AccessScope, + type FleetRolePolicy, type ScopedPolicy, } from '@oxide/api' @@ -41,3 +42,19 @@ export function useCanEditPolicy( siloRole === 'admin' || siloRole === 'collaborator' } + +/** + * Whether the current user can add, change, or remove fleet role assignments. + * Modifying the fleet policy requires the fleet admin role. Fleet is the + * top-level resource, so unlike project there's no collaborator cascade — only + * admin confers modify. + * https://github.com/oxidecomputer/omicron/blob/main/nexus/auth/src/authz/omicron.polar + */ +export function useCanEditFleetPolicy(policy: FleetRolePolicy): boolean { + const { me, myGroups } = useCurrentUser() + const myIds = new Set([me.id, ...myGroups.items.map((g) => g.id)]) + const myFleetRole = getEffectiveRole( + policy.roleAssignments.filter((ra) => myIds.has(ra.identityId)).map((ra) => ra.roleName) + ) + return myFleetRole === 'admin' +} diff --git a/app/forms/fleet-access.tsx b/app/forms/fleet-access.tsx index ce2d914884..5f918e7378 100644 --- a/app/forms/fleet-access.tsx +++ b/app/forms/fleet-access.tsx @@ -103,7 +103,7 @@ export function FleetAccessEditUserSideModal({ form={form} formType="edit" resourceName="role" - title="Edit role" + title="Edit fleet role" subtitle={ {name} diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index e29e7dbbcf..92e50142e6 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -25,15 +25,19 @@ import { import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' +import { + buildRemoveRoleAction, + noRolePermissionReason, + roleActionLabel, +} from '~/components/access/roleActions' +import { useCanEditFleetPolicy } from '~/components/access/use-can-edit-policy' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' import { FleetAccessAddUserSideModal, FleetAccessEditUserSideModal, } from '~/forms/fleet-access' import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions } from '~/hooks/use-quick-actions' -import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' @@ -106,6 +110,7 @@ export default function FleetAccessPage() { const navigate = useNavigate() const { me } = useCurrentUser() const { data: fleetPolicy } = usePrefetchedQuery(systemPolicyView) + const canEditRoles = useCanEditFleetPolicy(fleetPolicy) const { data: users } = usePrefetchedQuery(userList) const { data: groups } = usePrefetchedQuery(groupList) const { data: silos } = usePrefetchedQuery(siloList) @@ -232,28 +237,25 @@ export default function FleetAccessPage() { ]) .with({ kind: 'assignment' }, (row) => [ { - label: 'Change role', + label: roleActionLabel('fleet', 'change'), onActivate: () => setEditingUserRow(row), + disabled: !canEditRoles && noRolePermissionReason('fleet', 'change'), }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), - label: ( - - the {row.fleetRole} role for {row.name} - - ), - resourceKind: 'role assignment', - extraContent: - row.id === me.id ? 'This will remove your own fleet access.' : undefined, - }), - }, + buildRemoveRoleAction({ + name: row.name, + role: row.fleetRole, + scope: 'fleet', + isSelf: row.id === me.id, + disabledReason: canEditRoles + ? undefined + : noRolePermissionReason('fleet', 'remove'), + doRemove: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), + }), ]) .exhaustive() ), ], - [fleetPolicy, updatePolicy, me, navigate] + [canEditRoles, fleetPolicy, updatePolicy, me, navigate] ) useQuickActions( diff --git a/test/e2e/fleet-access.e2e.ts b/test/e2e/fleet-access.e2e.ts index 865de1d55f..d1375544fa 100644 --- a/test/e2e/fleet-access.e2e.ts +++ b/test/e2e/fleet-access.e2e.ts @@ -79,9 +79,9 @@ test('Click through fleet access page', async ({ page }) => { .getByRole('row', { name: user3.display_name, exact: false }) .getByRole('button', { name: 'Row actions' }) .click() - await page.getByRole('menuitem', { name: 'Change role' }).click() + await page.getByRole('menuitem', { name: 'Change fleet role' }).click() - await expect(page.getByRole('heading', { name: /Edit role/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /Edit fleet role/ })).toBeVisible() await expect(page.getByRole('radio', { name: /^Collaborator / })).toBeChecked() await page.getByRole('radio', { name: /^Viewer / }).click() @@ -89,11 +89,11 @@ test('Click through fleet access page', async ({ page }) => { await expectRowVisible(table, { Name: user3.display_name, 'Fleet role': 'fleet.viewer' }) - // delete user 3 + // remove 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('menuitem', { name: 'Remove fleet role' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await expectToast(page, 'Access removed') await expect(user3Row).toBeHidden() @@ -131,7 +131,7 @@ test('Self-removal warning on delete', async ({ page }) => { // Hannah Arendt is the logged-in user with fleet admin const hannahRow = page.getByRole('row', { name: 'Hannah Arendt', exact: false }) await hannahRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('menuitem', { name: 'Remove fleet role' }).click() // confirm dialog should show the self-removal warning await expect(page.getByText('This will remove your own fleet access.')).toBeVisible() @@ -140,7 +140,10 @@ test('Self-removal warning on delete', async ({ page }) => { await page.getByRole('button', { name: 'Cancel' }).click() }) -test('Fleet viewer cannot modify fleet access', async ({ browser }) => { +test('Fleet viewer cannot change or remove fleet roles', async ({ browser }) => { + // Jane Austen is only a fleet viewer, so she can load the page but lacks + // `modify` on the fleet policy (only fleet admins can). Both row actions + // should be disabled with an explanation. const page = await getPageAsUser(browser, 'Jane Austen') await page.goto('/system/access') @@ -148,16 +151,24 @@ test('Fleet viewer cannot modify fleet access', async ({ browser }) => { await expect(page.getByRole('heading', { name: /Fleet Access/ })).toBeVisible() await expectRowVisible(table, { Name: 'Hannah Arendt', 'Fleet role': 'fleet.admin' }) - // attempt to add a user — the submit should fail with 403 - await page.getByRole('button', { name: 'Add user or group' }).click() - await page.getByRole('button', { name: /User or group/ }).click() - await page.getByRole('option', { name: 'Jacob Klein' }).click() - await page.getByRole('button', { name: 'Assign role' }).click() - await expect(page.getByText('Action not authorized')).toBeVisible() + await table + .getByRole('row', { name: 'Hannah Arendt', exact: false }) + .getByRole('button', { name: 'Row actions' }) + .click() - // dismiss the modal and confirm the table is unchanged - await page.getByRole('button', { name: 'Cancel' }).click() - await expect(page.getByRole('cell', { name: 'Jacob Klein' })).toBeHidden() + const changeRole = page.getByRole('menuitem', { name: 'Change fleet role' }) + await expect(changeRole).toBeDisabled() + await changeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to change fleet roles" + ) + + const removeRole = page.getByRole('menuitem', { name: 'Remove fleet role' }) + await expect(removeRole).toBeDisabled() + await removeRole.hover() + await expect(page.getByRole('tooltip')).toHaveText( + "You don't have permission to remove fleet roles" + ) }) test('Cross-silo user shows UUID with tooltip', async ({ page }) => { From 3fd38e2cd653ba57303502c48491c8057b3eb535 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 22 Jul 2026 13:42:12 -0400 Subject: [PATCH 35/45] add note to AGENTS.md about top-level routes so we don't forget about it --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index bbee0fd1c0..0ed332a32f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ - Breadcrumbs come from route `handle.crumb`; use `makeCrumb`/`titleCrumb` and provide a `path` when the parent route redirects (`app/hooks/use-crumbs.ts:21-64`). Use `titleCrumb` for side modal forms that should appear in page title but not nav breadcrumbs (check `Crumb.titleOnly` flag). - When adding tabs or redirects, wire the canonical link in the path builder (e.g., point to the default tab) and update the sidebar/quick actions as needed. - For tabs synced with query params, use `QueryParamTabs` component which manages `?tab=` param and removes it when default tab is selected (`app/components/QueryParamTabs.tsx`). +- Adding a new _top-level_ path segment (e.g., `/users`) requires a matching console page endpoint in Nexus, or direct navigation and refresh at that URL will 404. Nexus can't use a catchall route (it would overlap API routes), so each top-level console prefix is registered as an `unpublished` endpoint serving the console index: definitions in [`nexus/external-api/src/lib.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/external-api/src/lib.rs#L8921-L8928), handlers in [`nexus/src/external_api/http_entrypoints.rs`](https://github.com/oxidecomputer/omicron/blob/4b0ce4b/nexus/src/external_api/http_entrypoints.rs#L8765-L8769). Routes nested under existing prefixes (`/projects/*`, `/system/*`, `/settings/*`, `/lookup/*`) need no Nexus change. # Forms From 02f17df1d19cf6d355f83854a127049a23559a32 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 22 Jul 2026 17:59:30 -0400 Subject: [PATCH 36/45] Un-abstract users/groups tabs to silo-only (#3300) Initially I think #3211 had users and groups at both the silo and project level and then pared it back to only have it at the silo level, but kept the abstractions used to make it work in both spots. This undoes the abstraction. Redone from #3298 because I made that one a stack, and it turns out that prevents you from merging it into the underlying one. --- app/api/roles.spec.ts | 14 ++ app/api/roles.ts | 24 +-- app/components/access/AccessGroupsTab.tsx | 88 ++++------- app/components/access/AccessUsersTab.tsx | 145 ++++++++----------- app/components/access/roleActions.tsx | 90 +++++------- app/components/access/use-can-edit-policy.ts | 6 + app/pages/SiloGroupsTab.tsx | 41 +----- app/pages/SiloUsersTab.tsx | 41 +----- 8 files changed, 164 insertions(+), 285 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index fa0efbe681..144774e755 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -14,6 +14,7 @@ import { effectiveScopedRole, getEffectiveRole, roleOrder, + rolesByIdFromPolicy, updateRole, userScopedRoleEntries, type Policy, @@ -125,6 +126,19 @@ test('byGroupThenName sorts as expected', () => { expect([c, e, b, d, a].sort(byGroupThenName)).toEqual([a, b, c, d, e]) }) +describe('rolesByIdFromPolicy', () => { + it('maps each identity to its role', () => { + expect(rolesByIdFromPolicy(abcAdminPolicy)).toEqual(new Map([['abc', 'admin']])) + }) + + it('keeps the strongest role when an identity has multiple assignments', () => { + const policy: Policy = { roleAssignments: [abcViewer, abcAdmin] } + expect(rolesByIdFromPolicy(policy)).toEqual(new Map([['abc', 'admin']])) + const reversed: Policy = { roleAssignments: [abcAdmin, abcViewer] } + expect(rolesByIdFromPolicy(reversed)).toEqual(new Map([['abc', 'admin']])) + }) +}) + describe('userScopedRoleEntries', () => { it('collapses multiple assignments for the same identity to the strongest role', () => { // API permits multiple assignments for one identity in a single policy diff --git a/app/api/roles.ts b/app/api/roles.ts index 83f6e240b5..56a8b96ed9 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -85,11 +85,20 @@ export function updateRole( return { roleAssignments } } -/** Map from identity ID to role name for quick lookup. */ +/** + * Map from identity ID to role name for quick lookup. If an actor has multiple + * assignments in the same policy (which the API allows) take the strongest one. + */ export function rolesByIdFromPolicy( policy: Policy ): Map { - return new Map(policy.roleAssignments.map((a) => [a.identityId, a.roleName])) + const map = new Map() + for (const { identityId, roleName } of policy.roleAssignments) { + const existing = map.get(identityId) + // non-null: list is non-empty + map.set(identityId, getEffectiveRole(existing ? [existing, roleName] : [roleName])!) + } + return map } /** @@ -160,12 +169,6 @@ export type ScopedRoleEntry = { source: { type: 'direct' } | { type: 'group'; group: { id: string; displayName: string } } } -/** Strongest role assigned to an identity in a policy, if any. */ -const roleForId = (policy: Policy, id: string) => - getEffectiveRole( - policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) - ) - /** * Enumerate all role assignments relevant to a user — one entry per direct * assignment and one per group assignment — across the given policies. Each @@ -181,12 +184,13 @@ export function userScopedRoleEntries( ): ScopedRoleEntry[] { const entries: ScopedRoleEntry[] = [] for (const { scope, policy } of scopedPolicies) { - const direct = roleForId(policy, userId) + const roleById = rolesByIdFromPolicy(policy) + const direct = roleById.get(userId) if (direct) { entries.push({ roleName: direct, scope, source: { type: 'direct' } }) } for (const group of userGroups) { - const via = roleForId(policy, group.id) + const via = roleById.get(group.id) if (via) { entries.push({ roleName: via, scope, source: { type: 'group', group } }) } diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 71c4830a61..92b74968ce 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -6,27 +6,25 @@ * Copyright Oxide Computer Company */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo, useState, type ComponentType } from 'react' +import { useCallback, useMemo, useState } from 'react' import * as R from 'remeda' import { api, deleteRole, - effectiveScopedRole, q, + queryClient, rolesByIdFromPolicy, + useApiMutation, usePrefetchedQuery, - userScopedRoleEntries, - type AccessScope, type Group, - type Policy, type RoleKey, - type ScopedPolicy, } from '@oxide/api' import { PersonGroup24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { type EditRoleModalProps } from '~/forms/access-util' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { ButtonCell } from '~/table/cells/LinkCell' import { MemberCountCell } from '~/table/cells/MemberCountCell' @@ -40,12 +38,13 @@ import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' import { buildRoleActions } from './roleActions' -import { useCanEditPolicy } from './use-can-edit-policy' +import { useCanEditSiloPolicy } from './use-can-edit-policy' // The API only sorts groups by id, so fetch the full set and sort by name // client-side. ALL_ISH is the practical ceiling; a silo with more groups than // that would have its tail dropped in (arbitrary) id order. const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +const policyView = q(api.policyView, {}) const colHelper = createColumnHelper() @@ -61,23 +60,7 @@ const GroupEmptyState = () => ( type EditingState = { group: Group; defaultRole: RoleKey | undefined } -type Props = { - /** Policies that contribute to a group's effective role on this page. */ - scopedPolicies: ScopedPolicy[] - /** Scope managed by this tab — its direct roles are assignable/removable. */ - managedScope: AccessScope - /** Modal for assigning/editing a role on the managed policy. */ - EditModal: ComponentType - /** Update the managed policy. Called when removing a role. */ - updateManagedPolicy: (newPolicy: Policy) => Promise -} - -export function AccessGroupsTab({ - scopedPolicies, - managedScope, - EditModal, - updateManagedPolicy, -}: Props) { +export function AccessGroupsTab() { const [selectedGroup, setSelectedGroup] = useState(null) const [editingGroup, setEditingGroup] = useState(null) @@ -87,12 +70,17 @@ export function AccessGroupsTab({ [groups] ) - // non-null: caller is responsible for including the managed scope - const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const roleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) + const canEdit = useCanEditSiloPolicy(siloPolicy) - const canEdit = useCanEditPolicy(scopedPolicies, managedScope) + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) const roleCol = useMemo( () => @@ -100,18 +88,13 @@ export function AccessGroupsTab({ id: 'role', header: 'Role', cell: ({ row }) => { - // groups never inherit, so passing no groups yields direct roles only - const entries = userScopedRoleEntries(row.original.id, [], scopedPolicies) - const effective = effectiveScopedRole(entries) - if (!effective) return - return ( - - {effective.scope}.{effective.role} - - ) + // groups never inherit, so their only silo role is a direct one + const role = roleById.get(row.original.id) + if (!role) return + return silo.{role} }, }), - [scopedPolicies] + [roleById] ) const staticColumns = useMemo( @@ -137,29 +120,18 @@ export function AccessGroupsTab({ const makeActions = useCallback( (group: Group): MenuAction[] => { - const directManagedRole = managedRoleById.get(group.id) - const entries = userScopedRoleEntries(group.id, [], scopedPolicies) - const effective = effectiveScopedRole(entries) + const directRole = roleById.get(group.id) return buildRoleActions({ name: group.displayName, - managedScope, - directManagedRole, - effective, - inheritedReason: 'Role is inherited from another scope; modify it there to revoke', + directRole, + effectiveRole: directRole, canEdit, isSelf: false, // a group is never the current user openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), - doRemove: () => updateManagedPolicy(deleteRole(group.id, managedPolicy)), + doRemove: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), }) }, - [ - managedRoleById, - managedPolicy, - updateManagedPolicy, - scopedPolicies, - managedScope, - canEdit, - ] + [roleById, siloPolicy, updatePolicy, canEdit] ) const columns = useColsWithActions(staticColumns, makeActions) @@ -175,9 +147,9 @@ export function AccessGroupsTab({ <> {sortedGroups.length === 0 ? :
    } {editingGroup && ( - setEditingGroup(null)} - policy={managedPolicy} + policy={siloPolicy} name={editingGroup.group.displayName} identityId={editingGroup.group.id} identityType="silo_group" @@ -188,7 +160,7 @@ export function AccessGroupsTab({ setSelectedGroup(null)} - scopedPolicies={scopedPolicies} + scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} /> )} diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index c6b9082805..041d5de810 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -6,31 +6,31 @@ * Copyright Oxide Computer Company */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo, useState, type ComponentType } from 'react' +import { useCallback, useMemo, useState } from 'react' import * as R from 'remeda' import { api, deleteRole, - effectiveScopedRole, + getEffectiveRole, q, + queryClient, roleOrder, rolesByIdFromPolicy, + useApiMutation, useGroupsByUserId, usePrefetchedQuery, - userScopedRoleEntries, - type AccessScope, - type Policy, + type Group, type RoleKey, - type ScopedPolicy, type User, } from '@oxide/api' import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { ListPlusCell } from '~/components/ListPlusCell' -import { type EditRoleModalProps } from '~/forms/access-util' +import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { useCurrentUser } from '~/hooks/use-current-user' +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' @@ -43,7 +43,7 @@ import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { buildRoleActions } from './roleActions' -import { useCanEditPolicy } from './use-can-edit-policy' +import { useCanEditSiloPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' // The API only sorts users by id, so fetch the full set and sort by name @@ -51,6 +51,7 @@ import { UserDetailsSideModal } from './UserDetailsSideModal' // that would have its tail dropped in (arbitrary) id order. const userListAll = q(api.userList, { query: { limit: ALL_ISH } }) const groupListAll = q(api.groupList, { query: { limit: ALL_ISH } }) +const policyView = q(api.policyView, {}) const colHelper = createColumnHelper() @@ -66,25 +67,36 @@ const EmptyState = () => ( ) -type EditingState = { user: User; defaultRole: RoleKey | undefined } - -type Props = { - /** Policies that contribute to a user's effective role on this page. */ - scopedPolicies: ScopedPolicy[] - /** Scope managed by this tab — its direct roles are assignable/removable. */ - managedScope: AccessScope - /** Modal for assigning/editing a role on the managed policy. */ - EditModal: ComponentType - /** Update the managed policy. Called when removing a role. */ - updateManagedPolicy: (newPolicy: Policy) => Promise +/** + * A user's effective silo role: the strongest of their direct assignment and + * any roles inherited from their groups. When no direct assignment covers the + * effective role, `viaGroups` lists the groups it comes from. + */ +function effectiveSiloRole( + userId: string, + userGroups: Group[], + roleById: Map +): { role: RoleKey; viaGroups: Group[] } | null { + const directRole = roleById.get(userId) + const groupEntries = userGroups.flatMap((group) => { + const role = roleById.get(group.id) + return role ? [{ group, role }] : [] + }) + const role = getEffectiveRole([ + ...(directRole ? [directRole] : []), + ...groupEntries.map((e) => e.role), + ]) + if (!role) return null + const directCovers = directRole && roleOrder[directRole] <= roleOrder[role] + const viaGroups = directCovers + ? [] + : groupEntries.filter((e) => roleOrder[e.role] <= roleOrder[role]).map((e) => e.group) + return { role, viaGroups } } -export function AccessUsersTab({ - scopedPolicies, - managedScope, - EditModal, - updateManagedPolicy, -}: Props) { +type EditingState = { user: User; defaultRole: RoleKey | undefined } + +export function AccessUsersTab() { const [selectedUser, setSelectedUser] = useState(null) const [editingUser, setEditingUser] = useState(null) @@ -97,14 +109,19 @@ export function AccessUsersTab({ const { data: groups } = usePrefetchedQuery(groupListAll) const groupsByUserId = useGroupsByUserId(groups.items) - // non-null: caller is responsible for including the managed scope - const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + const { data: siloPolicy } = usePrefetchedQuery(policyView) + const roleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - const managedRoleById = useMemo(() => rolesByIdFromPolicy(managedPolicy), [managedPolicy]) - - const canEdit = useCanEditPolicy(scopedPolicies, managedScope) + const canEdit = useCanEditSiloPolicy(siloPolicy) const { me } = useCurrentUser() + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Role removed' }) + }, + }) + const roleCol = useMemo( () => colHelper.display({ @@ -112,41 +129,15 @@ export function AccessUsersTab({ header: 'Role', cell: ({ row }) => { const userGroups = groupsByUserId.get(row.original.id) ?? [] - const entries = userScopedRoleEntries(row.original.id, userGroups, scopedPolicies) - const effective = effectiveScopedRole(entries) + const effective = effectiveSiloRole(row.original.id, userGroups, roleById) if (!effective) return - // show "via groups" tooltip when the displayed role+scope isn't - // covered by a direct assignment in that scope. (a direct project - // role doesn't suppress the tooltip if the displayed badge is the - // silo scope coming via a group, since silo wins ties.) - const displayedScopeHasDirect = entries.some( - (e) => - e.source.type === 'direct' && - e.scope === effective.scope && - roleOrder[e.roleName] <= roleOrder[effective.role] - ) - const viaGroupsMap = new Map() - if (!displayedScopeHasDirect) { - for (const e of entries) { - if ( - e.source.type === 'group' && - e.scope === effective.scope && - roleOrder[e.roleName] <= roleOrder[effective.role] - ) { - viaGroupsMap.set(e.source.group.id, e.source.group) - } - } - } - const viaGroups = [...viaGroupsMap.values()] return (
    - - {effective.scope}.{effective.role} - - {viaGroups.length > 0 && ( + silo.{effective.role} + {effective.viaGroups.length > 0 && ( via{' '} - {viaGroups.map((g, i) => ( + {effective.viaGroups.map((g, i) => ( {i > 0 && ', '} {g.displayName} @@ -158,7 +149,7 @@ export function AccessUsersTab({ ) }, }), - [groupsByUserId, scopedPolicies] + [groupsByUserId, roleById] ) const groupsCol = useMemo( @@ -199,35 +190,19 @@ export function AccessUsersTab({ const makeActions = useCallback( (user: User): MenuAction[] => { - const directManagedRole = managedRoleById.get(user.id) const userGroups = groupsByUserId.get(user.id) ?? [] - const entries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) - const effective = effectiveScopedRole(entries) - const inheritedReason = `Role is inherited; modify the source ${ - entries.find((e) => e.source.type === 'group') ? 'group' : 'silo assignment' - } to revoke` + const effective = effectiveSiloRole(user.id, userGroups, roleById) return buildRoleActions({ name: user.displayName, - managedScope, - directManagedRole, - effective, - inheritedReason, + directRole: roleById.get(user.id), + effectiveRole: effective?.role, canEdit, isSelf: user.id === me.id, openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), - doRemove: () => updateManagedPolicy(deleteRole(user.id, managedPolicy)), + doRemove: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), }) }, - [ - managedRoleById, - managedPolicy, - updateManagedPolicy, - groupsByUserId, - scopedPolicies, - managedScope, - canEdit, - me, - ] + [roleById, siloPolicy, updatePolicy, groupsByUserId, canEdit, me] ) const columns = useColsWithActions(staticColumns, makeActions) @@ -243,9 +218,9 @@ export function AccessUsersTab({ <> {sortedUsers.length === 0 ? :
    } {editingUser && ( - setEditingUser(null)} - policy={managedPolicy} + policy={siloPolicy} name={editingUser.user.displayName} identityId={editingUser.user.id} identityType="silo_user" @@ -256,7 +231,7 @@ export function AccessUsersTab({ setSelectedUser(null)} - scopedPolicies={scopedPolicies} + scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index d2372677e7..544130116e 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -68,79 +68,65 @@ export function buildRemoveRoleAction({ type BuildRoleActionsArgs = { /** Display name of the user or group, used in the confirm-delete copy. */ name: string - /** Scope managed by this tab; determines labels and project-specific framing. */ - managedScope: AccessScope - /** Direct role on the managed policy, if any. Required to remove a role. */ - directManagedRole: RoleKey | undefined - /** Effective role across all scopes, or null if the identity has no role. */ - effective: { role: RoleKey } | null - /** Disabled reason shown when there's no direct managed role to remove. */ - inheritedReason: string - /** Whether the current user can add/change/remove roles in the managed scope. */ + /** Direct role on the silo policy, if any. Required to remove a role. */ + directRole: RoleKey | undefined + /** Effective role including group-inherited, undefined if none. */ + effectiveRole: RoleKey | undefined + /** Whether the current user can add/change/remove silo roles. */ canEdit: boolean /** Whether this row is the current user (shows a self-removal warning). */ isSelf: boolean /** Open the edit modal, pre-filled with the given role (undefined = assign). */ openEditModal: (defaultRole: RoleKey | undefined) => void - /** Remove the direct managed role. */ + /** Remove the direct silo role. */ doRemove: () => Promise } /** - * Row-action menu for a user or group in the access tabs. Identical logic for - * both; callers supply how the effective role was computed and the - * inherited-role message (which differs because a user can inherit via a group - * or the silo, while a group only inherits from another scope). + * Row-action menu for a user or group in the silo users/groups tabs. For + * groups, direct and effective are the same role (groups don't inherit). For + * users, an inherited (via group) role can be promoted to a direct silo + * assignment via the change action, pre-filled with the effective role. */ export function buildRoleActions({ name, - managedScope, - directManagedRole, - effective, - inheritedReason, + directRole, + effectiveRole, canEdit, isSelf, openEditModal, doRemove, }: BuildRoleActionsArgs): MenuAction[] { - const addAction: MenuAction = { - label: roleActionLabel(managedScope, 'add'), - onActivate: () => openEditModal(undefined), - disabled: !canEdit && noRolePermissionReason(managedScope, 'add'), - } - const removeAction = buildRemoveRoleAction({ - name, - role: directManagedRole, - scope: managedScope, - isSelf, - disabledReason: !canEdit - ? noRolePermissionReason(managedScope, 'remove') - : // a direct role on the managed policy is required to remove anything - !directManagedRole - ? inheritedReason - : undefined, - doRemove, - }) // No role at all — direct or inherited. - if (!effective) { - return [addAction] - } - // For the project tab, an inherited silo role doesn't give us anything to - // "change" on the project policy — frame it as adding a project role. For - // the silo tab, an inherited (via group) role can be promoted to a direct - // silo assignment via the change action, pre-filled with the effective role. - if (managedScope === 'project' && !directManagedRole) { - return [addAction, removeAction] + if (!effectiveRole) { + return [ + { + label: roleActionLabel('silo', 'add'), + onActivate: () => openEditModal(undefined), + disabled: !canEdit && noRolePermissionReason('silo', 'add'), + }, + ] } - // Pre-fill with the direct managed role if any; otherwise the effective role - // so the modal opens in 'edit' mode showing the role currently in effect. - const defaultRole = directManagedRole ?? effective.role return [ { - label: roleActionLabel(managedScope, 'change'), - onActivate: () => openEditModal(defaultRole), - disabled: !canEdit && noRolePermissionReason(managedScope, 'change'), + label: roleActionLabel('silo', 'change'), + // pre-fill with the direct role if any; otherwise the effective role so + // the modal opens in 'edit' mode showing the role currently in effect + onActivate: () => openEditModal(directRole ?? effectiveRole), + disabled: !canEdit && noRolePermissionReason('silo', 'change'), }, - removeAction, + buildRemoveRoleAction({ + name, + role: directRole, + scope: 'silo', + isSelf, + disabledReason: !canEdit + ? noRolePermissionReason('silo', 'remove') + : // a direct role is required to remove anything + !directRole + ? "Role is inherited from a group; change the group's role to revoke" + : undefined, + doRemove, + }), ] } diff --git a/app/components/access/use-can-edit-policy.ts b/app/components/access/use-can-edit-policy.ts index 4c4337f374..621ffeb075 100644 --- a/app/components/access/use-can-edit-policy.ts +++ b/app/components/access/use-can-edit-policy.ts @@ -10,6 +10,7 @@ import { userScopedRoleEntries, type AccessScope, type FleetRolePolicy, + type Policy, type ScopedPolicy, } from '@oxide/api' @@ -43,6 +44,11 @@ export function useCanEditPolicy( siloRole === 'collaborator' } +/** `useCanEditPolicy` for the silo-only tabs, which have no scope machinery. */ +export function useCanEditSiloPolicy(siloPolicy: Policy): boolean { + return useCanEditPolicy([{ scope: 'silo', policy: siloPolicy }], 'silo') +} + /** * Whether the current user can add, change, or remove fleet role assignments. * Modifying the fleet policy requires the fleet admin role. Fleet is the diff --git a/app/pages/SiloGroupsTab.tsx b/app/pages/SiloGroupsTab.tsx index 215ed7f387..939ed6dc69 100644 --- a/app/pages/SiloGroupsTab.tsx +++ b/app/pages/SiloGroupsTab.tsx @@ -5,48 +5,9 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' - -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type Policy, - type ScopedPolicy, -} from '@oxide/api' - import { AccessGroupsTab } from '~/components/access/AccessGroupsTab' -import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' -import { addToast } from '~/stores/toast' - -const policyView = q(api.policyView, {}) export const handle = titleCrumb('Groups') -export default function SiloGroupsTab() { - const { data: siloPolicy } = usePrefetchedQuery(policyView) - - const scopedPolicies = useMemo( - () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], - [siloPolicy] - ) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - addToast({ content: 'Role removed' }) - }, - }) - - return ( - updatePolicy({ body })} - /> - ) -} +export default AccessGroupsTab diff --git a/app/pages/SiloUsersTab.tsx b/app/pages/SiloUsersTab.tsx index 00c0de4c91..6464b0d783 100644 --- a/app/pages/SiloUsersTab.tsx +++ b/app/pages/SiloUsersTab.tsx @@ -5,48 +5,9 @@ * * Copyright Oxide Computer Company */ -import { useMemo } from 'react' - -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type Policy, - type ScopedPolicy, -} from '@oxide/api' - import { AccessUsersTab } from '~/components/access/AccessUsersTab' -import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { titleCrumb } from '~/hooks/use-crumbs' -import { addToast } from '~/stores/toast' - -const policyView = q(api.policyView, {}) export const handle = titleCrumb('Users') -export default function SiloUsersTab() { - const { data: siloPolicy } = usePrefetchedQuery(policyView) - - const scopedPolicies = useMemo( - () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], - [siloPolicy] - ) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - addToast({ content: 'Role removed' }) - }, - }) - - return ( - updatePolicy({ body })} - /> - ) -} +export default AccessUsersTab From e52a6e1664d9be97df25998134e6d64d64618e99 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 12:02:00 -0400 Subject: [PATCH 37/45] delete unused effectiveScopedRole --- app/api/roles.spec.ts | 40 --------------------------- app/api/roles.ts | 18 ------------ app/components/access/roleActions.tsx | 2 +- 3 files changed, 1 insertion(+), 59 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index 144774e755..f560057c2d 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -11,14 +11,12 @@ import { allRoles, byGroupThenName, deleteRole, - effectiveScopedRole, getEffectiveRole, roleOrder, rolesByIdFromPolicy, updateRole, userScopedRoleEntries, type Policy, - type ScopedRoleEntry, } from './roles' describe('getEffectiveRole', () => { @@ -78,44 +76,6 @@ describe('deleteRole', () => { }) }) -describe('effectiveScopedRole', () => { - const direct = { type: 'direct' } as const - const viaGroup = { type: 'group', group: { id: 'g', displayName: 'g' } } as const - const entry = ( - roleName: ScopedRoleEntry['roleName'], - scope: ScopedRoleEntry['scope'], - source: ScopedRoleEntry['source'] = direct - ): ScopedRoleEntry => ({ roleName, scope, source }) - - it('returns null when there are no entries', () => { - expect(effectiveScopedRole([])).toBeNull() - }) - - it('picks the strongest role regardless of scope', () => { - expect( - effectiveScopedRole([entry('viewer', 'silo'), entry('admin', 'project')]) - ).toEqual({ role: 'admin', scope: 'project' }) - }) - - it('gives ties to silo scope, since silo roles cascade into projects', () => { - expect( - effectiveScopedRole([entry('collaborator', 'project'), entry('collaborator', 'silo')]) - ).toEqual({ role: 'collaborator', scope: 'silo' }) - }) - - it('gives ties to silo even if permission comes via a group', () => { - expect( - effectiveScopedRole([entry('admin', 'project'), entry('admin', 'silo', viaGroup)]) - ).toEqual({ role: 'admin', scope: 'silo' }) - }) - - it('keeps project scope when the project role is strictly stronger', () => { - expect( - effectiveScopedRole([entry('admin', 'project'), entry('viewer', 'silo')]) - ).toEqual({ role: 'admin', scope: 'project' }) - }) -}) - test('byGroupThenName sorts as expected', () => { const a = { identityType: 'silo_group' as const, name: 'a' } const b = { identityType: 'silo_group' as const, name: 'b' } diff --git a/app/api/roles.ts b/app/api/roles.ts index 56a8b96ed9..4973d01abe 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -199,24 +199,6 @@ export function userScopedRoleEntries( return entries } -/** - * Pick the strongest role across entries. Ties go to silo scope, since silo - * roles cascade into projects. - */ -export function effectiveScopedRole( - entries: ScopedRoleEntry[] -): { role: RoleKey; scope: AccessScope } | null { - if (entries.length === 0) return null - // strongest role overall - const strongest = R.firstBy(entries, (e) => roleOrder[e.roleName])! - const role = strongest.roleName - // prefer silo scope when silo has a role at least as strong - const siloDominates = entries.some( - (e) => e.scope === 'silo' && roleOrder[e.roleName] <= roleOrder[role] - ) - return { role, scope: siloDominates ? 'silo' : 'project' } -} - /** * Builds a map from user ID to the list of groups that user belongs to, * firing one query per group to fetch members. Shared between user tabs. diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index 544130116e..33b18801be 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -13,7 +13,7 @@ import { type MenuAction } from '~/table/columns/action-col' import { capitalize } from '~/util/str' /** Scopes whose role assignments are editable from a row action. */ -export type RoleScope = AccessScope | 'fleet' +type RoleScope = AccessScope | 'fleet' type RoleVerb = 'add' | 'change' | 'remove' /** Row-action label, scoped so it reads e.g. "Change project role". */ From 33f3cd998dd5aa35b7ec5bad4b0666c662c160cf Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 12:24:26 -0400 Subject: [PATCH 38/45] AccessRolesTable: replace local roleIn with rolesByIdFromPolicy --- app/components/access/AccessRolesTable.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index f72d735027..335e182379 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -12,9 +12,9 @@ import { api, byGroupThenName, deleteRole, - getEffectiveRole, q, roleOrder, + rolesByIdFromPolicy, useGroupsByUserId, usePrefetchedQuery, type AccessScope, @@ -116,10 +116,11 @@ export function AccessRolesTable({ const nameById = new Map( [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) ) - const roleIn = (policy: Policy, id: string) => - getEffectiveRole( - policy.roleAssignments.filter((ra) => ra.identityId === id).map((ra) => ra.roleName) - ) + const managedRoleById = rolesByIdFromPolicy(managedPolicy) + const scopedRolesById = scopedPolicies.map(({ scope, policy }) => ({ + scope, + roleById: rolesByIdFromPolicy(policy), + })) // an identity appears if it has a direct role in any of the scoped policies const identities = new Map() @@ -130,9 +131,9 @@ export function AccessRolesTable({ return [...identities.entries()] .map(([id, identityType]) => { - const roleBadges = scopedPolicies - .map(({ scope, policy }) => { - const roleName = roleIn(policy, id) + const roleBadges = scopedRolesById + .map(({ scope, roleById }) => { + const roleName = roleById.get(id) return roleName ? { scope, roleName } : undefined }) .filter((b) => !!b) @@ -142,7 +143,7 @@ export function AccessRolesTable({ id, identityType, name: nameById.get(id) ?? id, - managedRole: roleIn(managedPolicy, id), + managedRole: managedRoleById.get(id), roleBadges, } satisfies AccessRow }) From 09fbc17cab94d798d76cdfd8cb4644efad62cb95 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 13:34:24 -0400 Subject: [PATCH 39/45] remove role disable tooltip copy tweak --- app/components/access/roleActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index 33b18801be..ddefd1668f 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -124,7 +124,7 @@ export function buildRoleActions({ ? noRolePermissionReason('silo', 'remove') : // a direct role is required to remove anything !directRole - ? "Role is inherited from a group; change the group's role to revoke" + ? 'Role is inherited from a group; it can only be removed from the group.' : undefined, doRemove, }), From 289c20d0fbdd11b63341be20116991082116db96 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Jul 2026 12:46:41 -0700 Subject: [PATCH 40/45] Add modal to show all roles on hover on Users and Groups page --- app/api/roles.ts | 4 ++ app/components/access/AccessUsersTab.tsx | 85 ++++++++++-------------- app/components/access/DetailTables.tsx | 6 +- mock-api/role-assignment.ts | 11 +++ test/e2e/silo-access.e2e.ts | 17 +++++ 5 files changed, 69 insertions(+), 54 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 4973d01abe..270452c8b1 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -199,6 +199,10 @@ export function userScopedRoleEntries( return entries } +/** Sort role entries strongest first (see `roleOrder`), so the effective role leads. */ +export const sortRoleEntries = (entries: ScopedRoleEntry[]) => + R.sortBy(entries, (e) => roleOrder[e.roleName]) + /** * 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. diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index 041d5de810..c5efe1cc66 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -12,22 +12,23 @@ import * as R from 'remeda' import { api, deleteRole, - getEffectiveRole, q, queryClient, - roleOrder, rolesByIdFromPolicy, + sortRoleEntries, useApiMutation, useGroupsByUserId, usePrefetchedQuery, - type Group, + userScopedRoleEntries, type RoleKey, + type ScopedPolicy, + type ScopedRoleEntry, type User, } from '@oxide/api' import { Person24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ListPlusCell } from '~/components/ListPlusCell' +import { ListPlusCell, ListPlusOverflow } from '~/components/ListPlusCell' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { useCurrentUser } from '~/hooks/use-current-user' import { addToast } from '~/stores/toast' @@ -38,7 +39,6 @@ import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -67,32 +67,9 @@ const EmptyState = () => ( ) -/** - * A user's effective silo role: the strongest of their direct assignment and - * any roles inherited from their groups. When no direct assignment covers the - * effective role, `viaGroups` lists the groups it comes from. - */ -function effectiveSiloRole( - userId: string, - userGroups: Group[], - roleById: Map -): { role: RoleKey; viaGroups: Group[] } | null { - const directRole = roleById.get(userId) - const groupEntries = userGroups.flatMap((group) => { - const role = roleById.get(group.id) - return role ? [{ group, role }] : [] - }) - const role = getEffectiveRole([ - ...(directRole ? [directRole] : []), - ...groupEntries.map((e) => e.role), - ]) - if (!role) return null - const directCovers = directRole && roleOrder[directRole] <= roleOrder[role] - const viaGroups = directCovers - ? [] - : groupEntries.filter((e) => roleOrder[e.role] <= roleOrder[role]).map((e) => e.group) - return { role, viaGroups } -} +/** How a user came to hold a role, shown as the source in the "other roles" tooltip. */ +const sourceLabel = (source: ScopedRoleEntry['source']) => + source.type === 'direct' ? 'Assigned' : `via ${source.group.displayName}` type EditingState = { user: User; defaultRole: RoleKey | undefined } @@ -111,6 +88,11 @@ export function AccessUsersTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const roleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) + // silo is the only scope on this page, but userScopedRoleEntries takes a list + const scopedPolicies = useMemo( + () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], + [siloPolicy] + ) const canEdit = useCanEditSiloPolicy(siloPolicy) const { me } = useCurrentUser() @@ -129,27 +111,28 @@ export function AccessUsersTab() { header: 'Role', cell: ({ row }) => { const userGroups = groupsByUserId.get(row.original.id) ?? [] - const effective = effectiveSiloRole(row.original.id, userGroups, roleById) - if (!effective) return + const entries = sortRoleEntries( + userScopedRoleEntries(row.original.id, userGroups, scopedPolicies) + ) + if (entries.length === 0) return + // strongest is the effective role; the rest go in the +N tooltip + const [effective, ...rest] = entries return ( -
    - silo.{effective.role} - {effective.viaGroups.length > 0 && ( - - via{' '} - {effective.viaGroups.map((g, i) => ( - - {i > 0 && ', '} - {g.displayName} - - ))} - - )} +
    + silo.{effective.roleName} + + {rest.map((entry, i) => ( +
    + silo.{entry.roleName} + {sourceLabel(entry.source)} +
    + ))} +
    ) }, }), - [groupsByUserId, roleById] + [groupsByUserId, scopedPolicies] ) const groupsCol = useMemo( @@ -191,18 +174,20 @@ export function AccessUsersTab() { const makeActions = useCallback( (user: User): MenuAction[] => { const userGroups = groupsByUserId.get(user.id) ?? [] - const effective = effectiveSiloRole(user.id, userGroups, roleById) + const entries = sortRoleEntries( + userScopedRoleEntries(user.id, userGroups, scopedPolicies) + ) return buildRoleActions({ name: user.displayName, directRole: roleById.get(user.id), - effectiveRole: effective?.role, + effectiveRole: entries[0]?.roleName, canEdit, isSelf: user.id === me.id, openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), doRemove: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), }) }, - [roleById, siloPolicy, updatePolicy, groupsByUserId, canEdit, me] + [roleById, siloPolicy, updatePolicy, groupsByUserId, scopedPolicies, canEdit, me] ) const columns = useColsWithActions(staticColumns, makeActions) diff --git a/app/components/access/DetailTables.tsx b/app/components/access/DetailTables.tsx index ee3d42ea85..fef3b3d98f 100644 --- a/app/components/access/DetailTables.tsx +++ b/app/components/access/DetailTables.tsx @@ -5,9 +5,7 @@ * * Copyright Oxide Computer Company */ -import * as R from 'remeda' - -import { roleOrder, type ScopedRoleEntry } from '@oxide/api' +import { sortRoleEntries, type ScopedRoleEntry } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' import { RowActions } from '~/table/columns/action-col' @@ -20,7 +18,7 @@ import { roleColor } from '~/util/access' * entries are all direct). Sorted strongest-first. */ export function RoleAssignmentsTable({ entries }: { entries: ScopedRoleEntry[] }) { - const sorted = R.sortBy(entries, (e) => roleOrder[e.roleName]) + const sorted = sortRoleEntries(entries) return (
    diff --git a/mock-api/role-assignment.ts b/mock-api/role-assignment.ts index 933f02b246..831d301664 100644 --- a/mock-api/role-assignment.ts +++ b/mock-api/role-assignment.ts @@ -87,6 +87,17 @@ export const roleAssignments: DbRoleAssignment[] = [ identity_type: 'silo_user', role_name: 'admin', }, + { + // Jane also inherits the stronger silo.collaborator via real-estate-devs, so + // she has both a direct and a group-derived role (exercises the "+N" roles + // display on the Users tab). Kept below admin so she stays a non-silo-admin, + // which other tests rely on. + resource_type: 'silo', + resource_id: defaultSilo.id, + identity_id: user5.id, // Jane Austen + identity_type: 'silo_user', + role_name: 'viewer', + }, { resource_type: 'project', resource_id: project.id, diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index d7371fb47c..0ef7d2510e 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -144,6 +144,23 @@ test('Users & Groups page lands on Users tab; shows direct + via-group silo role // Jacob Klein has no silo role and no groups await expectRowVisible(table, { Name: 'Jacob Klein', Role: '—', Groups: '—' }) + // Jane Austen has a direct silo.viewer role but inherits the stronger + // silo.collaborator via real-estate-devs, so her effective role is + // collaborator with a "+1" revealing the other role + await expectRowVisible(table, { + Name: 'Jane Austen', + Role: 'silo.collaborator+1', + Groups: 'real-estate-devs', + }) + + // hovering the +1 lists the other role and where it comes from + const janeRow = table.getByRole('row', { name: 'Jane Austen', exact: false }) + await janeRow.getByText('+1').hover() + const tooltip = page.getByRole('tooltip') + await expect(tooltip).toContainText('Other roles') + await expect(tooltip).toContainText('silo.viewer') + await expect(tooltip).toContainText('Assigned') + // Groups tab comes second await page.getByRole('tab', { name: 'Groups' }).click() await expect(page).toHaveURL(/\/groups$/) From 374ae04e8356a92b5fef1aba4fd2bbb5cf3de9a4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 14:00:22 -0400 Subject: [PATCH 41/45] drop ScopedPolicy[] in favor of explicit siloPolicy/projectPolicy args --- app/api/roles.spec.ts | 13 +--- app/api/roles.ts | 37 +++++++---- app/components/access/AccessGroupsTab.tsx | 2 +- app/components/access/AccessRolesTable.tsx | 64 ++++++++++--------- app/components/access/AccessUsersTab.tsx | 2 +- .../access/GroupMembersSideModal.tsx | 14 ++-- .../access/UserDetailsSideModal.tsx | 10 +-- app/components/access/use-can-edit-policy.ts | 27 ++++---- app/pages/SiloAccessPage.tsx | 19 +----- .../project/access/ProjectAccessPage.tsx | 16 +---- 10 files changed, 96 insertions(+), 108 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index f560057c2d..53d72d4649 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -108,7 +108,7 @@ describe('userScopedRoleEntries', () => { { identityId: 'u', identityType: 'silo_user', roleName: 'admin' }, ], } - expect(userScopedRoleEntries('u', [], [{ scope: 'silo', policy }])).toEqual([ + expect(userScopedRoleEntries('u', [], policy)).toEqual([ { roleName: 'admin', scope: 'silo', source: { type: 'direct' } }, ]) }) @@ -123,16 +123,7 @@ describe('userScopedRoleEntries', () => { const project: Policy = { roleAssignments: [{ identityId: 'u', identityType: 'silo_user', roleName: 'admin' }], } - expect( - userScopedRoleEntries( - 'u', - [group], - [ - { scope: 'silo', policy: silo }, - { scope: 'project', policy: project }, - ] - ) - ).toEqual([ + expect(userScopedRoleEntries('u', [group], silo, project)).toEqual([ { roleName: 'viewer', scope: 'silo', source: { type: 'group', group } }, { roleName: 'admin', scope: 'project', source: { type: 'direct' } }, ]) diff --git a/app/api/roles.ts b/app/api/roles.ts index 270452c8b1..9b6f0936a9 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -161,7 +161,6 @@ export function useActorsNotInPolicy( } export type AccessScope = 'silo' | 'project' -export type ScopedPolicy = { scope: AccessScope; policy: Policy } export type ScopedRoleEntry = { roleName: RoleKey @@ -180,20 +179,32 @@ export type ScopedRoleEntry = { export function userScopedRoleEntries( userId: string, userGroups: { id: string; displayName: string }[], - scopedPolicies: ScopedPolicy[] + siloPolicy: Policy, + projectPolicy?: Policy ): ScopedRoleEntry[] { + const entries = policyRoleEntries('silo', siloPolicy, userId, userGroups) + if (projectPolicy) { + entries.push(...policyRoleEntries('project', projectPolicy, userId, userGroups)) + } + return entries +} + +function policyRoleEntries( + scope: AccessScope, + policy: Policy, + userId: string, + userGroups: { id: string; displayName: string }[] +): ScopedRoleEntry[] { + const roleById = rolesByIdFromPolicy(policy) const entries: ScopedRoleEntry[] = [] - for (const { scope, policy } of scopedPolicies) { - const roleById = rolesByIdFromPolicy(policy) - const direct = roleById.get(userId) - if (direct) { - entries.push({ roleName: direct, scope, source: { type: 'direct' } }) - } - for (const group of userGroups) { - const via = roleById.get(group.id) - if (via) { - entries.push({ roleName: via, scope, source: { type: 'group', group } }) - } + const direct = roleById.get(userId) + if (direct) { + entries.push({ roleName: direct, scope, source: { type: 'direct' } }) + } + for (const group of userGroups) { + const via = roleById.get(group.id) + if (via) { + entries.push({ roleName: via, scope, source: { type: 'group', group } }) } } return entries diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 92b74968ce..3c52a9cd93 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -160,7 +160,7 @@ export function AccessGroupsTab() { setSelectedGroup(null)} - scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} + siloPolicy={siloPolicy} /> )} diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index 335e182379..d5f5383d5d 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -22,7 +22,6 @@ import { type IdentityType, type Policy, type RoleKey, - type ScopedPolicy, type User, } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' @@ -68,10 +67,14 @@ type AccessRow = { const colHelper = createColumnHelper() type Props = { - /** Policies that contribute to an identity's effective role on this page. */ - scopedPolicies: ScopedPolicy[] - /** Scope managed by this page — its direct roles are editable/removable. */ - managedScope: AccessScope + /** Silo roles contribute to an identity's effective role on both pages. */ + siloPolicy: Policy + /** + * Present on the Project Access page only. The managed scope — the one whose + * direct roles are editable/removable — is the project when this is given, + * otherwise the silo. + */ + projectPolicy?: Policy /** Modal for editing a role on the managed policy. */ EditModal: ComponentType /** Update the managed policy. Called when removing a role. */ @@ -86,8 +89,8 @@ type Props = { * in the managed scope. Shared by the Silo Access and Project Access pages. */ export function AccessRolesTable({ - scopedPolicies, - managedScope, + siloPolicy, + projectPolicy, EditModal, updateManagedPolicy, onAddClick, @@ -107,37 +110,36 @@ export function AccessRolesTable({ const usersById = useMemo(() => new Map(users.items.map((u) => [u.id, u])), [users]) const groupsById = useMemo(() => new Map(groups.items.map((g) => [g.id, g])), [groups]) - // non-null: caller is responsible for including the managed scope - const managedPolicy = scopedPolicies.find((sp) => sp.scope === managedScope)!.policy + const managedScope: AccessScope = projectPolicy ? 'project' : 'silo' + const managedPolicy = projectPolicy ?? siloPolicy - const canEditRoles = useCanEditPolicy(scopedPolicies, managedScope) + const canEditRoles = useCanEditPolicy(siloPolicy, projectPolicy) const rows = useMemo(() => { const nameById = new Map( [...users.items, ...groups.items].map((u) => [u.id, u.displayName]) ) - const managedRoleById = rolesByIdFromPolicy(managedPolicy) - const scopedRolesById = scopedPolicies.map(({ scope, policy }) => ({ - scope, - roleById: rolesByIdFromPolicy(policy), - })) + const siloRoleById = rolesByIdFromPolicy(siloPolicy) + const projectRoleById = projectPolicy && rolesByIdFromPolicy(projectPolicy) + const managedRoleById = projectRoleById ?? siloRoleById - // an identity appears if it has a direct role in any of the scoped policies + // an identity appears if it has a direct role in either policy const identities = new Map() - for (const { policy } of scopedPolicies) { - for (const ra of policy.roleAssignments) - identities.set(ra.identityId, ra.identityType) + for (const ra of siloPolicy.roleAssignments) { + identities.set(ra.identityId, ra.identityType) + } + for (const ra of projectPolicy?.roleAssignments ?? []) { + identities.set(ra.identityId, ra.identityType) } return [...identities.entries()] .map(([id, identityType]) => { - const roleBadges = scopedRolesById - .map(({ scope, roleById }) => { - const roleName = roleById.get(id) - return roleName ? { scope, roleName } : undefined - }) - .filter((b) => !!b) - .sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) // strongest first + const roleBadges: AccessRow['roleBadges'] = [] + const siloRole = siloRoleById.get(id) + if (siloRole) roleBadges.push({ scope: 'silo', roleName: siloRole }) + const projectRole = projectRoleById?.get(id) + if (projectRole) roleBadges.push({ scope: 'project', roleName: projectRole }) + roleBadges.sort((a, b) => roleOrder[a.roleName] - roleOrder[b.roleName]) // strongest first return { id, @@ -148,9 +150,9 @@ export function AccessRolesTable({ } satisfies AccessRow }) .sort(byGroupThenName) - }, [scopedPolicies, managedPolicy, users, groups]) + }, [siloPolicy, projectPolicy, users, groups]) - const multiScope = scopedPolicies.length > 1 + const multiScope = !!projectPolicy const columns = useMemo( () => [ @@ -272,7 +274,8 @@ export function AccessRolesTable({ setSelectedUser(null)} - scopedPolicies={scopedPolicies} + siloPolicy={siloPolicy} + projectPolicy={projectPolicy} userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} @@ -280,7 +283,8 @@ export function AccessRolesTable({ setSelectedGroup(null)} - scopedPolicies={scopedPolicies} + siloPolicy={siloPolicy} + projectPolicy={projectPolicy} /> )} {rows.length === 0 ? ( diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index c5efe1cc66..e8c953c889 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -216,7 +216,7 @@ export function AccessUsersTab() { setSelectedUser(null)} - scopedPolicies={[{ scope: 'silo', policy: siloPolicy }]} + siloPolicy={siloPolicy} userGroups={groupsByUserId.get(selectedUser.id) ?? []} /> )} diff --git a/app/components/access/GroupMembersSideModal.tsx b/app/components/access/GroupMembersSideModal.tsx index 7db1c2d174..f3d4b93aa2 100644 --- a/app/components/access/GroupMembersSideModal.tsx +++ b/app/components/access/GroupMembersSideModal.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' -import { api, q, userScopedRoleEntries, type Group, type ScopedPolicy } from '@oxide/api' +import { api, q, userScopedRoleEntries, type Group, type Policy } from '@oxide/api' import { PersonGroup16Icon } from '@oxide/design-system/icons/react' import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' @@ -20,15 +20,21 @@ import { IdentityListTable, RoleAssignmentsTable } from './DetailTables' type Props = { group: Group onDismiss: () => void - scopedPolicies: ScopedPolicy[] + siloPolicy: Policy + projectPolicy?: Policy } -export function GroupMembersSideModal({ group, onDismiss, scopedPolicies }: Props) { +export function GroupMembersSideModal({ + group, + onDismiss, + siloPolicy, + projectPolicy, +}: Props) { const { data } = useQuery(q(api.userList, { query: { group: group.id, limit: ALL_ISH } })) const members = data?.items ?? [] // groups never inherit, so passing no groups yields the group's direct roles - const roleEntries = userScopedRoleEntries(group.id, [], scopedPolicies) + const roleEntries = userScopedRoleEntries(group.id, [], siloPolicy, projectPolicy) return ( void userGroups: Group[] - scopedPolicies: ScopedPolicy[] + siloPolicy: Policy + projectPolicy?: Policy } export function UserDetailsSideModal({ user, onDismiss, userGroups, - scopedPolicies, + siloPolicy, + projectPolicy, }: Props) { - const roleEntries = userScopedRoleEntries(user.id, userGroups, scopedPolicies) + const roleEntries = userScopedRoleEntries(user.id, userGroups, siloPolicy, projectPolicy) return ( getEffectiveRole(entries.filter((e) => e.scope === scope).map((e) => e.roleName)) const siloRole = roleInScope('silo') - return managedScope === 'silo' - ? siloRole === 'admin' - : roleInScope('project') === 'admin' || + return projectPolicy + ? roleInScope('project') === 'admin' || siloRole === 'admin' || siloRole === 'collaborator' + : siloRole === 'admin' } -/** `useCanEditPolicy` for the silo-only tabs, which have no scope machinery. */ +/** `useCanEditPolicy` for the silo-only tabs, named for symmetry with fleet. */ export function useCanEditSiloPolicy(siloPolicy: Policy): boolean { - return useCanEditPolicy([{ scope: 'silo', policy: siloPolicy }], 'silo') + return useCanEditPolicy(siloPolicy) } /** diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 670e8e2e57..c0576ca684 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -5,16 +5,9 @@ * * Copyright Oxide Computer Company */ -import { useMemo, useState } from 'react' +import { useState } from 'react' -import { - api, - q, - queryClient, - useApiMutation, - usePrefetchedQuery, - type ScopedPolicy, -} from '@oxide/api' +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { AccessRolesTable } from '~/components/access/AccessRolesTable' @@ -57,11 +50,6 @@ export default function SiloAccessPage() { const { data: siloPolicy } = usePrefetchedQuery(policyView) - const scopedPolicies = useMemo( - () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], - [siloPolicy] - ) - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') @@ -99,8 +87,7 @@ export default function SiloAccessPage() { /> )} updatePolicy({ body })} onAddClick={() => setAddModalOpen(true)} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index c5780828df..b7fc072f4b 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useMemo, useState } from 'react' +import { useState } from 'react' import type { LoaderFunctionArgs } from 'react-router' import { @@ -15,7 +15,6 @@ import { useApiMutation, usePrefetchedQuery, type Policy, - type ScopedPolicy, } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' @@ -65,15 +64,6 @@ export default function ProjectAccessPage() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) - const scopedPolicies = useMemo( - () => - [ - { scope: 'silo', policy: siloPolicy }, - { scope: 'project', policy: projectPolicy }, - ] satisfies ScopedPolicy[], - [siloPolicy, projectPolicy] - ) - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') @@ -111,8 +101,8 @@ export default function ProjectAccessPage() { /> )} updatePolicy({ path: { project: projectSelector.project }, body }) From 81ef513eab603ca91ff32406d2da5afbf7aef9c0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 14:44:38 -0400 Subject: [PATCH 42/45] simplify role actions --- app/components/access/AccessGroupsTab.tsx | 26 ++-- app/components/access/AccessRolesTable.tsx | 29 ++-- app/components/access/AccessUsersTab.tsx | 47 ++++--- app/components/access/roleActions.tsx | 153 +++++++-------------- app/pages/system/FleetAccessPage.tsx | 35 ++--- test/e2e/project-access.e2e.ts | 2 +- 6 files changed, 117 insertions(+), 175 deletions(-) diff --git a/app/components/access/AccessGroupsTab.tsx b/app/components/access/AccessGroupsTab.tsx index 3c52a9cd93..0ea92e80c9 100644 --- a/app/components/access/AccessGroupsTab.tsx +++ b/app/components/access/AccessGroupsTab.tsx @@ -37,7 +37,7 @@ import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' -import { buildRoleActions } from './roleActions' +import { roleActions } from './roleActions' import { useCanEditSiloPolicy } from './use-can-edit-policy' // The API only sorts groups by id, so fetch the full set and sort by name @@ -88,7 +88,7 @@ export function AccessGroupsTab() { id: 'role', header: 'Role', cell: ({ row }) => { - // groups never inherit, so their only silo role is a direct one + // groups can't be in other groups, so their only silo role is a direct one const role = roleById.get(row.original.id) if (!role) return return silo.{role} @@ -121,15 +121,19 @@ export function AccessGroupsTab() { const makeActions = useCallback( (group: Group): MenuAction[] => { const directRole = roleById.get(group.id) - return buildRoleActions({ - name: group.displayName, - directRole, - effectiveRole: directRole, - canEdit, - isSelf: false, // a group is never the current user - openEditModal: (defaultRole) => setEditingGroup({ group, defaultRole }), - doRemove: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), - }) + const actions = roleActions('silo', canEdit) + // Groups do not inherit silo roles, so no direct role means no role at all. + if (!directRole) { + return [actions.add(() => setEditingGroup({ group, defaultRole: undefined }))] + } + return [ + actions.change(() => setEditingGroup({ group, defaultRole: directRole })), + actions.remove({ + name: group.displayName, + directRole, + doRemove: () => updatePolicy({ body: deleteRole(group.id, siloPolicy) }), + }), + ] }, [roleById, siloPolicy, updatePolicy, canEdit] ) diff --git a/app/components/access/AccessRolesTable.tsx b/app/components/access/AccessRolesTable.tsx index d5f5383d5d..62755f7053 100644 --- a/app/components/access/AccessRolesTable.tsx +++ b/app/components/access/AccessRolesTable.tsx @@ -41,11 +41,7 @@ import { identityTypeLabel, roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' import { GroupMembersSideModal } from './GroupMembersSideModal' -import { - buildRemoveRoleAction, - noRolePermissionReason, - roleActionLabel, -} from './roleActions' +import { roleActions } from './roleActions' import { useCanEditPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' @@ -214,24 +210,17 @@ export function AccessRolesTable({ // show on the project page) without a direct role in the managed scope. // There's nothing to change or remove in that case, but a managed-scope // role can still be added. - const editVerb = row.managedRole ? 'change' : 'add' + const actions = roleActions(managedScope, canEditRoles) + const editAction = row.managedRole + ? actions.change(() => setEditing({ row, defaultRole: row.managedRole })) + : actions.add(() => setEditing({ row, defaultRole: row.managedRole })) return [ - { - label: roleActionLabel(managedScope, editVerb), - onActivate: () => setEditing({ row, defaultRole: row.managedRole }), - disabled: !canEditRoles && noRolePermissionReason(managedScope, editVerb), - }, - buildRemoveRoleAction({ + editAction, + actions.remove({ name: row.name, - role: row.managedRole, - scope: managedScope, + directRole: row.managedRole, isSelf: row.id === me.id, - disabledReason: !canEditRoles - ? noRolePermissionReason(managedScope, 'remove') - : // no direct role in this scope to remove — it's inherited from the silo - !row.managedRole - ? 'This role is inherited from the silo' - : undefined, + inheritedReason: 'This role comes from the silo policy', doRemove: () => updateManagedPolicy(deleteRole(row.id, managedPolicy)), }), ] diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index e8c953c889..09cbd823e1 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -21,7 +21,6 @@ import { usePrefetchedQuery, userScopedRoleEntries, type RoleKey, - type ScopedPolicy, type ScopedRoleEntry, type User, } from '@oxide/api' @@ -42,7 +41,7 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' -import { buildRoleActions } from './roleActions' +import { roleActions } from './roleActions' import { useCanEditSiloPolicy } from './use-can-edit-policy' import { UserDetailsSideModal } from './UserDetailsSideModal' @@ -88,11 +87,6 @@ export function AccessUsersTab() { const { data: siloPolicy } = usePrefetchedQuery(policyView) const roleById = useMemo(() => rolesByIdFromPolicy(siloPolicy), [siloPolicy]) - // silo is the only scope on this page, but userScopedRoleEntries takes a list - const scopedPolicies = useMemo( - () => [{ scope: 'silo', policy: siloPolicy }] satisfies ScopedPolicy[], - [siloPolicy] - ) const canEdit = useCanEditSiloPolicy(siloPolicy) const { me } = useCurrentUser() @@ -112,7 +106,7 @@ export function AccessUsersTab() { cell: ({ row }) => { const userGroups = groupsByUserId.get(row.original.id) ?? [] const entries = sortRoleEntries( - userScopedRoleEntries(row.original.id, userGroups, scopedPolicies) + userScopedRoleEntries(row.original.id, userGroups, siloPolicy) ) if (entries.length === 0) return // strongest is the effective role; the rest go in the +N tooltip @@ -132,7 +126,7 @@ export function AccessUsersTab() { ) }, }), - [groupsByUserId, scopedPolicies] + [groupsByUserId, siloPolicy] ) const groupsCol = useMemo( @@ -174,20 +168,33 @@ export function AccessUsersTab() { const makeActions = useCallback( (user: User): MenuAction[] => { const userGroups = groupsByUserId.get(user.id) ?? [] + const directRole = roleById.get(user.id) const entries = sortRoleEntries( - userScopedRoleEntries(user.id, userGroups, scopedPolicies) + userScopedRoleEntries(user.id, userGroups, siloPolicy) ) - return buildRoleActions({ - name: user.displayName, - directRole: roleById.get(user.id), - effectiveRole: entries[0]?.roleName, - canEdit, - isSelf: user.id === me.id, - openEditModal: (defaultRole) => setEditingUser({ user, defaultRole }), - doRemove: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), - }) + const effectiveRole = entries[0]?.roleName + const actions = roleActions('silo', canEdit) + // No role at all, either direct or inherited. + if (!effectiveRole) { + return [actions.add(() => setEditingUser({ user, defaultRole: undefined }))] + } + return [ + // An inherited role can be promoted to a direct assignment; prefill the + // modal with the role the user already has. + actions.change(() => + setEditingUser({ user, defaultRole: directRole ?? effectiveRole }) + ), + actions.remove({ + name: user.displayName, + directRole, + isSelf: user.id === me.id, + inheritedReason: + 'Role is inherited from a group; it can only be removed from the group.', + doRemove: () => updatePolicy({ body: deleteRole(user.id, siloPolicy) }), + }), + ] }, - [roleById, siloPolicy, updatePolicy, groupsByUserId, scopedPolicies, canEdit, me] + [roleById, siloPolicy, updatePolicy, groupsByUserId, canEdit, me] ) const columns = useColsWithActions(staticColumns, makeActions) diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index ddefd1668f..97e9217875 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -17,116 +17,67 @@ type RoleScope = AccessScope | 'fleet' type RoleVerb = 'add' | 'change' | 'remove' /** Row-action label, scoped so it reads e.g. "Change project role". */ -export const roleActionLabel = (scope: RoleScope, verb: RoleVerb) => +const roleActionLabel = (scope: RoleScope, verb: RoleVerb) => `${capitalize(verb)} ${scope} role` /** Disabled-action reason shown when the user can't edit roles in this scope. */ -export const noRolePermissionReason = (scope: RoleScope, verb: RoleVerb) => +const noRolePermissionReason = (scope: RoleScope, verb: RoleVerb) => `You don't have permission to ${verb} ${scope} roles` /** - * The "Remove role" row action: a destructive confirm-delete with the standard - * role-assignment copy and a self-removal warning. `disabledReason` is the - * caller's computed reason (undefined when enabled), because what makes removal - * unavailable differs by surface — no edit permission, or an inherited-only role - * that must be changed at its source. + * Builds consistently labeled actions for one scope. Callers choose add versus + * change because the meaning of direct and inherited roles varies by surface. */ -export function buildRemoveRoleAction({ - name, - role, - scope, - isSelf, - disabledReason, - doRemove, -}: { - name: string - role: RoleKey | undefined - scope: RoleScope - isSelf: boolean - disabledReason: string | undefined - doRemove: () => Promise -}): MenuAction { - return { - // renamed from "Delete", so the auto destructive styling (keyed on the label - // "delete") no longer applies — set it explicitly - label: roleActionLabel(scope, 'remove'), - className: 'destructive', - onActivate: confirmDelete({ - doDelete: doRemove, - label: ( - - the {role} role for {name} - - ), - resourceKind: 'role assignment', - extraContent: isSelf ? `This will remove your own ${scope} access.` : undefined, - }), - disabled: disabledReason, - } -} +export const roleActions = (scope: RoleScope, canEdit: boolean) => { + const edit = (verb: Exclude, onActivate: () => void): MenuAction => ({ + label: roleActionLabel(scope, verb), + onActivate, + disabled: !canEdit && noRolePermissionReason(scope, verb), + }) -type BuildRoleActionsArgs = { - /** Display name of the user or group, used in the confirm-delete copy. */ - name: string - /** Direct role on the silo policy, if any. Required to remove a role. */ - directRole: RoleKey | undefined - /** Effective role including group-inherited, undefined if none. */ - effectiveRole: RoleKey | undefined - /** Whether the current user can add/change/remove silo roles. */ - canEdit: boolean - /** Whether this row is the current user (shows a self-removal warning). */ - isSelf: boolean - /** Open the edit modal, pre-filled with the given role (undefined = assign). */ - openEditModal: (defaultRole: RoleKey | undefined) => void - /** Remove the direct silo role. */ - doRemove: () => Promise -} + return { + add: (onActivate: () => void) => edit('add', onActivate), + change: (onActivate: () => void) => edit('change', onActivate), -/** - * Row-action menu for a user or group in the silo users/groups tabs. For - * groups, direct and effective are the same role (groups don't inherit). For - * users, an inherited (via group) role can be promoted to a direct silo - * assignment via the change action, pre-filled with the effective role. - */ -export function buildRoleActions({ - name, - directRole, - effectiveRole, - canEdit, - isSelf, - openEditModal, - doRemove, -}: BuildRoleActionsArgs): MenuAction[] { - // No role at all — direct or inherited. - if (!effectiveRole) { - return [ - { - label: roleActionLabel('silo', 'add'), - onActivate: () => openEditModal(undefined), - disabled: !canEdit && noRolePermissionReason('silo', 'add'), - }, - ] - } - return [ - { - label: roleActionLabel('silo', 'change'), - // pre-fill with the direct role if any; otherwise the effective role so - // the modal opens in 'edit' mode showing the role currently in effect - onActivate: () => openEditModal(directRole ?? effectiveRole), - disabled: !canEdit && noRolePermissionReason('silo', 'change'), - }, - buildRemoveRoleAction({ + /** A destructive remove action with standard confirmation copy. */ + remove({ name, - role: directRole, - scope: 'silo', - isSelf, - disabledReason: !canEdit - ? noRolePermissionReason('silo', 'remove') - : // a direct role is required to remove anything - !directRole - ? 'Role is inherited from a group; it can only be removed from the group.' - : undefined, + directRole, + isSelf = false, + inheritedReason, doRemove, - }), - ] + }: { + name: string + /** Direct assignment to remove, absent when the displayed role is inherited. */ + directRole: RoleKey | undefined + isSelf?: boolean + /** Explains where an inherited role must be removed instead. */ + inheritedReason?: string + doRemove: () => Promise + }): MenuAction { + return { + // the action is not named "Delete", so set destructive styling explicitly + label: roleActionLabel(scope, 'remove'), + className: 'destructive', + onActivate: confirmDelete({ + doDelete: doRemove, + label: ( + + the {directRole} role for {name} + + ), + resourceKind: 'role assignment', + extraContent: isSelf ? `This will remove your own ${scope} access.` : undefined, + }), + disabled: !canEdit + ? noRolePermissionReason(scope, 'remove') + : // if role is not direct, it is inherited from a silo or group, so + // we use inheritedReason to direct them to the appropriate spot to + // remove it + !directRole + ? inheritedReason + : undefined, + } + }, + } } diff --git a/app/pages/system/FleetAccessPage.tsx b/app/pages/system/FleetAccessPage.tsx index 92e50142e6..3d66a915a5 100644 --- a/app/pages/system/FleetAccessPage.tsx +++ b/app/pages/system/FleetAccessPage.tsx @@ -25,11 +25,7 @@ import { import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { - buildRemoveRoleAction, - noRolePermissionReason, - roleActionLabel, -} from '~/components/access/roleActions' +import { roleActions } from '~/components/access/roleActions' import { useCanEditFleetPolicy } from '~/components/access/use-can-edit-policy' import { DocsPopover } from '~/components/DocsPopover' import { @@ -235,23 +231,18 @@ export default function FleetAccessPage() { onActivate: () => navigate(pb.siloFleetRoles({ silo: row.siloName })), }, ]) - .with({ kind: 'assignment' }, (row) => [ - { - label: roleActionLabel('fleet', 'change'), - onActivate: () => setEditingUserRow(row), - disabled: !canEditRoles && noRolePermissionReason('fleet', 'change'), - }, - buildRemoveRoleAction({ - name: row.name, - role: row.fleetRole, - scope: 'fleet', - isSelf: row.id === me.id, - disabledReason: canEditRoles - ? undefined - : noRolePermissionReason('fleet', 'remove'), - doRemove: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), - }), - ]) + .with({ kind: 'assignment' }, (row) => { + const actions = roleActions('fleet', canEditRoles) + return [ + actions.change(() => setEditingUserRow(row)), + actions.remove({ + name: row.name, + directRole: row.fleetRole, + isSelf: row.id === me.id, + doRemove: () => updatePolicy({ body: deleteRole(row.id, fleetPolicy) }), + }), + ] + }) .exhaustive() ), ], diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 427f9d20d8..0a772e5ec7 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -111,7 +111,7 @@ test('Inherited-only row offers Add project role, not a disabled Change', async const removeItem = page.getByRole('menuitem', { name: 'Remove project role' }) await expect(removeItem).toBeDisabled() await removeItem.hover() - await expect(page.getByRole('tooltip')).toHaveText('This role is inherited from the silo') + await expect(page.getByRole('tooltip')).toHaveText('This role comes from the silo policy') await page.getByRole('menuitem', { name: 'Add project role' }).click() await expect(page.getByRole('heading', { name: 'Add project role' })).toBeVisible() From 58601cb30b938fcc972ae2854726a458e828446c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 15:46:44 -0400 Subject: [PATCH 43/45] gray out disabled remove-role actions --- app/components/access/roleActions.tsx | 22 ++++++++++++---------- test/e2e/project-access.e2e.ts | 5 ++++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/components/access/roleActions.tsx b/app/components/access/roleActions.tsx index 97e9217875..5433bb8db4 100644 --- a/app/components/access/roleActions.tsx +++ b/app/components/access/roleActions.tsx @@ -55,10 +55,19 @@ export const roleActions = (scope: RoleScope, canEdit: boolean) => { inheritedReason?: string doRemove: () => Promise }): MenuAction { + const disabled = !canEdit + ? noRolePermissionReason(scope, 'remove') + : // if role is not direct, it is inherited from a silo or group, so + // we use inheritedReason to direct them to the appropriate spot to + // remove it + !directRole + ? inheritedReason + : undefined + return { - // the action is not named "Delete", so set destructive styling explicitly + // Match RowActions' Delete behavior: destructive styling only while enabled. label: roleActionLabel(scope, 'remove'), - className: 'destructive', + className: disabled ? undefined : 'destructive', onActivate: confirmDelete({ doDelete: doRemove, label: ( @@ -69,14 +78,7 @@ export const roleActions = (scope: RoleScope, canEdit: boolean) => { resourceKind: 'role assignment', extraContent: isSelf ? `This will remove your own ${scope} access.` : undefined, }), - disabled: !canEdit - ? noRolePermissionReason(scope, 'remove') - : // if role is not direct, it is inherited from a silo or group, so - // we use inheritedReason to direct them to the appropriate spot to - // remove it - !directRole - ? inheritedReason - : undefined, + disabled, } }, } diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index 0a772e5ec7..995d893322 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -74,7 +74,9 @@ test('Project access shows and edits project role assignments', async ({ page }) // remove Jacob's project role const jacobRow = page.getByRole('row', { name: user3.display_name, exact: false }) await jacobRow.getByRole('button', { name: 'Row actions' }).click() - await page.getByRole('menuitem', { name: 'Remove project role' }).click() + const removeRole = page.getByRole('menuitem', { name: 'Remove project role' }) + await expect(removeRole).toHaveClass(/destructive/) + await removeRole.click() await page.getByRole('button', { name: 'Confirm' }).click() await expect(jacobRow).toBeHidden() @@ -110,6 +112,7 @@ test('Inherited-only row offers Add project role, not a disabled Change', async await expect(page.getByRole('menuitem', { name: 'Add project role' })).toBeEnabled() const removeItem = page.getByRole('menuitem', { name: 'Remove project role' }) await expect(removeItem).toBeDisabled() + await expect(removeItem).not.toHaveClass(/destructive/) await removeItem.hover() await expect(page.getByRole('tooltip')).toHaveText('This role comes from the silo policy') From cc4f35ff08653bf95c10c40dfa49f87157f5040d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 23 Jul 2026 14:39:29 -0700 Subject: [PATCH 44/45] Revert to remove +N affordance on Silo Access; use group icon --- app/api/roles.spec.ts | 16 +++++++++++ app/components/access/AccessUsersTab.tsx | 34 ++++++++++-------------- app/layouts/SiloLayout.tsx | 2 +- app/ui/lib/TipIcon.tsx | 6 +++-- test/e2e/silo-access.e2e.ts | 13 ++++----- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/app/api/roles.spec.ts b/app/api/roles.spec.ts index 53d72d4649..cb3bd6b011 100644 --- a/app/api/roles.spec.ts +++ b/app/api/roles.spec.ts @@ -128,6 +128,22 @@ describe('userScopedRoleEntries', () => { { roleName: 'admin', scope: 'project', source: { type: 'direct' } }, ]) }) + + it('keeps a separate entry per group even when the role is identical', () => { + const groupA = { id: 'a', displayName: 'a' } + const groupB = { id: 'b', displayName: 'b' } + const silo: Policy = { + roleAssignments: [ + { identityId: 'a', identityType: 'silo_group', roleName: 'collaborator' }, + { identityId: 'b', identityType: 'silo_group', roleName: 'collaborator' }, + ], + } + // same role via two groups must not collapse — each source is shown separately + expect(userScopedRoleEntries('u', [groupA, groupB], silo)).toEqual([ + { roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupA } }, + { roleName: 'collaborator', scope: 'silo', source: { type: 'group', group: groupB } }, + ]) + }) }) test('allRoles', () => { diff --git a/app/components/access/AccessUsersTab.tsx b/app/components/access/AccessUsersTab.tsx index 09cbd823e1..32cbb14531 100644 --- a/app/components/access/AccessUsersTab.tsx +++ b/app/components/access/AccessUsersTab.tsx @@ -21,13 +21,12 @@ import { usePrefetchedQuery, userScopedRoleEntries, type RoleKey, - type ScopedRoleEntry, type User, } from '@oxide/api' -import { Person24Icon } from '@oxide/design-system/icons/react' +import { Person24Icon, PersonGroup16Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -import { ListPlusCell, ListPlusOverflow } from '~/components/ListPlusCell' +import { ListPlusCell } from '~/components/ListPlusCell' import { SiloAccessEditUserSideModal } from '~/forms/silo-access' import { useCurrentUser } from '~/hooks/use-current-user' import { addToast } from '~/stores/toast' @@ -38,6 +37,7 @@ import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' import { roleColor } from '~/util/access' import { ALL_ISH } from '~/util/consts' @@ -66,10 +66,6 @@ const EmptyState = () => ( ) -/** How a user came to hold a role, shown as the source in the "other roles" tooltip. */ -const sourceLabel = (source: ScopedRoleEntry['source']) => - source.type === 'direct' ? 'Assigned' : `via ${source.group.displayName}` - type EditingState = { user: User; defaultRole: RoleKey | undefined } export function AccessUsersTab() { @@ -105,23 +101,21 @@ export function AccessUsersTab() { header: 'Role', cell: ({ row }) => { const userGroups = groupsByUserId.get(row.original.id) ?? [] - const entries = sortRoleEntries( + // strongest assignment is the effective role; any others are shown in + // the user detail side modal, reached by clicking the name + const effective = sortRoleEntries( userScopedRoleEntries(row.original.id, userGroups, siloPolicy) - ) - if (entries.length === 0) return - // strongest is the effective role; the rest go in the +N tooltip - const [effective, ...rest] = entries + ).at(0) + if (!effective) return return (
    silo.{effective.roleName} - - {rest.map((entry, i) => ( -
    - silo.{entry.roleName} - {sourceLabel(entry.source)} -
    - ))} -
    + {/* call out when the effective role is inherited rather than direct */} + {effective.source.type === 'group' && ( + }> + via {effective.source.group.displayName} + + )}
    ) }, diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index faacb44198..b30ae9bdc4 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -70,7 +70,7 @@ export default function SiloLayout() { Utilization - + Silo Access diff --git a/app/ui/lib/TipIcon.tsx b/app/ui/lib/TipIcon.tsx index dec07d18f3..61df9ab4db 100644 --- a/app/ui/lib/TipIcon.tsx +++ b/app/ui/lib/TipIcon.tsx @@ -14,8 +14,10 @@ import { Tooltip } from './Tooltip' type TipIconProps = { children: React.ReactNode className?: string + /** Override the default question-mark glyph (e.g. a domain-specific icon). */ + icon?: React.ReactNode } -export function TipIcon({ children, className }: TipIconProps) { +export function TipIcon({ children, className, icon }: TipIconProps) { return ( ) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 0ef7d2510e..cbc37e31f7 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -146,20 +146,17 @@ test('Users & Groups page lands on Users tab; shows direct + via-group silo role // Jane Austen has a direct silo.viewer role but inherits the stronger // silo.collaborator via real-estate-devs, so her effective role is - // collaborator with a "+1" revealing the other role + // collaborator; the other role is only shown in the detail side modal await expectRowVisible(table, { Name: 'Jane Austen', - Role: 'silo.collaborator+1', + Role: 'silo.collaborator', Groups: 'real-estate-devs', }) - // hovering the +1 lists the other role and where it comes from + // the effective role is inherited, so an info icon names the source group const janeRow = table.getByRole('row', { name: 'Jane Austen', exact: false }) - await janeRow.getByText('+1').hover() - const tooltip = page.getByRole('tooltip') - await expect(tooltip).toContainText('Other roles') - await expect(tooltip).toContainText('silo.viewer') - await expect(tooltip).toContainText('Assigned') + await janeRow.getByRole('button', { name: 'Tip' }).hover() + await expect(page.getByRole('tooltip')).toContainText('via real-estate-devs') // Groups tab comes second await page.getByRole('tab', { name: 'Groups' }).click() From 08a8adfbeb070a24d54102d06097b26a3825403e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 23 Jul 2026 18:10:00 -0400 Subject: [PATCH 45/45] replace membership query version key with combine --- app/api/roles.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 9b6f0936a9..5f35023adc 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -217,28 +217,19 @@ export const sortRoleEntries = (entries: ScopedRoleEntry[]) => /** * Builds a map from user ID to the list of groups that user belongs to, * firing one query per group to fetch members. Shared between user tabs. - * - * The returned Map is referentially stable between data updates, which keeps - * downstream useMemos (column definitions) from invalidating every render. - * `useQueries` returns a new array reference each render, so we can't put it in - * a useMemo deps array directly — instead we encode the relevant inputs (group - * IDs and per-query updated-at timestamps) into a single version string and - * memoize on that. */ export function useGroupsByUserId(groups: Group[]): Map { - const groupMemberQueries = useQueries({ + const groupMemberPages = useQueries({ queries: groups.map((g) => q(api.userList, { query: { group: g.id, limit: ALL_ISH } })), + // useQueries returns a new array each render; combine structurally shares + // the selected data so the Map only changes with groups or memberships + combine: (results) => results.map((result) => result.data), }) - const version = [ - groups.map((g) => g.id).join(','), - ...groupMemberQueries.map((query) => query.dataUpdatedAt), - ].join('|') - return useMemo(() => { const map = new Map() groups.forEach((group, i) => { - const members = groupMemberQueries[i]?.data?.items ?? [] + const members = groupMemberPages[i]?.items ?? [] members.forEach((member) => { const existing = map.get(member.id) if (existing) existing.push(group) @@ -246,6 +237,5 @@ export function useGroupsByUserId(groups: Group[]): Map { }) }) return map - // eslint-disable-next-line react-hooks/exhaustive-deps -- groups and queries are encoded in version - }, [version]) + }, [groups, groupMemberPages]) }