From c59c55ab634c067842bc2162f050601b44ef64a8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 5 Mar 2026 09:52:53 -0800 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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' }) })