From 0b29881245cbc7e7b754cdd260be946fac48cf6d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 2 Dec 2025 13:59:59 -0500 Subject: [PATCH 01/22] First pass with tabs on access pages --- .../project/access/ProjectAccessAllTab.tsx | 224 ++++++++++++++++++ .../project/access/ProjectAccessGroupsTab.tsx | 208 ++++++++++++++++ .../project/access/ProjectAccessPage.tsx | 204 +--------------- .../project/access/ProjectAccessUsersTab.tsx | 206 ++++++++++++++++ .../access/SiloAccessAllTab.tsx} | 38 +-- app/pages/silo/access/SiloAccessGroupsTab.tsx | 176 ++++++++++++++ app/pages/silo/access/SiloAccessPage.tsx | 58 +++++ app/pages/silo/access/SiloAccessUsersTab.tsx | 173 ++++++++++++++ app/routes.tsx | 41 +++- .../__snapshots__/path-builder.spec.ts.snap | 64 ++++- app/util/path-builder.spec.ts | 10 +- app/util/path-builder.ts | 10 +- 12 files changed, 1183 insertions(+), 229 deletions(-) create mode 100644 app/pages/project/access/ProjectAccessAllTab.tsx create mode 100644 app/pages/project/access/ProjectAccessGroupsTab.tsx create mode 100644 app/pages/project/access/ProjectAccessUsersTab.tsx rename app/pages/{SiloAccessPage.tsx => silo/access/SiloAccessAllTab.tsx} (82%) create mode 100644 app/pages/silo/access/SiloAccessGroupsTab.tsx create mode 100644 app/pages/silo/access/SiloAccessPage.tsx create mode 100644 app/pages/silo/access/SiloAccessUsersTab.tsx diff --git a/app/pages/project/access/ProjectAccessAllTab.tsx b/app/pages/project/access/ProjectAccessAllTab.tsx new file mode 100644 index 000000000..6789551bf --- /dev/null +++ b/app/pages/project/access/ProjectAccessAllTab.tsx @@ -0,0 +1,224 @@ +/* + * 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 * 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 { 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 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} + /> + +) + +type UserRow = { + id: string + identityType: IdentityType + name: string + projectRole: RoleKey | undefined + roleBadges: { roleSource: string; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +export default function ProjectAccessAllTab() { + 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' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. + 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/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx new file mode 100644 index 000000000..df1e48a5a --- /dev/null +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -0,0 +1,208 @@ +/* + * 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 * 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 { 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 { 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 EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No authorized groups" + body="Give permission to view, edit, or administer this project" + buttonText="Add group to project" + onClick={onClick} + /> + +) + +type UserRow = { + id: string + identityType: IdentityType + name: string + projectRole: RoleKey | undefined + roleBadges: { roleSource: string; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +export default function ProjectAccessGroupsTab() { + 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 + }) + .filter((row) => row.identityType === 'silo_group') + .sort(byGroupThenName) + }, [siloRows, projectRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + colHelper.accessor('roleBadges', { + header: () => ( + + Role + + A group's effective role for this project is the strongest role on either + the silo or project + + + ), + cell: (info) => ( + + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} + + ))} + + ), + }), + + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: + !row.projectRole && "You don't have permission to change this group's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + path: { project: projectSelector.project }, + body: deleteRole(row.id, projectPolicy), + }), + label: ( + + the {row.projectRole} role for {row.name} + + ), + }), + disabled: !row.projectRole && "You don't have permission to delete this group", + }, + ]), + ], + [projectPolicy, projectSelector.project, updatePolicy] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + setAddModalOpen(true)}>Add 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/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 17cf4d453..5f6b26843 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -5,48 +5,17 @@ * * 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 { api, q, queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' +import { RouteTabs, Tab } from '~/components/RouteTabs' 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 { groupBy } from '~/util/array' import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const policyView = q(api.policyView, {}) @@ -55,18 +24,6 @@ const projectPolicyView = ({ project }: PP.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([ @@ -81,131 +38,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const handle = { crumb: 'Project Access' } -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() - export default function ProjectAccessPage() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) const 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 ( <> @@ -218,30 +53,15 @@ export default function ProjectAccessPage() { /> - - 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)} /> - ) : ( -
- )} + + All + Users + Groups + + {/* TODO: Add routes for side modal forms to enable deep linking and browser back button: + - /access/all/users-new and /access/all/groups-new for adding + - /access/all/{id}/edit for editing roles + This would align with patterns like /instances-new, /idps-new, etc. */} ) } diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx new file mode 100644 index 000000000..1a8953df6 --- /dev/null +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -0,0 +1,206 @@ +/* + * 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 * 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 { 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 { 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 EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No authorized users" + body="Give permission to view, edit, or administer this project" + buttonText="Add user to project" + onClick={onClick} + /> + +) + +type UserRow = { + id: string + identityType: IdentityType + name: string + projectRole: RoleKey | undefined + roleBadges: { roleSource: string; roleName: RoleKey }[] +} + +const colHelper = createColumnHelper() + +export default function ProjectAccessUsersTab() { + 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 + }) + .filter((row) => row.identityType === 'silo_user') + .sort(byGroupThenName) + }, [siloRows, projectRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add lastAccessed column once API provides it. The User type should include + // a lastAccessed timestamp field to show when each user last logged in or accessed + // the system. This helps identify inactive users. + colHelper.accessor('roleBadges', { + header: () => ( + + Role + + A user's effective role for this project is the strongest role on either + the silo or project + + + ), + cell: (info) => ( + + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} + + ))} + + ), + }), + + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: + !row.projectRole && "You don't have permission to change this user's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + path: { project: projectSelector.project }, + body: deleteRole(row.id, projectPolicy), + }), + label: ( + + the {row.projectRole} role for {row.name} + + ), + }), + 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 + + {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/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessAllTab.tsx similarity index 82% rename from app/pages/SiloAccessPage.tsx rename to app/pages/silo/access/SiloAccessAllTab.tsx index eb65e9535..f91b5bcb7 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/silo/access/SiloAccessAllTab.tsx @@ -21,10 +21,9 @@ import { type IdentityType, type RoleKey, } from '@oxide/api' -import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { 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, @@ -35,11 +34,9 @@ import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { identityTypeLabel, roleColor } from '~/util/access' import { groupBy } from '~/util/array' -import { docLinks } from '~/util/links' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -54,20 +51,6 @@ const EmptyState = ({ onClick }: { onClick: () => void }) => ( ) 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 = { crumb: 'Silo Access' } type UserRow = { id: string @@ -79,7 +62,7 @@ type UserRow = { const colHelper = createColumnHelper() -export default function SiloAccessPage() { +export default function SiloAccessAllTab() { const [addModalOpen, setAddModalOpen] = useState(false) const [editingUserRow, setEditingUserRow] = useState(null) @@ -120,6 +103,13 @@ export default function SiloAccessPage() { const columns = useMemo( () => [ colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. colHelper.accessor('identityType', { header: 'Type', cell: (info) => identityTypeLabel[info.getValue()], @@ -168,16 +158,6 @@ export default function SiloAccessPage() { return ( <> - - }>Silo Access - } - 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]} - /> - - setAddModalOpen(true)}>Add user or group diff --git a/app/pages/silo/access/SiloAccessGroupsTab.tsx b/app/pages/silo/access/SiloAccessGroupsTab.tsx new file mode 100644 index 000000000..4d6ad1a38 --- /dev/null +++ b/app/pages/silo/access/SiloAccessGroupsTab.tsx @@ -0,0 +1,176 @@ +/* + * 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 { 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 { roleColor } from '~/util/access' +import { groupBy } from '~/util/array' + +const EmptyState = ({ onClick }: { onClick: () => void }) => ( + + } + title="No authorized groups" + body="Give permission to view, edit, or administer this silo" + buttonText="Add group" + onClick={onClick} + /> + +) + +const policyView = q(api.policyView, {}) + +type UserRow = { + id: string + identityType: IdentityType + name: string + siloRole: RoleKey | undefined + effectiveRole: RoleKey +} + +const colHelper = createColumnHelper() + +export default function SiloAccessGroupsTab() { + 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 + }) + .filter((row) => row.identityType === 'silo_group') + .sort(byGroupThenName) + }, [siloRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => queryClient.invalidateEndpoint('policyView'), + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + colHelper.accessor('siloRole', { + header: 'Role', + cell: (info) => { + const role = info.getValue() + return role ? silo.{role} : null + }, + }), + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: + !row.siloRole && "You don't have permission to change this group's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + body: deleteRole(row.id, siloPolicy), + }), + label: ( + + the {row.siloRole} role for {row.name} + + ), + }), + disabled: !row.siloRole && "You don't have permission to delete this group", + }, + ]), + ], + [siloPolicy, updatePolicy] + ) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + <> + + setAddModalOpen(true)}>Add 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/silo/access/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessPage.tsx new file mode 100644 index 000000000..62c6a936f --- /dev/null +++ b/app/pages/silo/access/SiloAccessPage.tsx @@ -0,0 +1,58 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { api, q, queryClient } from '@oxide/api' +import { 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' + +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 = { crumb: 'Silo Access' } + +export default function SiloAccessPage() { + return ( + <> + + }>Silo Access + } + 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]} + /> + + + + All + Users + Groups + + {/* TODO: Add routes for side modal forms to enable deep linking and browser back button: + - /access/all/users-new and /access/all/groups-new for adding + - /access/all/{id}/edit for editing roles + This would align with patterns like /instances-new, /idps-new, etc. */} + + ) +} diff --git a/app/pages/silo/access/SiloAccessUsersTab.tsx b/app/pages/silo/access/SiloAccessUsersTab.tsx new file mode 100644 index 000000000..1f516c69c --- /dev/null +++ b/app/pages/silo/access/SiloAccessUsersTab.tsx @@ -0,0 +1,173 @@ +/* + * 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 { 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 { 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" + onClick={onClick} + /> + +) + +const policyView = q(api.policyView, {}) + +type UserRow = { + id: string + identityType: IdentityType + name: string + siloRole: RoleKey | undefined + effectiveRole: RoleKey +} + +const colHelper = createColumnHelper() + +export default function SiloAccessUsersTab() { + 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 + }) + .filter((row) => row.identityType === 'silo_user') + .sort(byGroupThenName) + }, [siloRows]) + + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => queryClient.invalidateEndpoint('policyView'), + }) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add lastAccessed column once API provides it. The User type should include + // a lastAccessed timestamp field to show when each user last logged in or accessed + // the system. This helps identify inactive users. + colHelper.accessor('siloRole', { + header: 'Role', + cell: (info) => { + const role = info.getValue() + return role ? silo.{role} : null + }, + }), + getActionsCol((row: UserRow) => [ + { + label: 'Change role', + onActivate: () => setEditingUserRow(row), + disabled: !row.siloRole && "You don't have permission to change this user's role", + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + updatePolicy({ + 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 + + {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/routes.tsx b/app/routes.tsx index 8a16d50b5..49f6c97a4 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -276,7 +276,24 @@ export const routes = createRoutesFromElements( /> - import('./pages/SiloAccessPage').then(convert)} /> + import('./pages/silo/access/SiloAccessPage').then(convert)} + > + } /> + import('./pages/silo/access/SiloAccessAllTab').then(convert)} + /> + import('./pages/silo/access/SiloAccessUsersTab').then(convert)} + /> + import('./pages/silo/access/SiloAccessGroupsTab').then(convert)} + /> + {/* PROJECT */} @@ -528,7 +545,27 @@ export const routes = createRoutesFromElements( import('./pages/project/access/ProjectAccessPage').then(convert)} - /> + > + } /> + + import('./pages/project/access/ProjectAccessAllTab').then(convert) + } + /> + + import('./pages/project/access/ProjectAccessUsersTab').then(convert) + } + /> + + import('./pages/project/access/ProjectAccessGroupsTab').then(convert) + } + /> + import('./pages/project/affinity/AffinityPage').then(convert)} handle={{ crumb: 'Affinity Groups' }} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 0666f345a..8a053a42f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -431,7 +431,49 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, ], - "projectAccess (/projects/p/access)": [ + "projectAccess (/projects/p/access/all)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access", + }, + ], + "projectAccessAll (/projects/p/access/all)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access", + }, + ], + "projectAccessGroups (/projects/p/access/groups)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Project Access", + "path": "/projects/p/access", + }, + ], + "projectAccessUsers (/projects/p/access/users)": [ { "label": "Projects", "path": "/projects", @@ -555,7 +597,25 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/idps", }, ], - "siloAccess (/access)": [ + "siloAccess (/access/all)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], + "siloAccessAll (/access/all)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], + "siloAccessGroups (/access/groups)": [ + { + "label": "Silo Access", + "path": "/access", + }, + ], + "siloAccessUsers (/access/users)": [ { "label": "Silo Access", "path": "/access", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 35aefaab2..e969e47b3 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -70,7 +70,10 @@ test('path builder', () => { "ipPoolsNew": "/system/networking/ip-pools-new", "profile": "/settings/profile", "project": "/projects/p/instances", - "projectAccess": "/projects/p/access", + "projectAccess": "/projects/p/access/all", + "projectAccessAll": "/projects/p/access/all", + "projectAccessGroups": "/projects/p/access/groups", + "projectAccessUsers": "/projects/p/access/users", "projectEdit": "/projects/p/edit", "projectImageEdit": "/projects/p/images/im/edit", "projectImages": "/projects/p/images", @@ -80,7 +83,10 @@ test('path builder', () => { "samlIdp": "/system/silos/s/idps/saml/pr", "serialConsole": "/projects/p/instances/i/serial-console", "silo": "/system/silos/s/idps", - "siloAccess": "/access", + "siloAccess": "/access/all", + "siloAccessAll": "/access/all", + "siloAccessGroups": "/access/groups", + "siloAccessUsers": "/access/users", "siloFleetRoles": "/system/silos/s/fleet-roles", "siloIdps": "/system/silos/s/idps", "siloIdpsNew": "/system/silos/s/idps-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index da7e04c69..949d00e5b 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -26,7 +26,10 @@ export const pb = { project: (params: PP.Project) => `${projectBase(params)}/instances`, projectEdit: (params: PP.Project) => `${projectBase(params)}/edit`, - projectAccess: (params: PP.Project) => `${projectBase(params)}/access`, + projectAccess: (params: PP.Project) => `${projectBase(params)}/access/all`, + projectAccessAll: (params: PP.Project) => `${projectBase(params)}/access/all`, + 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) => @@ -105,7 +108,10 @@ export const pb = { `${pb.antiAffinityGroup(params)}/edit`, siloUtilization: () => '/utilization', - siloAccess: () => '/access', + siloAccess: () => '/access/all', + siloAccessAll: () => '/access/all', + siloAccessUsers: () => '/access/users', + siloAccessGroups: () => '/access/groups', siloImages: () => '/images', siloImageEdit: (params: PP.SiloImage) => `${pb.siloImages()}/${params.image}/edit`, From 8d3a4f0e70af5d57d52c3af841e1a4558a22b422 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 2 Dec 2025 17:16:42 -0500 Subject: [PATCH 02/22] Refactoring --- app/api/access-queries.ts | 18 +++ app/api/roles.ts | 2 +- app/components/AccessEmptyState.tsx | 54 +++++++++ app/hooks/use-access-mutations.ts | 32 ++++++ app/hooks/use-access-rows.ts | 105 +++++++++++++++++ .../project/access/ProjectAccessAllTab.tsx | 106 ++++-------------- .../project/access/ProjectAccessGroupsTab.tsx | 106 ++++-------------- .../project/access/ProjectAccessUsersTab.tsx | 106 ++++-------------- app/pages/silo/access/SiloAccessAllTab.tsx | 89 +++------------ app/pages/silo/access/SiloAccessGroupsTab.tsx | 93 ++++----------- app/pages/silo/access/SiloAccessUsersTab.tsx | 93 ++++----------- app/types/access.ts | 24 ++++ 12 files changed, 359 insertions(+), 469 deletions(-) create mode 100644 app/api/access-queries.ts create mode 100644 app/components/AccessEmptyState.tsx create mode 100644 app/hooks/use-access-mutations.ts create mode 100644 app/hooks/use-access-rows.ts create mode 100644 app/types/access.ts diff --git a/app/api/access-queries.ts b/app/api/access-queries.ts new file mode 100644 index 000000000..01c42b716 --- /dev/null +++ b/app/api/access-queries.ts @@ -0,0 +1,18 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { api, q } from '@oxide/api' + +import type * as PP from '~/util/path-params' + +export const accessQueries = { + siloPolicy: () => q(api.policyView, {}), + projectPolicy: ({ project }: PP.Project) => + q(api.projectPolicyView, { path: { project } }), + userList: () => q(api.userList, {}), + groupList: () => q(api.groupList, {}), +} diff --git a/app/api/roles.ts b/app/api/roles.ts index 9c3ff07da..e074ce445 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -77,7 +77,7 @@ export function deleteRole(identityId: string, policy: Policy): Policy { return { roleAssignments } } -type UserAccessRow = { +export type UserAccessRow = { id: string identityType: IdentityType name: string diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx new file mode 100644 index 000000000..d42cb11bc --- /dev/null +++ b/app/components/AccessEmptyState.tsx @@ -0,0 +1,54 @@ +/* + * 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 { Access24Icon } from '@oxide/design-system/icons/react' + +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' + +type AccessEmptyStateProps = { + onClick: () => void + scope: 'project' | 'silo' + filter?: 'all' | 'users' | 'groups' +} + +export function AccessEmptyState({ + onClick, + scope, + filter = 'all', +}: AccessEmptyStateProps) { + const titleMap = { + all: 'No authorized users', + users: 'No authorized users', + groups: 'No authorized groups', + } + + const buttonTextMap = { + project: { + all: 'Add user or group to project', + users: 'Add user to project', + groups: 'Add group to project', + }, + silo: { + all: 'Add user or group', + users: 'Add user', + groups: 'Add group', + }, + } + + return ( + + } + title={titleMap[filter]} + body={`Give permission to view, edit, or administer this ${scope}`} + buttonText={buttonTextMap[scope][filter]} + onClick={onClick} + /> + + ) +} diff --git a/app/hooks/use-access-mutations.ts b/app/hooks/use-access-mutations.ts new file mode 100644 index 000000000..e20581467 --- /dev/null +++ b/app/hooks/use-access-mutations.ts @@ -0,0 +1,32 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { api, queryClient, useApiMutation } from '@oxide/api' + +import { addToast } from '~/stores/toast' + +export function useProjectAccessMutations() { + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + addToast({ content: 'Access removed' }) + }, + }) + + return { updatePolicy } +} + +export function useSiloAccessMutations() { + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + addToast({ content: 'Access removed' }) + }, + }) + + return { updatePolicy } +} diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts new file mode 100644 index 000000000..04a60dc18 --- /dev/null +++ b/app/hooks/use-access-rows.ts @@ -0,0 +1,105 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useMemo } from 'react' +import * as R from 'remeda' + +import { + byGroupThenName, + getEffectiveRole, + roleOrder, + type IdentityType, + type UserAccessRow, +} from '@oxide/api' + +import type { ProjectAccessRow, SiloAccessRow } from '~/types/access' +import { groupBy } from '~/util/array' + +type IdentityFilter = 'all' | 'users' | 'groups' + +/** Filter rows by identity type based on the filter parameter */ +function filterByIdentityType( + rows: T[], + filter: IdentityFilter +): T[] { + if (filter === 'users') return rows.filter((row) => row.identityType === 'silo_user') + if (filter === 'groups') return rows.filter((row) => row.identityType === 'silo_group') + return rows +} + +export function useSiloAccessRows( + siloRows: UserAccessRow[], + filter: IdentityFilter = 'all' +): SiloAccessRow[] { + return useMemo(() => { + const rows = groupBy(siloRows, (u) => u.id).map(([userId, userAssignments]) => { + // groupBy always produces non-empty arrays, but add guard for safety + if (userAssignments.length === 0) { + throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) + } + + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + const { name, identityType } = userAssignments[0] + + const row: SiloAccessRow = { + id: userId, + identityType, + name, + siloRole, + // effectiveRole is the most permissive role, or 'viewer' if no role assigned + effectiveRole: getEffectiveRole(siloRole ? [siloRole] : []) || 'viewer', + } + + return row + }) + + return filterByIdentityType(rows, filter).sort(byGroupThenName) + }, [siloRows, filter]) +} + +export function useProjectAccessRows( + siloRows: UserAccessRow[], + projectRows: UserAccessRow[], + filter: IdentityFilter = 'all' +): ProjectAccessRow[] { + return useMemo(() => { + const rows = groupBy(siloRows.concat(projectRows), (u) => u.id).map( + ([userId, userAssignments]) => { + // groupBy always produces non-empty arrays, but add guard for safety + if (userAssignments.length === 0) { + throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) + } + + const { name, identityType } = userAssignments[0] + + const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') + const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') + + // Filter out undefined values with proper type guard, then map to expected shape + const roleBadges = R.sortBy( + [siloAccessRow, projectAccessRow].filter( + (r): r is UserAccessRow => r !== undefined + ), + (r) => roleOrder[r.roleName] // sorts strongest role first + ).map((r) => ({ + roleSource: r.roleSource as 'silo' | 'project', + roleName: r.roleName, + })) + + return { + id: userId, + identityType, + name, + projectRole: projectAccessRow?.roleName, + roleBadges, + } satisfies ProjectAccessRow + } + ) + + return filterByIdentityType(rows, filter).sort(byGroupThenName) + }, [siloRows, projectRows, filter]) +} diff --git a/app/pages/project/access/ProjectAccessAllTab.tsx b/app/pages/project/access/ProjectAccessAllTab.tsx index 6789551bf..2fc714c84 100644 --- a/app/pages/project/access/ProjectAccessAllTab.tsx +++ b/app/pages/project/access/ProjectAccessAllTab.tsx @@ -8,111 +8,47 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' -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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' +import { useProjectAccessMutations } from '~/hooks/use-access-mutations' +import { useProjectAccessRows } from '~/hooks/use-access-rows' import { 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 type { ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } 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 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} - /> - -) -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function ProjectAccessAllTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) const projectSelector = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: projectPolicy } = usePrefetchedQuery( + accessQueries.projectPolicy(projectSelector) + ) const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') + const rows = useProjectAccessRows(siloRows, projectRows, 'all') - 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 - }) + const { updatePolicy } = useProjectAccessMutations() // TODO: checkboxes and bulk delete? not sure // TODO: disable delete on permissions you can't delete @@ -153,7 +89,7 @@ export default function ProjectAccessAllTab() { }), // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ + getActionsCol((row: ProjectAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -198,13 +134,13 @@ export default function ProjectAccessAllTab() { setAddModalOpen(true)}>Add user or group - {projectPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {projectPolicy && editingUserRow?.projectRole && ( + {editingUserRow?.projectRole && ( setEditingUserRow(null)} policy={projectPolicy} @@ -215,7 +151,11 @@ export default function ProjectAccessAllTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} + /> ) : (
)} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index df1e48a5a..61affe84d 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -8,111 +8,47 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' -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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' +import { useProjectAccessMutations } from '~/hooks/use-access-mutations' +import { useProjectAccessRows } from '~/hooks/use-access-rows' import { 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 type { ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { 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 EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized groups" - body="Give permission to view, edit, or administer this project" - buttonText="Add group to project" - onClick={onClick} - /> - -) -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function ProjectAccessGroupsTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) const projectSelector = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: projectPolicy } = usePrefetchedQuery( + accessQueries.projectPolicy(projectSelector) + ) const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') + const rows = useProjectAccessRows(siloRows, projectRows, 'groups') - 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 - }) - .filter((row) => row.identityType === 'silo_group') - .sort(byGroupThenName) - }, [siloRows, projectRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Access removed' }) - }, - }) + const { updatePolicy } = useProjectAccessMutations() const columns = useMemo( () => [ @@ -143,7 +79,7 @@ export default function ProjectAccessGroupsTab() { ), }), - getActionsCol((row: UserRow) => [ + getActionsCol((row: ProjectAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -182,13 +118,13 @@ export default function ProjectAccessGroupsTab() { setAddModalOpen(true)}>Add group - {projectPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {projectPolicy && editingUserRow?.projectRole && ( + {editingUserRow?.projectRole && ( setEditingUserRow(null)} policy={projectPolicy} @@ -199,7 +135,11 @@ export default function ProjectAccessGroupsTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} + /> ) : (
)} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 1a8953df6..3d48fd914 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -8,111 +8,47 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState } from 'react' -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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' +import { useProjectAccessMutations } from '~/hooks/use-access-mutations' +import { useProjectAccessRows } from '~/hooks/use-access-rows' import { 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 type { ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { 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 EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized users" - body="Give permission to view, edit, or administer this project" - buttonText="Add user to project" - onClick={onClick} - /> - -) -type UserRow = { - id: string - identityType: IdentityType - name: string - projectRole: RoleKey | undefined - roleBadges: { roleSource: string; roleName: RoleKey }[] -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function ProjectAccessUsersTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) const projectSelector = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector)) + const { data: projectPolicy } = usePrefetchedQuery( + accessQueries.projectPolicy(projectSelector) + ) const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') + const rows = useProjectAccessRows(siloRows, projectRows, 'users') - 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 - }) - .filter((row) => row.identityType === 'silo_user') - .sort(byGroupThenName) - }, [siloRows, projectRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Access removed' }) - }, - }) + const { updatePolicy } = useProjectAccessMutations() const columns = useMemo( () => [ @@ -141,7 +77,7 @@ export default function ProjectAccessUsersTab() { ), }), - getActionsCol((row: UserRow) => [ + getActionsCol((row: ProjectAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -180,13 +116,13 @@ export default function ProjectAccessUsersTab() { setAddModalOpen(true)}>Add user - {projectPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {projectPolicy && editingUserRow?.projectRole && ( + {editingUserRow?.projectRole && ( setEditingUserRow(null)} policy={projectPolicy} @@ -197,7 +133,11 @@ export default function ProjectAccessUsersTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} + /> ) : (
)} diff --git a/app/pages/silo/access/SiloAccessAllTab.tsx b/app/pages/silo/access/SiloAccessAllTab.tsx index f91b5bcb7..764c5d144 100644 --- a/app/pages/silo/access/SiloAccessAllTab.tsx +++ b/app/pages/silo/access/SiloAccessAllTab.tsx @@ -8,94 +8,37 @@ 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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' +import { useSiloAccessMutations } from '~/hooks/use-access-mutations' +import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import type { SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } 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, {}) -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function SiloAccessAllTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') + const rows = useSiloAccessRows(siloRows, 'all') - 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 - }) + const { updatePolicy } = useSiloAccessMutations() // TODO: checkboxes and bulk delete? not sure // TODO: disable delete on permissions you can't delete @@ -122,7 +65,7 @@ export default function SiloAccessAllTab() { }, }), // TODO: tooltips on disabled elements explaining why - getActionsCol((row: UserRow) => [ + getActionsCol((row: SiloAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -161,13 +104,13 @@ export default function SiloAccessAllTab() { setAddModalOpen(true)}>Add user or group - {siloPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {siloPolicy && editingUserRow?.siloRole && ( + {editingUserRow?.siloRole && ( setEditingUserRow(null)} policy={siloPolicy} @@ -178,7 +121,7 @@ export default function SiloAccessAllTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} /> ) : (
)} diff --git a/app/pages/silo/access/SiloAccessGroupsTab.tsx b/app/pages/silo/access/SiloAccessGroupsTab.tsx index 4d6ad1a38..5d8bcf4b5 100644 --- a/app/pages/silo/access/SiloAccessGroupsTab.tsx +++ b/app/pages/silo/access/SiloAccessGroupsTab.tsx @@ -8,94 +8,37 @@ 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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' +import { useSiloAccessMutations } from '~/hooks/use-access-mutations' +import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import type { SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } from '~/ui/lib/Table' import { roleColor } from '~/util/access' -import { groupBy } from '~/util/array' - -const EmptyState = ({ onClick }: { onClick: () => void }) => ( - - } - title="No authorized groups" - body="Give permission to view, edit, or administer this silo" - buttonText="Add group" - onClick={onClick} - /> - -) - -const policyView = q(api.policyView, {}) -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function SiloAccessGroupsTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') + const rows = useSiloAccessRows(siloRows, 'groups') - 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 - }) - .filter((row) => row.identityType === 'silo_group') - .sort(byGroupThenName) - }, [siloRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), - }) + const { updatePolicy } = useSiloAccessMutations() const columns = useMemo( () => [ @@ -112,7 +55,7 @@ export default function SiloAccessGroupsTab() { return role ? silo.{role} : null }, }), - getActionsCol((row: UserRow) => [ + getActionsCol((row: SiloAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -150,13 +93,13 @@ export default function SiloAccessGroupsTab() { setAddModalOpen(true)}>Add group - {siloPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {siloPolicy && editingUserRow?.siloRole && ( + {editingUserRow?.siloRole && ( setEditingUserRow(null)} policy={siloPolicy} @@ -167,7 +110,11 @@ export default function SiloAccessGroupsTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} + /> ) : (
)} diff --git a/app/pages/silo/access/SiloAccessUsersTab.tsx b/app/pages/silo/access/SiloAccessUsersTab.tsx index 1f516c69c..dc370142d 100644 --- a/app/pages/silo/access/SiloAccessUsersTab.tsx +++ b/app/pages/silo/access/SiloAccessUsersTab.tsx @@ -8,94 +8,37 @@ 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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' +import { useSiloAccessMutations } from '~/hooks/use-access-mutations' +import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import type { SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' -import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { TableActions } from '~/ui/lib/Table' import { 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" - onClick={onClick} - /> - -) - -const policyView = q(api.policyView, {}) -type UserRow = { - id: string - identityType: IdentityType - name: string - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} - -const colHelper = createColumnHelper() +const colHelper = createColumnHelper() export default function SiloAccessUsersTab() { const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) + const [editingUserRow, setEditingUserRow] = useState(null) - const { data: siloPolicy } = usePrefetchedQuery(policyView) + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') + const rows = useSiloAccessRows(siloRows, 'users') - 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 - }) - .filter((row) => row.identityType === 'silo_user') - .sort(byGroupThenName) - }, [siloRows]) - - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => queryClient.invalidateEndpoint('policyView'), - }) + const { updatePolicy } = useSiloAccessMutations() const columns = useMemo( () => [ @@ -110,7 +53,7 @@ export default function SiloAccessUsersTab() { return role ? silo.{role} : null }, }), - getActionsCol((row: UserRow) => [ + getActionsCol((row: SiloAccessRow) => [ { label: 'Change role', onActivate: () => setEditingUserRow(row), @@ -147,13 +90,13 @@ export default function SiloAccessUsersTab() { setAddModalOpen(true)}>Add user - {siloPolicy && addModalOpen && ( + {addModalOpen && ( setAddModalOpen(false)} policy={siloPolicy} /> )} - {siloPolicy && editingUserRow?.siloRole && ( + {editingUserRow?.siloRole && ( setEditingUserRow(null)} policy={siloPolicy} @@ -164,7 +107,11 @@ export default function SiloAccessUsersTab() { /> )} {rows.length === 0 ? ( - setAddModalOpen(true)} /> + setAddModalOpen(true)} + /> ) : (
)} diff --git a/app/types/access.ts b/app/types/access.ts new file mode 100644 index 000000000..807f6724c --- /dev/null +++ b/app/types/access.ts @@ -0,0 +1,24 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { IdentityType, RoleKey } from '@oxide/api' + +export type AccessRowBase = { + id: string + identityType: IdentityType + name: string +} + +export type ProjectAccessRow = AccessRowBase & { + projectRole: RoleKey | undefined + roleBadges: { roleSource: 'silo' | 'project'; roleName: RoleKey }[] +} + +export type SiloAccessRow = AccessRowBase & { + siloRole: RoleKey | undefined + effectiveRole: RoleKey +} From a4fc3be1d58dccae31621506b1e6bca376b7611a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 2 Dec 2025 17:32:56 -0500 Subject: [PATCH 03/22] Add test --- test/e2e/project-access.e2e.ts | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index d4f34d8ce..d1f43847c 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -116,3 +116,65 @@ test('Click through project access page', async ({ page }) => { Role: 'silo.admin+1', }) }) + +test('Add user on All tab and verify on Users tab', async ({ page }) => { + await page.goto('/projects/mock-project/access/all') + + // Start on the All tab + await expectVisible(page, ['role=tab[name="All"][selected]']) + + const allTable = page.locator('table') + + // Add Hans Jonas as viewer + 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"]') + await page.click('role=option[name="Hans Jonas"]') + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Assign role"]') + + // User shows up in the All tab table + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: 'project.viewer', + }) + + // Navigate to Users tab + await page.click('role=tab[name="Users"]') + await expectVisible(page, ['role=tab[name="Users"][selected]']) + + // Verify the URL changed to /users + await expect(page).toHaveURL(/\/access\/users$/) + + const usersTable = page.locator('table') + + // User should still be visible on Users tab + await expectRowVisible(usersTable, { + Name: 'Hans Jonas', + Role: 'project.viewer', + }) + + // Type column should not be present on Users tab (since all are users) + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Navigate to Groups tab + await page.click('role=tab[name="Groups"]') + await expectVisible(page, ['role=tab[name="Groups"][selected]']) + + // Hans Jonas should NOT be visible on Groups tab + await expectNotVisible(page, ['role=cell[name="Hans Jonas"]']) + + // Type column should not be present on Groups tab either + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Go back to All tab and verify Hans Jonas is still there + await page.click('role=tab[name="All"]') + await expectVisible(page, ['role=tab[name="All"][selected]']) + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: 'project.viewer', + }) +}) From bedc0b9a4bcfaed5acb4bd2b8baea11d3715d5b4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 3 Dec 2025 10:49:59 -0500 Subject: [PATCH 04/22] copy --- app/components/AccessEmptyState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index d42cb11bc..0dd47f6c0 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -22,7 +22,7 @@ export function AccessEmptyState({ filter = 'all', }: AccessEmptyStateProps) { const titleMap = { - all: 'No authorized users', + all: 'No authorized users or groups', users: 'No authorized users', groups: 'No authorized groups', } From b6db2222326012437637cd98933afc3e647b6e28 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 4 Dec 2025 11:27:15 -0800 Subject: [PATCH 05/22] Refactoring --- app/api/roles.ts | 13 ++-- app/components/AccessEmptyState.tsx | 67 +++++++++---------- app/hooks/use-access-rows.ts | 12 ++-- .../project/access/ProjectAccessAllTab.tsx | 6 +- .../project/access/ProjectAccessGroupsTab.tsx | 6 +- .../project/access/ProjectAccessUsersTab.tsx | 6 +- app/types/access.ts | 4 +- 7 files changed, 59 insertions(+), 55 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index e074ce445..22a558dc8 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -23,6 +23,11 @@ import { api, q, usePrefetchedQuery } from './client' */ export type RoleKey = FleetRole | SiloRole | ProjectRole +/** + * The scope of a role assignment (silo-level or project-level). + */ +export type RoleScope = 'silo' | 'project' + /** Turn a role order record into a sorted array of strings. */ // used for displaying lists of roles, like in a @@ -87,7 +87,7 @@ export type UserAccessRow = { identityType: IdentityType name: string roleName: RoleKey - roleScope: RoleScope + roleSource: RoleSource } /** @@ -99,7 +99,7 @@ export type UserAccessRow = { */ export function useUserRows( roleAssignments: RoleAssignment[], - roleScope: RoleScope + roleSource: RoleSource ): UserAccessRow[] { // HACK: because the policy has no names, we are fetching ~all the users, // putting them in a dictionary, and adding the names to the rows @@ -114,9 +114,9 @@ export function useUserRows( identityType: ra.identityType, name: usersDict[ra.identityId]?.displayName || '', // placeholder until we get names, obviously roleName: ra.roleName, - roleScope, + roleSource, })) - }, [roleAssignments, roleScope, users, groups]) + }, [roleAssignments, roleSource, users, groups]) } type SortableUserRow = { identityType: IdentityType; name: string } diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index 800cc236f..56ea94069 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import type { RoleScope } from '@oxide/api' +import type { RoleSource } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -32,7 +32,7 @@ const buttonTextMap = { type AccessEmptyStateProps = { onClick: () => void - scope: RoleScope + scope: RoleSource filter?: 'all' | 'users' | 'groups' } diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index a4ecb3def..fc45a6fd5 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -42,7 +42,7 @@ export function useSiloAccessRows( throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) } - const siloRole = userAssignments.find((a) => a.roleScope === 'silo')?.roleName + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName const { name, identityType } = userAssignments[0] const row: SiloAccessRow = { @@ -76,8 +76,8 @@ export function useProjectAccessRows( const { name, identityType } = userAssignments[0] - const siloAccessRow = userAssignments.find((a) => a.roleScope === 'silo') - const projectAccessRow = userAssignments.find((a) => a.roleScope === 'project') + const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') + const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') // Filter out undefined values with proper type guard, then map to expected shape const roleBadges = R.sortBy( @@ -86,7 +86,7 @@ export function useProjectAccessRows( ), (r) => roleOrder[r.roleName] // sorts strongest role first ).map((r) => ({ - roleScope: r.roleScope, + roleSource: r.roleSource, roleName: r.roleName, })) diff --git a/app/pages/project/access/ProjectAccessAllTab.tsx b/app/pages/project/access/ProjectAccessAllTab.tsx index ada1b1c1b..2fc714c84 100644 --- a/app/pages/project/access/ProjectAccessAllTab.tsx +++ b/app/pages/project/access/ProjectAccessAllTab.tsx @@ -79,9 +79,9 @@ export default function ProjectAccessAllTab() { ), cell: (info) => ( - {info.getValue().map(({ roleName, roleScope }) => ( - - {roleScope}.{roleName} + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} ))} diff --git a/app/pages/project/access/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx index 71b261fc2..61affe84d 100644 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ b/app/pages/project/access/ProjectAccessGroupsTab.tsx @@ -70,9 +70,9 @@ export default function ProjectAccessGroupsTab() { ), cell: (info) => ( - {info.getValue().map(({ roleName, roleScope }) => ( - - {roleScope}.{roleName} + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} ))} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx index 42094a4ad..3d48fd914 100644 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ b/app/pages/project/access/ProjectAccessUsersTab.tsx @@ -68,9 +68,9 @@ export default function ProjectAccessUsersTab() { ), cell: (info) => ( - {info.getValue().map(({ roleName, roleScope }) => ( - - {roleScope}.{roleName} + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} ))} diff --git a/app/types/access.ts b/app/types/access.ts index 818807370..c95a11b88 100644 --- a/app/types/access.ts +++ b/app/types/access.ts @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import type { IdentityType, RoleKey, RoleScope } from '@oxide/api' +import type { IdentityType, RoleKey, RoleSource } from '@oxide/api' export type AccessRowBase = { id: string @@ -15,7 +15,7 @@ export type AccessRowBase = { export type ProjectAccessRow = AccessRowBase & { projectRole: RoleKey | undefined - roleBadges: { roleScope: RoleScope; roleName: RoleKey }[] + roleBadges: { roleSource: RoleSource; roleName: RoleKey }[] } export type SiloAccessRow = AccessRowBase & { From c1a13efc82780dab96480a0c1b20a9c58b859356 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 09:01:49 -0800 Subject: [PATCH 07/22] Refactor tabs --- app/components/AccessTab.tsx | 321 ++++++++++++++++++ app/hooks/use-access-rows.ts | 6 +- .../project/access/ProjectAccessAllTab.tsx | 164 --------- .../project/access/ProjectAccessGroupsTab.tsx | 148 -------- .../project/access/ProjectAccessUsersTab.tsx | 146 -------- app/pages/silo/access/SiloAccessAllTab.tsx | 130 ------- app/pages/silo/access/SiloAccessGroupsTab.tsx | 123 ------- app/pages/silo/access/SiloAccessUsersTab.tsx | 120 ------- app/routes.tsx | 37 +- 9 files changed, 330 insertions(+), 865 deletions(-) create mode 100644 app/components/AccessTab.tsx delete mode 100644 app/pages/project/access/ProjectAccessAllTab.tsx delete mode 100644 app/pages/project/access/ProjectAccessGroupsTab.tsx delete mode 100644 app/pages/project/access/ProjectAccessUsersTab.tsx delete mode 100644 app/pages/silo/access/SiloAccessAllTab.tsx delete mode 100644 app/pages/silo/access/SiloAccessGroupsTab.tsx delete mode 100644 app/pages/silo/access/SiloAccessUsersTab.tsx diff --git a/app/components/AccessTab.tsx b/app/components/AccessTab.tsx new file mode 100644 index 000000000..635c537e3 --- /dev/null +++ b/app/components/AccessTab.tsx @@ -0,0 +1,321 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState, type ReactNode } from 'react' + +import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { accessQueries } from '~/api/access-queries' +import { AccessEmptyState } from '~/components/AccessEmptyState' +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { + ProjectAccessAddUserSideModal, + ProjectAccessEditUserSideModal, +} from '~/forms/project-access' +import { + SiloAccessAddUserSideModal, + SiloAccessEditUserSideModal, +} from '~/forms/silo-access' +import { + useProjectAccessMutations, + useSiloAccessMutations, +} from '~/hooks/use-access-mutations' +import { useProjectAccessRows, useSiloAccessRows } from '~/hooks/use-access-rows' +import { useProjectSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import type { ProjectAccessRow, SiloAccessRow } from '~/types/access' +import { CreateButton } from '~/ui/lib/CreateButton' +import { TableActions } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { identityTypeLabel, roleColor } from '~/util/access' + +type IdentityFilter = 'all' | 'users' | 'groups' + +type AccessTabProps = { + /** Filter to apply to rows (all, users, or groups) */ + filter: IdentityFilter + /** Scope for the access (silo or project) */ + scope: 'silo' | 'project' + /** Optional additional content to render before the table */ + children?: ReactNode +} + +// Helper functions for repeated logic +const getIdentityLabel = (identityType: string) => + identityType === 'silo_user' ? 'user' : 'group' + +const getNoPermissionMessage = (action: 'change' | 'delete', identityType: string) => + `You don't have permission to ${action} this ${getIdentityLabel(identityType)}'s role` + +const getFilterEntityLabel = (filter: IdentityFilter) => + filter === 'all' ? 'user or group' : filter === 'users' ? 'user' : 'group' + +// Shared identity type column definition (using any for cell to work with both row types) +const identityTypeColumnDef = { + header: 'Type', + cell: (info: { getValue: () => keyof typeof identityTypeLabel }) => + identityTypeLabel[info.getValue()], +} + +// Type-safe table component for project scope +function ProjectAccessTable({ + filter, + rows, + policy, + projectName, + onEditRow, +}: { + filter: IdentityFilter + rows: ProjectAccessRow[] + policy: Policy + projectName: string + onEditRow: (row: ProjectAccessRow) => void +}) { + const { updatePolicy } = useProjectAccessMutations() + + // TODO: checkboxes and bulk delete? not sure + const columns = useMemo(() => { + const colHelper = createColumnHelper() + + return [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. + ...(filter === 'all' + ? [colHelper.accessor('identityType', identityTypeColumnDef)] + : []), + colHelper.accessor('roleBadges', { + header: () => ( + + Role + + A {getFilterEntityLabel(filter)}'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: ProjectAccessRow) => [ + { + label: 'Change role', + onActivate: () => onEditRow(row), + disabled: !row.projectRole && getNoPermissionMessage('change', row.identityType), + }, + { + label: 'Delete', + // TODO: explain that delete 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. + onActivate: confirmDelete({ + doDelete: async () => + await updatePolicy({ + path: { project: projectName }, + body: deleteRole(row.id, policy), + }), + label: ( + + the {row.projectRole} role for {row.name} + + ), + }), + disabled: !row.projectRole && getNoPermissionMessage('delete', row.identityType), + }, + ]), + ] + }, [filter, policy, projectName, updatePolicy, onEditRow]) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return
+} + +// Type-safe table component for silo scope +function SiloAccessTable({ + filter, + rows, + policy, + onEditRow, +}: { + filter: IdentityFilter + rows: SiloAccessRow[] + policy: Policy + onEditRow: (row: SiloAccessRow) => void +}) { + const { updatePolicy } = useSiloAccessMutations() + + // TODO: checkboxes and bulk delete? not sure + const columns = useMemo(() => { + const colHelper = createColumnHelper() + + return [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. + ...(filter === 'all' + ? [colHelper.accessor('identityType', identityTypeColumnDef)] + : []), + 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: SiloAccessRow) => [ + { + label: 'Change role', + onActivate: () => onEditRow(row), + disabled: !row.siloRole && getNoPermissionMessage('change', row.identityType), + }, + { + label: 'Delete', + // TODO: only show delete if you have permission to do this + onActivate: confirmDelete({ + doDelete: async () => + await updatePolicy({ + body: deleteRole(row.id, policy), + }), + label: ( + + the {row.siloRole} role for {row.name} + + ), + }), + // TODO: disable delete on permissions you can't delete + disabled: !row.siloRole && getNoPermissionMessage('delete', row.identityType), + }, + ]), + ] + }, [filter, policy, updatePolicy, onEditRow]) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return
+} + +/** + * Shared component for access control tabs (project and silo, all/users/groups). + * Handles the common table structure, modals, columns, and empty states. + */ +export function AccessTab({ filter, scope, children }: AccessTabProps) { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingRow, setEditingRow] = useState( + null + ) + + // Get project selector (only used when scope === 'project') + const projectSelector = useProjectSelector() + + // Fetch policies based on scope + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) + const { data: projectPolicy } = usePrefetchedQuery( + accessQueries.projectPolicy(projectSelector) + ) + const policy = scope === 'project' ? projectPolicy : siloPolicy + + // Generate rows based on scope and filter + const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') + const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') + const siloAccessRows = useSiloAccessRows(siloRows, filter) + const projectAccessRows = useProjectAccessRows(siloRows, projectRows, filter) + + const rows = scope === 'project' ? projectAccessRows : siloAccessRows + + // Generate button text based on filter + const addButtonText = `Add ${getFilterEntityLabel(filter)}` + + // Get role name based on scope - TypeScript allows us to access properties that exist on either type + const getRoleName = (row: ProjectAccessRow | SiloAccessRow) => + 'projectRole' in row ? row.projectRole : row.siloRole + + const editingRoleName = editingRow ? getRoleName(editingRow) : undefined + + // Select the appropriate modals based on scope + const AddModal = + scope === 'project' ? ProjectAccessAddUserSideModal : SiloAccessAddUserSideModal + const EditModal = + scope === 'project' ? ProjectAccessEditUserSideModal : SiloAccessEditUserSideModal + + return ( + <> + + setAddModalOpen(true)}>{addButtonText} + + {addModalOpen && ( + setAddModalOpen(false)} policy={policy} /> + )} + {editingRow && editingRoleName && ( + setEditingRow(null)} + policy={policy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRoleName }} + /> + )} + {children} + {rows.length === 0 ? ( + setAddModalOpen(true)} + /> + ) : scope === 'project' ? ( + setEditingRow(row)} + /> + ) : ( + setEditingRow(row)} + /> + )} + + ) +} diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index fc45a6fd5..bbfdd885b 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -79,11 +79,9 @@ export function useProjectAccessRows( const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') - // Filter out undefined values with proper type guard, then map to expected shape + // Filter out undefined values, then map to expected shape const roleBadges = R.sortBy( - [siloAccessRow, projectAccessRow].filter( - (r): r is UserAccessRow => r !== undefined - ), + [siloAccessRow, projectAccessRow].filter((r) => r !== undefined), (r) => roleOrder[r.roleName] // sorts strongest role first ).map((r) => ({ roleSource: r.roleSource, diff --git a/app/pages/project/access/ProjectAccessAllTab.tsx b/app/pages/project/access/ProjectAccessAllTab.tsx deleted file mode 100644 index 2fc714c84..000000000 --- a/app/pages/project/access/ProjectAccessAllTab.tsx +++ /dev/null @@ -1,164 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { useProjectAccessMutations } from '~/hooks/use-access-mutations' -import { useProjectAccessRows } from '~/hooks/use-access-rows' -import { useProjectSelector } from '~/hooks/use-params' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { ProjectAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function ProjectAccessAllTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - const projectSelector = useProjectSelector() - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - - const { data: projectPolicy } = usePrefetchedQuery( - accessQueries.projectPolicy(projectSelector) - ) - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const rows = useProjectAccessRows(siloRows, projectRows, 'all') - - const { updatePolicy } = useProjectAccessMutations() - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. - 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: ProjectAccessRow) => [ - { - 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 - - {addModalOpen && ( - setAddModalOpen(false)} - policy={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/ProjectAccessGroupsTab.tsx b/app/pages/project/access/ProjectAccessGroupsTab.tsx deleted file mode 100644 index 61affe84d..000000000 --- a/app/pages/project/access/ProjectAccessGroupsTab.tsx +++ /dev/null @@ -1,148 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { useProjectAccessMutations } from '~/hooks/use-access-mutations' -import { useProjectAccessRows } from '~/hooks/use-access-rows' -import { useProjectSelector } from '~/hooks/use-params' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { ProjectAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function ProjectAccessGroupsTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - const projectSelector = useProjectSelector() - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - - const { data: projectPolicy } = usePrefetchedQuery( - accessQueries.projectPolicy(projectSelector) - ) - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const rows = useProjectAccessRows(siloRows, projectRows, 'groups') - - const { updatePolicy } = useProjectAccessMutations() - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - colHelper.accessor('roleBadges', { - header: () => ( - - Role - - A group's effective role for this project is the strongest role on either - the silo or project - - - ), - cell: (info) => ( - - {info.getValue().map(({ roleName, roleSource }) => ( - - {roleSource}.{roleName} - - ))} - - ), - }), - - getActionsCol((row: ProjectAccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.projectRole && "You don't have permission to change this group's role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - path: { project: projectSelector.project }, - body: deleteRole(row.id, projectPolicy), - }), - label: ( - - the {row.projectRole} role for {row.name} - - ), - }), - disabled: !row.projectRole && "You don't have permission to delete this group", - }, - ]), - ], - [projectPolicy, projectSelector.project, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - return ( - <> - - setAddModalOpen(true)}>Add group - - {addModalOpen && ( - setAddModalOpen(false)} - policy={projectPolicy} - /> - )} - {editingUserRow?.projectRole && ( - setEditingUserRow(null)} - policy={projectPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.projectRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} - /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/project/access/ProjectAccessUsersTab.tsx b/app/pages/project/access/ProjectAccessUsersTab.tsx deleted file mode 100644 index 3d48fd914..000000000 --- a/app/pages/project/access/ProjectAccessUsersTab.tsx +++ /dev/null @@ -1,146 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { useProjectAccessMutations } from '~/hooks/use-access-mutations' -import { useProjectAccessRows } from '~/hooks/use-access-rows' -import { useProjectSelector } from '~/hooks/use-params' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { ProjectAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function ProjectAccessUsersTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - const projectSelector = useProjectSelector() - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - - const { data: projectPolicy } = usePrefetchedQuery( - accessQueries.projectPolicy(projectSelector) - ) - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const rows = useProjectAccessRows(siloRows, projectRows, 'users') - - const { updatePolicy } = useProjectAccessMutations() - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add lastAccessed column once API provides it. The User type should include - // a lastAccessed timestamp field to show when each user last logged in or accessed - // the system. This helps identify inactive users. - colHelper.accessor('roleBadges', { - header: () => ( - - Role - - A user's effective role for this project is the strongest role on either - the silo or project - - - ), - cell: (info) => ( - - {info.getValue().map(({ roleName, roleSource }) => ( - - {roleSource}.{roleName} - - ))} - - ), - }), - - getActionsCol((row: ProjectAccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.projectRole && "You don't have permission to change this user's role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - path: { project: projectSelector.project }, - body: deleteRole(row.id, projectPolicy), - }), - label: ( - - the {row.projectRole} role for {row.name} - - ), - }), - 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 - - {addModalOpen && ( - setAddModalOpen(false)} - policy={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/silo/access/SiloAccessAllTab.tsx b/app/pages/silo/access/SiloAccessAllTab.tsx deleted file mode 100644 index 764c5d144..000000000 --- a/app/pages/silo/access/SiloAccessAllTab.tsx +++ /dev/null @@ -1,130 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { useSiloAccessMutations } from '~/hooks/use-access-mutations' -import { useSiloAccessRows } from '~/hooks/use-access-rows' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { SiloAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { identityTypeLabel, roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function SiloAccessAllTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const rows = useSiloAccessRows(siloRows, 'all') - - const { updatePolicy } = useSiloAccessMutations() - - // TODO: checkboxes and bulk delete? not sure - // TODO: disable delete on permissions you can't delete - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. - 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: SiloAccessRow) => [ - { - 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 - - {addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {editingUserRow?.siloRole && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/silo/access/SiloAccessGroupsTab.tsx b/app/pages/silo/access/SiloAccessGroupsTab.tsx deleted file mode 100644 index 5d8bcf4b5..000000000 --- a/app/pages/silo/access/SiloAccessGroupsTab.tsx +++ /dev/null @@ -1,123 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { useSiloAccessMutations } from '~/hooks/use-access-mutations' -import { useSiloAccessRows } from '~/hooks/use-access-rows' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { SiloAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function SiloAccessGroupsTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const rows = useSiloAccessRows(siloRows, 'groups') - - const { updatePolicy } = useSiloAccessMutations() - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - colHelper.accessor('siloRole', { - header: 'Role', - cell: (info) => { - const role = info.getValue() - return role ? silo.{role} : null - }, - }), - getActionsCol((row: SiloAccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: - !row.siloRole && "You don't have permission to change this group's role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - body: deleteRole(row.id, siloPolicy), - }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - }), - disabled: !row.siloRole && "You don't have permission to delete this group", - }, - ]), - ], - [siloPolicy, updatePolicy] - ) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - return ( - <> - - setAddModalOpen(true)}>Add group - - {addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {editingUserRow?.siloRole && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} - /> - ) : ( -
- )} - - ) -} diff --git a/app/pages/silo/access/SiloAccessUsersTab.tsx b/app/pages/silo/access/SiloAccessUsersTab.tsx deleted file mode 100644 index dc370142d..000000000 --- a/app/pages/silo/access/SiloAccessUsersTab.tsx +++ /dev/null @@ -1,120 +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 { deleteRole, usePrefetchedQuery, useUserRows } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { useSiloAccessMutations } from '~/hooks/use-access-mutations' -import { useSiloAccessRows } from '~/hooks/use-access-rows' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { SiloAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { roleColor } from '~/util/access' - -const colHelper = createColumnHelper() - -export default function SiloAccessUsersTab() { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingUserRow, setEditingUserRow] = useState(null) - - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const rows = useSiloAccessRows(siloRows, 'users') - - const { updatePolicy } = useSiloAccessMutations() - - const columns = useMemo( - () => [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add lastAccessed column once API provides it. The User type should include - // a lastAccessed timestamp field to show when each user last logged in or accessed - // the system. This helps identify inactive users. - colHelper.accessor('siloRole', { - header: 'Role', - cell: (info) => { - const role = info.getValue() - return role ? silo.{role} : null - }, - }), - getActionsCol((row: SiloAccessRow) => [ - { - label: 'Change role', - onActivate: () => setEditingUserRow(row), - disabled: !row.siloRole && "You don't have permission to change this user's role", - }, - { - label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - updatePolicy({ - 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 - - {addModalOpen && ( - setAddModalOpen(false)} - policy={siloPolicy} - /> - )} - {editingUserRow?.siloRole && ( - setEditingUserRow(null)} - policy={siloPolicy} - name={editingUserRow.name} - identityId={editingUserRow.id} - identityType={editingUserRow.identityType} - defaultValues={{ roleName: editingUserRow.siloRole }} - /> - )} - {rows.length === 0 ? ( - setAddModalOpen(true)} - /> - ) : ( -
- )} - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 49f6c97a4..f6221c718 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -14,6 +14,7 @@ import { type LoaderFunctionArgs, } from 'react-router' +import { AccessTab } from './components/AccessTab' import { NotFound } from './components/ErrorPage' import { PageSkeleton } from './components/PageSkeleton.tsx' import { makeCrumb, type Crumb } from './hooks/use-crumbs' @@ -281,18 +282,9 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/silo/access/SiloAccessPage').then(convert)} > } /> - import('./pages/silo/access/SiloAccessAllTab').then(convert)} - /> - import('./pages/silo/access/SiloAccessUsersTab').then(convert)} - /> - import('./pages/silo/access/SiloAccessGroupsTab').then(convert)} - /> + } /> + } /> + } /> @@ -547,24 +539,9 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} > } /> - - import('./pages/project/access/ProjectAccessAllTab').then(convert) - } - /> - - import('./pages/project/access/ProjectAccessUsersTab').then(convert) - } - /> - - import('./pages/project/access/ProjectAccessGroupsTab').then(convert) - } - /> + } /> + } /> + } /> import('./pages/project/affinity/AffinityPage').then(convert)} From 261492e7743ed09b51c3edab7bb554c1d2b226d9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 09:52:22 -0800 Subject: [PATCH 08/22] Split again so hooks work correctly --- app/components/AccessTab.tsx | 321 ---------------------------- app/components/ProjectAccessTab.tsx | 195 +++++++++++++++++ app/components/SiloAccessTab.tsx | 164 ++++++++++++++ app/components/access/shared.tsx | 38 ++++ app/routes.tsx | 15 +- 5 files changed, 405 insertions(+), 328 deletions(-) delete mode 100644 app/components/AccessTab.tsx create mode 100644 app/components/ProjectAccessTab.tsx create mode 100644 app/components/SiloAccessTab.tsx create mode 100644 app/components/access/shared.tsx diff --git a/app/components/AccessTab.tsx b/app/components/AccessTab.tsx deleted file mode 100644 index 635c537e3..000000000 --- a/app/components/AccessTab.tsx +++ /dev/null @@ -1,321 +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, type ReactNode } from 'react' - -import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' - -import { accessQueries } from '~/api/access-queries' -import { AccessEmptyState } from '~/components/AccessEmptyState' -import { HL } from '~/components/HL' -import { ListPlusCell } from '~/components/ListPlusCell' -import { - ProjectAccessAddUserSideModal, - ProjectAccessEditUserSideModal, -} from '~/forms/project-access' -import { - SiloAccessAddUserSideModal, - SiloAccessEditUserSideModal, -} from '~/forms/silo-access' -import { - useProjectAccessMutations, - useSiloAccessMutations, -} from '~/hooks/use-access-mutations' -import { useProjectAccessRows, useSiloAccessRows } from '~/hooks/use-access-rows' -import { useProjectSelector } from '~/hooks/use-params' -import { confirmDelete } from '~/stores/confirm-delete' -import { getActionsCol } from '~/table/columns/action-col' -import { Table } from '~/table/Table' -import type { ProjectAccessRow, SiloAccessRow } from '~/types/access' -import { CreateButton } from '~/ui/lib/CreateButton' -import { TableActions } from '~/ui/lib/Table' -import { TipIcon } from '~/ui/lib/TipIcon' -import { identityTypeLabel, roleColor } from '~/util/access' - -type IdentityFilter = 'all' | 'users' | 'groups' - -type AccessTabProps = { - /** Filter to apply to rows (all, users, or groups) */ - filter: IdentityFilter - /** Scope for the access (silo or project) */ - scope: 'silo' | 'project' - /** Optional additional content to render before the table */ - children?: ReactNode -} - -// Helper functions for repeated logic -const getIdentityLabel = (identityType: string) => - identityType === 'silo_user' ? 'user' : 'group' - -const getNoPermissionMessage = (action: 'change' | 'delete', identityType: string) => - `You don't have permission to ${action} this ${getIdentityLabel(identityType)}'s role` - -const getFilterEntityLabel = (filter: IdentityFilter) => - filter === 'all' ? 'user or group' : filter === 'users' ? 'user' : 'group' - -// Shared identity type column definition (using any for cell to work with both row types) -const identityTypeColumnDef = { - header: 'Type', - cell: (info: { getValue: () => keyof typeof identityTypeLabel }) => - identityTypeLabel[info.getValue()], -} - -// Type-safe table component for project scope -function ProjectAccessTable({ - filter, - rows, - policy, - projectName, - onEditRow, -}: { - filter: IdentityFilter - rows: ProjectAccessRow[] - policy: Policy - projectName: string - onEditRow: (row: ProjectAccessRow) => void -}) { - const { updatePolicy } = useProjectAccessMutations() - - // TODO: checkboxes and bulk delete? not sure - const columns = useMemo(() => { - const colHelper = createColumnHelper() - - return [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. - ...(filter === 'all' - ? [colHelper.accessor('identityType', identityTypeColumnDef)] - : []), - colHelper.accessor('roleBadges', { - header: () => ( - - Role - - A {getFilterEntityLabel(filter)}'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: ProjectAccessRow) => [ - { - label: 'Change role', - onActivate: () => onEditRow(row), - disabled: !row.projectRole && getNoPermissionMessage('change', row.identityType), - }, - { - label: 'Delete', - // TODO: explain that delete 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. - onActivate: confirmDelete({ - doDelete: async () => - await updatePolicy({ - path: { project: projectName }, - body: deleteRole(row.id, policy), - }), - label: ( - - the {row.projectRole} role for {row.name} - - ), - }), - disabled: !row.projectRole && getNoPermissionMessage('delete', row.identityType), - }, - ]), - ] - }, [filter, policy, projectName, updatePolicy, onEditRow]) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - return
-} - -// Type-safe table component for silo scope -function SiloAccessTable({ - filter, - rows, - policy, - onEditRow, -}: { - filter: IdentityFilter - rows: SiloAccessRow[] - policy: Policy - onEditRow: (row: SiloAccessRow) => void -}) { - const { updatePolicy } = useSiloAccessMutations() - - // TODO: checkboxes and bulk delete? not sure - const columns = useMemo(() => { - const colHelper = createColumnHelper() - - return [ - colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. - ...(filter === 'all' - ? [colHelper.accessor('identityType', identityTypeColumnDef)] - : []), - 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: SiloAccessRow) => [ - { - label: 'Change role', - onActivate: () => onEditRow(row), - disabled: !row.siloRole && getNoPermissionMessage('change', row.identityType), - }, - { - label: 'Delete', - // TODO: only show delete if you have permission to do this - onActivate: confirmDelete({ - doDelete: async () => - await updatePolicy({ - body: deleteRole(row.id, policy), - }), - label: ( - - the {row.siloRole} role for {row.name} - - ), - }), - // TODO: disable delete on permissions you can't delete - disabled: !row.siloRole && getNoPermissionMessage('delete', row.identityType), - }, - ]), - ] - }, [filter, policy, updatePolicy, onEditRow]) - - const tableInstance = useReactTable({ - columns, - data: rows, - getCoreRowModel: getCoreRowModel(), - }) - - return
-} - -/** - * Shared component for access control tabs (project and silo, all/users/groups). - * Handles the common table structure, modals, columns, and empty states. - */ -export function AccessTab({ filter, scope, children }: AccessTabProps) { - const [addModalOpen, setAddModalOpen] = useState(false) - const [editingRow, setEditingRow] = useState( - null - ) - - // Get project selector (only used when scope === 'project') - const projectSelector = useProjectSelector() - - // Fetch policies based on scope - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) - const { data: projectPolicy } = usePrefetchedQuery( - accessQueries.projectPolicy(projectSelector) - ) - const policy = scope === 'project' ? projectPolicy : siloPolicy - - // Generate rows based on scope and filter - const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') - const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') - const siloAccessRows = useSiloAccessRows(siloRows, filter) - const projectAccessRows = useProjectAccessRows(siloRows, projectRows, filter) - - const rows = scope === 'project' ? projectAccessRows : siloAccessRows - - // Generate button text based on filter - const addButtonText = `Add ${getFilterEntityLabel(filter)}` - - // Get role name based on scope - TypeScript allows us to access properties that exist on either type - const getRoleName = (row: ProjectAccessRow | SiloAccessRow) => - 'projectRole' in row ? row.projectRole : row.siloRole - - const editingRoleName = editingRow ? getRoleName(editingRow) : undefined - - // Select the appropriate modals based on scope - const AddModal = - scope === 'project' ? ProjectAccessAddUserSideModal : SiloAccessAddUserSideModal - const EditModal = - scope === 'project' ? ProjectAccessEditUserSideModal : SiloAccessEditUserSideModal - - return ( - <> - - setAddModalOpen(true)}>{addButtonText} - - {addModalOpen && ( - setAddModalOpen(false)} policy={policy} /> - )} - {editingRow && editingRoleName && ( - setEditingRow(null)} - policy={policy} - name={editingRow.name} - identityId={editingRow.id} - identityType={editingRow.identityType} - defaultValues={{ roleName: editingRoleName }} - /> - )} - {children} - {rows.length === 0 ? ( - setAddModalOpen(true)} - /> - ) : scope === 'project' ? ( - setEditingRow(row)} - /> - ) : ( - setEditingRow(row)} - /> - )} - - ) -} diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx new file mode 100644 index 000000000..fc9350615 --- /dev/null +++ b/app/components/ProjectAccessTab.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, type ReactNode } from 'react' + +import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { accessQueries } from '~/api/access-queries' +import { + getFilterEntityLabel, + getNoPermissionMessage, + identityTypeColumnDef, + type IdentityFilter, +} from '~/components/access/shared' +import { AccessEmptyState } from '~/components/AccessEmptyState' +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { + ProjectAccessAddUserSideModal, + ProjectAccessEditUserSideModal, +} from '~/forms/project-access' +import { useProjectAccessMutations } from '~/hooks/use-access-mutations' +import { useProjectAccessRows } from '~/hooks/use-access-rows' +import { useProjectSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import type { ProjectAccessRow } from '~/types/access' +import { CreateButton } from '~/ui/lib/CreateButton' +import { TableActions } from '~/ui/lib/Table' +import { TipIcon } from '~/ui/lib/TipIcon' +import { roleColor } from '~/util/access' + +type ProjectAccessTabProps = { + filter: IdentityFilter + children?: ReactNode +} + +function ProjectAccessTable({ + filter, + rows, + policy, + projectName, + onEditRow, +}: { + filter: IdentityFilter + rows: ProjectAccessRow[] + policy: Policy + projectName: string + onEditRow: (row: ProjectAccessRow) => void +}) { + const { updatePolicy } = useProjectAccessMutations() + + const columns = useMemo(() => { + const colHelper = createColumnHelper() + + return [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. + ...(filter === 'all' + ? [colHelper.accessor('identityType', identityTypeColumnDef)] + : []), + colHelper.accessor('roleBadges', { + header: () => ( + + Role + + A {getFilterEntityLabel(filter)}'s effective role for this project is the + strongest role on either the silo or project + + + ), + cell: (info) => ( + + {info.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} + + ))} + + ), + }), + getActionsCol((row: ProjectAccessRow) => [ + { + label: 'Change role', + onActivate: () => onEditRow(row), + disabled: !row.projectRole && getNoPermissionMessage('change', row.identityType), + }, + { + label: 'Delete', + // TODO: explain that delete 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. + onActivate: confirmDelete({ + doDelete: async () => + await updatePolicy({ + path: { project: projectName }, + body: deleteRole(row.id, policy), + }), + label: ( + + the {row.projectRole} role for {row.name} + + ), + }), + disabled: !row.projectRole && getNoPermissionMessage('delete', row.identityType), + }, + ]), + ] + }, [filter, policy, projectName, updatePolicy, onEditRow]) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return
+} + +/** + * Access control tab for project-level permissions. + * Displays users and groups with their project and inherited silo roles, + * and allows adding/editing/deleting role assignments. + */ +export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingRow, setEditingRow] = useState(null) + + const projectSelector = useProjectSelector() + + const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) + const { data: projectPolicy } = usePrefetchedQuery( + accessQueries.projectPolicy(projectSelector) + ) + + const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') + const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') + const rows = useProjectAccessRows(siloRows, projectRows, filter) + + const addButtonText = `Add ${getFilterEntityLabel(filter)}` + + return ( + <> + + setAddModalOpen(true)}>{addButtonText} + + {addModalOpen && ( + setAddModalOpen(false)} + policy={projectPolicy} + /> + )} + {editingRow && editingRow.projectRole && ( + setEditingRow(null)} + policy={projectPolicy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.projectRole }} + /> + )} + {children} + {rows.length === 0 ? ( + setAddModalOpen(true)} + /> + ) : ( + + )} + + ) +} diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx new file mode 100644 index 000000000..c1a1a6409 --- /dev/null +++ b/app/components/SiloAccessTab.tsx @@ -0,0 +1,164 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useMemo, useState, type ReactNode } from 'react' + +import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { accessQueries } from '~/api/access-queries' +import { + getFilterEntityLabel, + getNoPermissionMessage, + identityTypeColumnDef, + type IdentityFilter, +} from '~/components/access/shared' +import { AccessEmptyState } from '~/components/AccessEmptyState' +import { HL } from '~/components/HL' +import { + SiloAccessAddUserSideModal, + SiloAccessEditUserSideModal, +} from '~/forms/silo-access' +import { useSiloAccessMutations } from '~/hooks/use-access-mutations' +import { useSiloAccessRows } from '~/hooks/use-access-rows' +import { confirmDelete } from '~/stores/confirm-delete' +import { getActionsCol } from '~/table/columns/action-col' +import { Table } from '~/table/Table' +import type { SiloAccessRow } from '~/types/access' +import { CreateButton } from '~/ui/lib/CreateButton' +import { TableActions } from '~/ui/lib/Table' +import { roleColor } from '~/util/access' + +type SiloAccessTabProps = { + filter: IdentityFilter + children?: ReactNode +} + +function SiloAccessTable({ + filter, + rows, + policy, + onEditRow, +}: { + filter: IdentityFilter + rows: SiloAccessRow[] + policy: Policy + onEditRow: (row: SiloAccessRow) => void +}) { + const { updatePolicy } = useSiloAccessMutations() + + const columns = useMemo(() => { + const colHelper = createColumnHelper() + + return [ + colHelper.accessor('name', { header: 'Name' }), + // TODO: Add member information for groups once API provides it. Ideally: + // 1. A /groups/{groupId}/members endpoint to list members + // 2. A memberCount field on the Group type + // This would allow showing member count in the table and displaying members + // in a tooltip or expandable row. + // TODO: Add lastAccessed column for users once API provides it. The User type + // should include a lastAccessed timestamp to show when users last logged in. + ...(filter === 'all' + ? [colHelper.accessor('identityType', identityTypeColumnDef)] + : []), + colHelper.accessor('siloRole', { + header: 'Role', + cell: (info) => { + const role = info.getValue() + return role ? silo.{role} : null + }, + }), + getActionsCol((row: SiloAccessRow) => [ + { + label: 'Change role', + onActivate: () => onEditRow(row), + disabled: !row.siloRole && getNoPermissionMessage('change', row.identityType), + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: async () => + await updatePolicy({ + body: deleteRole(row.id, policy), + }), + label: ( + + the {row.siloRole} role for {row.name} + + ), + }), + // TODO: disable delete on permissions you can't delete + disabled: !row.siloRole && getNoPermissionMessage('delete', row.identityType), + }, + ]), + ] + }, [filter, policy, updatePolicy, onEditRow]) + + const tableInstance = useReactTable({ + columns, + data: rows, + getCoreRowModel: getCoreRowModel(), + }) + + return
+} + +/** + * Access control tab for silo-level permissions. + * Displays users and groups with their silo roles, and allows adding/editing/deleting role assignments. + */ +export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { + const [addModalOpen, setAddModalOpen] = useState(false) + const [editingRow, setEditingRow] = useState(null) + + const { data: policy } = usePrefetchedQuery(accessQueries.siloPolicy()) + const siloRows = useUserRows(policy.roleAssignments, 'silo') + const rows = useSiloAccessRows(siloRows, filter) + + const addButtonText = `Add ${getFilterEntityLabel(filter)}` + + return ( + <> + + setAddModalOpen(true)}>{addButtonText} + + {addModalOpen && ( + setAddModalOpen(false)} + policy={policy} + /> + )} + {editingRow && editingRow.siloRole && ( + setEditingRow(null)} + policy={policy} + name={editingRow.name} + identityId={editingRow.id} + identityType={editingRow.identityType} + defaultValues={{ roleName: editingRow.siloRole }} + /> + )} + {children} + {rows.length === 0 ? ( + setAddModalOpen(true)} + /> + ) : ( + + )} + + ) +} diff --git a/app/components/access/shared.tsx b/app/components/access/shared.tsx new file mode 100644 index 000000000..f17dd9595 --- /dev/null +++ b/app/components/access/shared.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 { identityTypeLabel } from '~/util/access' + +export type IdentityFilter = 'all' | 'users' | 'groups' + +/** + * Converts an identity type to a user-friendly label + */ +export const getIdentityLabel = (identityType: string) => + identityType === 'silo_user' ? 'user' : 'group' + +/** + * Generates a permission error message for disabled actions + */ +export const getNoPermissionMessage = (action: 'change' | 'delete', identityType: string) => + `You don't have permission to ${action} this ${getIdentityLabel(identityType)}'s role` + +/** + * Returns a label based on the current filter (e.g., "user or group", "user", "group") + */ +export const getFilterEntityLabel = (filter: IdentityFilter) => + filter === 'all' ? 'user or group' : filter === 'users' ? 'user' : 'group' + +/** + * Shared identity type column definition for use in both silo and project tables + */ +export const identityTypeColumnDef = { + header: 'Type', + cell: (info: { getValue: () => keyof typeof identityTypeLabel }) => + identityTypeLabel[info.getValue()], +} diff --git a/app/routes.tsx b/app/routes.tsx index f6221c718..bc98e650d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -14,9 +14,10 @@ import { type LoaderFunctionArgs, } from 'react-router' -import { AccessTab } from './components/AccessTab' import { NotFound } from './components/ErrorPage' import { PageSkeleton } from './components/PageSkeleton.tsx' +import { ProjectAccessTab } from './components/ProjectAccessTab' +import { SiloAccessTab } from './components/SiloAccessTab' import { makeCrumb, type Crumb } from './hooks/use-crumbs' import { getInstanceSelector, getVpcSelector } from './hooks/use-params' import { pb } from './util/path-builder' @@ -282,9 +283,9 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/silo/access/SiloAccessPage').then(convert)} > } /> - } /> - } /> - } /> + } /> + } /> + } /> @@ -539,9 +540,9 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} > } /> - } /> - } /> - } /> + } /> + } /> + } /> import('./pages/project/affinity/AffinityPage').then(convert)} From 5e130c5cafd76370bb37ad58e8a882aceb8307ae Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 12:15:05 -0800 Subject: [PATCH 09/22] More refactoring --- app/components/AccessEmptyState.tsx | 3 ++- app/components/ProjectAccessTab.tsx | 3 +-- app/components/SiloAccessTab.tsx | 3 +-- app/components/access/shared.tsx | 3 +-- app/hooks/use-access-rows.ts | 4 +--- app/types/access.ts | 2 ++ 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index 56ea94069..b5dab3ee3 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -8,6 +8,7 @@ import type { RoleSource } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' +import type { IdentityFilter } from '~/types/access' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' @@ -33,7 +34,7 @@ const buttonTextMap = { type AccessEmptyStateProps = { onClick: () => void scope: RoleSource - filter?: 'all' | 'users' | 'groups' + filter?: IdentityFilter } export const AccessEmptyState = ({ diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index fc9350615..63901e4af 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -16,7 +16,6 @@ import { getFilterEntityLabel, getNoPermissionMessage, identityTypeColumnDef, - type IdentityFilter, } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' @@ -31,7 +30,7 @@ import { useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' -import type { ProjectAccessRow } from '~/types/access' +import type { IdentityFilter, ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index c1a1a6409..2b3cb3dc1 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -16,7 +16,6 @@ import { getFilterEntityLabel, getNoPermissionMessage, identityTypeColumnDef, - type IdentityFilter, } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' @@ -29,7 +28,7 @@ import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' -import type { SiloAccessRow } from '~/types/access' +import type { IdentityFilter, SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { roleColor } from '~/util/access' diff --git a/app/components/access/shared.tsx b/app/components/access/shared.tsx index f17dd9595..88c540d79 100644 --- a/app/components/access/shared.tsx +++ b/app/components/access/shared.tsx @@ -6,10 +6,9 @@ * Copyright Oxide Computer Company */ +import type { IdentityFilter } from '~/types/access' import { identityTypeLabel } from '~/util/access' -export type IdentityFilter = 'all' | 'users' | 'groups' - /** * Converts an identity type to a user-friendly label */ diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index bbfdd885b..fa1dc7905 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -16,11 +16,9 @@ import { type UserAccessRow, } from '@oxide/api' -import type { ProjectAccessRow, SiloAccessRow } from '~/types/access' +import type { IdentityFilter, ProjectAccessRow, SiloAccessRow } from '~/types/access' import { groupBy } from '~/util/array' -type IdentityFilter = 'all' | 'users' | 'groups' - /** Filter rows by identity type based on the filter parameter */ function filterByIdentityType( rows: T[], diff --git a/app/types/access.ts b/app/types/access.ts index c95a11b88..702c411c0 100644 --- a/app/types/access.ts +++ b/app/types/access.ts @@ -7,6 +7,8 @@ */ import type { IdentityType, RoleKey, RoleSource } from '@oxide/api' +export type IdentityFilter = 'all' | 'users' | 'groups' + export type AccessRowBase = { id: string identityType: IdentityType From 1c1caafcf64150e545008ac28cda82e0dbfedb1c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 12:46:34 -0800 Subject: [PATCH 10/22] Post review adjustments --- app/components/ProjectAccessTab.tsx | 4 +- app/components/SiloAccessTab.tsx | 4 +- app/hooks/use-access-rows.ts | 45 +++++++++----- .../project/access/ProjectAccessPage.tsx | 18 ++---- app/pages/silo/access/SiloAccessPage.tsx | 13 ++-- test/e2e/silo-access.e2e.ts | 62 +++++++++++++++++++ 6 files changed, 105 insertions(+), 41 deletions(-) diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 63901e4af..d6ce8e9ab 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -157,13 +157,13 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { setAddModalOpen(true)}>{addButtonText} - {addModalOpen && ( + {projectPolicy && addModalOpen && ( setAddModalOpen(false)} policy={projectPolicy} /> )} - {editingRow && editingRow.projectRole && ( + {projectPolicy && editingRow && editingRow.projectRole && ( setEditingRow(null)} policy={projectPolicy} diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 2b3cb3dc1..c99e58f55 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -127,13 +127,13 @@ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { setAddModalOpen(true)}>{addButtonText} - {addModalOpen && ( + {policy && addModalOpen && ( setAddModalOpen(false)} policy={policy} /> )} - {editingRow && editingRow.siloRole && ( + {policy && editingRow && editingRow.siloRole && ( setEditingRow(null)} policy={policy} diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index fa1dc7905..27190fd05 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -34,26 +34,37 @@ export function useSiloAccessRows( filter: IdentityFilter = 'all' ): SiloAccessRow[] { return useMemo(() => { - const rows = groupBy(siloRows, (u) => u.id).map(([userId, userAssignments]) => { - // groupBy always produces non-empty arrays, but add guard for safety - if (userAssignments.length === 0) { - throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) - } + const rows = groupBy(siloRows, (u) => u.id) + .map(([userId, userAssignments]) => { + // groupBy always produces non-empty arrays, but add guard for safety + if (userAssignments.length === 0) { + throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) + } - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const { name, identityType } = userAssignments[0] + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + const { name, identityType } = userAssignments[0] - const row: SiloAccessRow = { - id: userId, - identityType, - name, - siloRole, - // All users in silo policy have a silo role (guaranteed by API) - effectiveRole: getEffectiveRole([siloRole!])!, - } + // Skip rows without a silo role (shouldn't happen in normal operation, but be defensive) + if (!siloRole) { + return null + } + + const effectiveRole = getEffectiveRole([siloRole]) + if (!effectiveRole) { + return null + } + + const row: SiloAccessRow = { + id: userId, + identityType, + name, + siloRole, + effectiveRole, + } - return row - }) + return row + }) + .filter((row): row is SiloAccessRow => row !== null) return filterByIdentityType(rows, filter).sort(byGroupThenName) }, [siloRows, filter]) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 5f6b26843..598551ce5 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -7,31 +7,25 @@ */ import type { LoaderFunctionArgs } from 'react-router' -import { api, q, queryClient } from '@oxide/api' +import { queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { accessQueries } from '~/api/access-queries' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' 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 userList = q(api.userList, {}) -const groupList = q(api.groupList, {}) export async function clientLoader({ params }: LoaderFunctionArgs) { const selector = getProjectSelector(params) await Promise.all([ - queryClient.prefetchQuery(policyView), - queryClient.prefetchQuery(projectPolicyView(selector)), + queryClient.prefetchQuery(accessQueries.siloPolicy()), + queryClient.prefetchQuery(accessQueries.projectPolicy(selector)), // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(accessQueries.userList()), + queryClient.prefetchQuery(accessQueries.groupList()), ]) return null } diff --git a/app/pages/silo/access/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessPage.tsx index 62c6a936f..7e75a8429 100644 --- a/app/pages/silo/access/SiloAccessPage.tsx +++ b/app/pages/silo/access/SiloAccessPage.tsx @@ -6,25 +6,22 @@ * Copyright Oxide Computer Company */ -import { api, q, queryClient } from '@oxide/api' +import { queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' +import { accessQueries } from '~/api/access-queries' 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' -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), + queryClient.prefetchQuery(accessQueries.siloPolicy()), // used to resolve user names - queryClient.prefetchQuery(userList), - queryClient.prefetchQuery(groupList), + queryClient.prefetchQuery(accessQueries.userList()), + queryClient.prefetchQuery(accessQueries.groupList()), ]) return null } diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 720ef8ba1..54f372aee 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -80,3 +80,65 @@ test('Click through silo access page', async ({ page }) => { await page.getByRole('button', { name: 'Confirm' }).click() await expect(user3Row).toBeHidden() }) + +test('Add user on All tab and verify on Users tab', async ({ page }) => { + await page.goto('/access/all') + + // Start on the All tab + await expectVisible(page, ['role=tab[name="All"][selected]']) + + const allTable = page.locator('table') + + // Add Hans Jonas as viewer + 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"]') + await page.click('role=option[name="Hans Jonas"]') + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Assign role"]') + + // User shows up in the All tab table + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: 'silo.viewer', + }) + + // Navigate to Users tab + await page.click('role=tab[name="Users"]') + await expectVisible(page, ['role=tab[name="Users"][selected]']) + + // Verify the URL changed to /users + await expect(page).toHaveURL(/\/access\/users$/) + + const usersTable = page.locator('table') + + // User should still be visible on Users tab + await expectRowVisible(usersTable, { + Name: 'Hans Jonas', + Role: 'silo.viewer', + }) + + // Type column should not be present on Users tab (since all are users) + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Navigate to Groups tab + await page.click('role=tab[name="Groups"]') + await expectVisible(page, ['role=tab[name="Groups"][selected]']) + + // Hans Jonas should NOT be visible on Groups tab + await expectNotVisible(page, ['role=cell[name="Hans Jonas"]']) + + // Type column should not be present on Groups tab either + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Go back to All tab and verify Hans Jonas is still there + await page.click('role=tab[name="All"]') + await expectVisible(page, ['role=tab[name="All"][selected]']) + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: 'silo.viewer', + }) +}) From e0b79a51e5e70a9c9e7c71a4dfbb2938061c24b9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 14:32:50 -0800 Subject: [PATCH 11/22] Refactor --- app/components/ProjectAccessTab.tsx | 6 +++--- app/components/SiloAccessTab.tsx | 8 +------- app/components/access/shared.tsx | 9 +++++++++ app/hooks/use-access-rows.ts | 4 +++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index d6ce8e9ab..13ffd2797 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -14,7 +14,7 @@ import { Badge } from '@oxide/design-system/ui' import { accessQueries } from '~/api/access-queries' import { getFilterEntityLabel, - getNoPermissionMessage, + getInheritedRoleMessage, identityTypeColumnDef, } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' @@ -95,7 +95,7 @@ function ProjectAccessTable({ { label: 'Change role', onActivate: () => onEditRow(row), - disabled: !row.projectRole && getNoPermissionMessage('change', row.identityType), + disabled: !row.projectRole && getInheritedRoleMessage('change', row.identityType), }, { label: 'Delete', @@ -115,7 +115,7 @@ function ProjectAccessTable({ ), }), - disabled: !row.projectRole && getNoPermissionMessage('delete', row.identityType), + disabled: !row.projectRole && getInheritedRoleMessage('delete', row.identityType), }, ]), ] diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index c99e58f55..2c2dcd48d 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -12,11 +12,7 @@ import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide import { Badge } from '@oxide/design-system/ui' import { accessQueries } from '~/api/access-queries' -import { - getFilterEntityLabel, - getNoPermissionMessage, - identityTypeColumnDef, -} from '~/components/access/shared' +import { getFilterEntityLabel, identityTypeColumnDef } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { @@ -77,7 +73,6 @@ function SiloAccessTable({ { label: 'Change role', onActivate: () => onEditRow(row), - disabled: !row.siloRole && getNoPermissionMessage('change', row.identityType), }, { label: 'Delete', @@ -93,7 +88,6 @@ function SiloAccessTable({ ), }), // TODO: disable delete on permissions you can't delete - disabled: !row.siloRole && getNoPermissionMessage('delete', row.identityType), }, ]), ] diff --git a/app/components/access/shared.tsx b/app/components/access/shared.tsx index 88c540d79..ade4a3cc5 100644 --- a/app/components/access/shared.tsx +++ b/app/components/access/shared.tsx @@ -21,6 +21,15 @@ export const getIdentityLabel = (identityType: string) => export const getNoPermissionMessage = (action: 'change' | 'delete', identityType: string) => `You don't have permission to ${action} this ${getIdentityLabel(identityType)}'s role` +/** + * Message explaining that an inherited silo role cannot be modified at the project level + */ +export const getInheritedRoleMessage = ( + action: 'change' | 'delete', + identityType: string +) => + `Cannot ${action} inherited silo role. This ${getIdentityLabel(identityType)}'s role is set at the silo level.` + /** * Returns a label based on the current filter (e.g., "user or group", "user", "group") */ diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index 27190fd05..e1a16ca52 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -90,7 +90,9 @@ export function useProjectAccessRows( // Filter out undefined values, then map to expected shape const roleBadges = R.sortBy( - [siloAccessRow, projectAccessRow].filter((r) => r !== undefined), + [siloAccessRow, projectAccessRow].filter( + (r): r is UserAccessRow => r !== undefined + ), (r) => roleOrder[r.roleName] // sorts strongest role first ).map((r) => ({ roleSource: r.roleSource, From c60cc4ab4b2fc3d0e7d6166a02d371a694317f11 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 5 Dec 2025 16:05:55 -0800 Subject: [PATCH 12/22] toast cleanup --- app/components/ProjectAccessTab.tsx | 7 +++++-- app/components/SiloAccessTab.tsx | 7 +++++-- app/hooks/use-access-mutations.ts | 4 ---- app/hooks/use-access-rows.ts | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 13ffd2797..644749643 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -28,6 +28,7 @@ import { useProjectAccessMutations } from '~/hooks/use-access-mutations' import { useProjectAccessRows } from '~/hooks/use-access-rows' import { 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 type { IdentityFilter, ProjectAccessRow } from '~/types/access' @@ -104,11 +105,13 @@ function ProjectAccessTable({ // cause the user to have an effective role of X. However we would have to look at // their groups too. onActivate: confirmDelete({ - doDelete: async () => + doDelete: async () => { await updatePolicy({ path: { project: projectName }, body: deleteRole(row.id, policy), - }), + }) + addToast({ content: 'Access removed' }) + }, label: ( the {row.projectRole} role for {row.name} diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 2c2dcd48d..17999ea97 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -22,6 +22,7 @@ import { import { useSiloAccessMutations } from '~/hooks/use-access-mutations' import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import type { IdentityFilter, SiloAccessRow } from '~/types/access' @@ -77,10 +78,12 @@ function SiloAccessTable({ { label: 'Delete', onActivate: confirmDelete({ - doDelete: async () => + doDelete: async () => { await updatePolicy({ body: deleteRole(row.id, policy), - }), + }) + addToast({ content: 'Access removed' }) + }, label: ( the {row.siloRole} role for {row.name} diff --git a/app/hooks/use-access-mutations.ts b/app/hooks/use-access-mutations.ts index e20581467..a2445b837 100644 --- a/app/hooks/use-access-mutations.ts +++ b/app/hooks/use-access-mutations.ts @@ -7,13 +7,10 @@ */ import { api, queryClient, useApiMutation } from '@oxide/api' -import { addToast } from '~/stores/toast' - export function useProjectAccessMutations() { const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('projectPolicyView') - addToast({ content: 'Access removed' }) }, }) @@ -24,7 +21,6 @@ export function useSiloAccessMutations() { const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { queryClient.invalidateEndpoint('policyView') - addToast({ content: 'Access removed' }) }, }) diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts index e1a16ca52..5d5cdc3bf 100644 --- a/app/hooks/use-access-rows.ts +++ b/app/hooks/use-access-rows.ts @@ -44,7 +44,7 @@ export function useSiloAccessRows( const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName const { name, identityType } = userAssignments[0] - // Skip rows without a silo role (shouldn't happen in normal operation, but be defensive) + // Silo access tab only shows identities with explicit silo roles if (!siloRole) { return null } From a9b88880c9c31064b221c54328eba56c04567cc8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sat, 6 Dec 2025 07:29:57 -0800 Subject: [PATCH 13/22] string cleanup --- app/components/AccessEmptyState.tsx | 17 +++-------------- app/components/ProjectAccessTab.tsx | 18 +++++++++++++----- app/components/access/shared.tsx | 21 --------------------- 3 files changed, 16 insertions(+), 40 deletions(-) diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index b5dab3ee3..50b5ae8bc 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -12,25 +12,14 @@ import type { IdentityFilter } from '~/types/access' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' +import { getFilterEntityLabel } from './access/shared' + const titleMap = { all: 'No authorized users or groups', users: 'No authorized users', groups: 'No authorized groups', } as const -const buttonTextMap = { - project: { - all: 'Add user or group to project', - users: 'Add user to project', - groups: 'Add group to project', - }, - silo: { - all: 'Add user or group', - users: 'Add user', - groups: 'Add group', - }, -} as const - type AccessEmptyStateProps = { onClick: () => void scope: RoleSource @@ -47,7 +36,7 @@ export const AccessEmptyState = ({ icon={} title={titleMap[filter]} body={`Give permission to view, edit, or administer this ${scope}`} - buttonText={buttonTextMap[scope][filter]} + buttonText={`Add ${getFilterEntityLabel(filter)} to ${scope}`} onClick={onClick} /> diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 644749643..7b6d777b3 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -12,11 +12,7 @@ import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide import { Badge } from '@oxide/design-system/ui' import { accessQueries } from '~/api/access-queries' -import { - getFilterEntityLabel, - getInheritedRoleMessage, - identityTypeColumnDef, -} from '~/components/access/shared' +import { getFilterEntityLabel, identityTypeColumnDef } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' @@ -42,6 +38,18 @@ type ProjectAccessTabProps = { children?: ReactNode } +/** + * Converts an identity type to a user-friendly label + */ +const getIdentityLabel = (identityType: string) => + identityType === 'silo_user' ? 'user' : 'group' + +/** + * Message explaining that an inherited silo role cannot be modified at the project level + */ +const getInheritedRoleMessage = (action: 'change' | 'delete', identityType: string) => + `Cannot ${action} inherited silo role. This ${getIdentityLabel(identityType)}'s role is set at the silo level.` + function ProjectAccessTable({ filter, rows, diff --git a/app/components/access/shared.tsx b/app/components/access/shared.tsx index ade4a3cc5..225d97122 100644 --- a/app/components/access/shared.tsx +++ b/app/components/access/shared.tsx @@ -9,27 +9,6 @@ import type { IdentityFilter } from '~/types/access' import { identityTypeLabel } from '~/util/access' -/** - * Converts an identity type to a user-friendly label - */ -export const getIdentityLabel = (identityType: string) => - identityType === 'silo_user' ? 'user' : 'group' - -/** - * Generates a permission error message for disabled actions - */ -export const getNoPermissionMessage = (action: 'change' | 'delete', identityType: string) => - `You don't have permission to ${action} this ${getIdentityLabel(identityType)}'s role` - -/** - * Message explaining that an inherited silo role cannot be modified at the project level - */ -export const getInheritedRoleMessage = ( - action: 'change' | 'delete', - identityType: string -) => - `Cannot ${action} inherited silo role. This ${getIdentityLabel(identityType)}'s role is set at the silo level.` - /** * Returns a label based on the current filter (e.g., "user or group", "user", "group") */ From 4573ba0479d22586fb21016e773873d9817f212a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sat, 6 Dec 2025 20:04:15 -0800 Subject: [PATCH 14/22] tweaks --- app/components/AccessEmptyState.tsx | 5 ++--- app/components/ProjectAccessTab.tsx | 14 +++++++++----- app/components/SiloAccessTab.tsx | 12 ++++++++---- app/components/access/shared.tsx | 25 ------------------------- app/util/access.ts | 7 +++++++ 5 files changed, 26 insertions(+), 37 deletions(-) delete mode 100644 app/components/access/shared.tsx diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index 50b5ae8bc..b3f9c580a 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -11,8 +11,7 @@ import { Access24Icon } from '@oxide/design-system/icons/react' import type { IdentityFilter } from '~/types/access' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' - -import { getFilterEntityLabel } from './access/shared' +import { identityFilterLabel } from '~/util/access' const titleMap = { all: 'No authorized users or groups', @@ -36,7 +35,7 @@ export const AccessEmptyState = ({ icon={} title={titleMap[filter]} body={`Give permission to view, edit, or administer this ${scope}`} - buttonText={`Add ${getFilterEntityLabel(filter)} to ${scope}`} + buttonText={`Add ${identityFilterLabel[filter]} to ${scope}`} onClick={onClick} /> diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 7b6d777b3..7623445ac 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -12,7 +12,6 @@ import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide import { Badge } from '@oxide/design-system/ui' import { accessQueries } from '~/api/access-queries' -import { getFilterEntityLabel, identityTypeColumnDef } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' @@ -31,7 +30,7 @@ import type { IdentityFilter, ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' -import { roleColor } from '~/util/access' +import { identityFilterLabel, identityTypeLabel, roleColor } from '~/util/access' type ProjectAccessTabProps = { filter: IdentityFilter @@ -78,14 +77,19 @@ function ProjectAccessTable({ // TODO: Add lastAccessed column for users once API provides it. The User type // should include a lastAccessed timestamp to show when users last logged in. ...(filter === 'all' - ? [colHelper.accessor('identityType', identityTypeColumnDef)] + ? [ + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + ] : []), colHelper.accessor('roleBadges', { header: () => ( Role - A {getFilterEntityLabel(filter)}'s effective role for this project is the + A {identityFilterLabel[filter]}'s effective role for this project is the strongest role on either the silo or project @@ -161,7 +165,7 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { const projectRows = useUserRows(projectPolicy.roleAssignments, 'project') const rows = useProjectAccessRows(siloRows, projectRows, filter) - const addButtonText = `Add ${getFilterEntityLabel(filter)}` + const addButtonText = `Add ${identityFilterLabel[filter]}` return ( <> diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 17999ea97..19d9d75c8 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -12,7 +12,6 @@ import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide import { Badge } from '@oxide/design-system/ui' import { accessQueries } from '~/api/access-queries' -import { getFilterEntityLabel, identityTypeColumnDef } from '~/components/access/shared' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { @@ -28,7 +27,7 @@ import { Table } from '~/table/Table' import type { IdentityFilter, SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' -import { roleColor } from '~/util/access' +import { identityFilterLabel, identityTypeLabel, roleColor } from '~/util/access' type SiloAccessTabProps = { filter: IdentityFilter @@ -61,7 +60,12 @@ function SiloAccessTable({ // TODO: Add lastAccessed column for users once API provides it. The User type // should include a lastAccessed timestamp to show when users last logged in. ...(filter === 'all' - ? [colHelper.accessor('identityType', identityTypeColumnDef)] + ? [ + colHelper.accessor('identityType', { + header: 'Type', + cell: (info) => identityTypeLabel[info.getValue()], + }), + ] : []), colHelper.accessor('siloRole', { header: 'Role', @@ -117,7 +121,7 @@ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { const siloRows = useUserRows(policy.roleAssignments, 'silo') const rows = useSiloAccessRows(siloRows, filter) - const addButtonText = `Add ${getFilterEntityLabel(filter)}` + const addButtonText = `Add ${identityFilterLabel[filter]}` return ( <> diff --git a/app/components/access/shared.tsx b/app/components/access/shared.tsx deleted file mode 100644 index 225d97122..000000000 --- a/app/components/access/shared.tsx +++ /dev/null @@ -1,25 +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 type { IdentityFilter } from '~/types/access' -import { identityTypeLabel } from '~/util/access' - -/** - * Returns a label based on the current filter (e.g., "user or group", "user", "group") - */ -export const getFilterEntityLabel = (filter: IdentityFilter) => - filter === 'all' ? 'user or group' : filter === 'users' ? 'user' : 'group' - -/** - * Shared identity type column definition for use in both silo and project tables - */ -export const identityTypeColumnDef = { - header: 'Type', - cell: (info: { getValue: () => keyof typeof identityTypeLabel }) => - identityTypeLabel[info.getValue()], -} diff --git a/app/util/access.ts b/app/util/access.ts index 0e7b374cd..d7afaf46d 100644 --- a/app/util/access.ts +++ b/app/util/access.ts @@ -9,12 +9,19 @@ import { type BadgeColor } from '@oxide/design-system/ui' import type { IdentityType, RoleKey } from '~/api' +import type { IdentityFilter } from '~/types/access' export const identityTypeLabel: Record = { silo_group: 'Group', silo_user: 'User', } +export const identityFilterLabel: Record = { + all: 'user or group', + users: 'user', + groups: 'group', +} + export const roleColor: Record = { admin: 'default', collaborator: 'purple', From 954bc4654a25f7e6088f89548d354e1856f619be Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 04:46:56 -0800 Subject: [PATCH 15/22] More deduping and cleanup --- app/api/access-queries.ts | 18 --- app/components/AccessEmptyState.tsx | 10 +- app/components/ProjectAccessTab.tsx | 100 +++++++++++---- app/components/SiloAccessTab.tsx | 81 +++++++++++-- app/hooks/use-access-mutations.ts | 28 ----- app/hooks/use-access-rows.ts | 114 ------------------ .../project/access/ProjectAccessPage.tsx | 13 +- app/pages/silo/access/SiloAccessPage.tsx | 9 +- app/util/access.ts | 10 ++ test/e2e/access-test-helpers.ts | 85 +++++++++++++ test/e2e/project-access.e2e.ts | 64 +--------- test/e2e/silo-access.e2e.ts | 64 +--------- 12 files changed, 264 insertions(+), 332 deletions(-) delete mode 100644 app/api/access-queries.ts delete mode 100644 app/hooks/use-access-mutations.ts delete mode 100644 app/hooks/use-access-rows.ts create mode 100644 test/e2e/access-test-helpers.ts diff --git a/app/api/access-queries.ts b/app/api/access-queries.ts deleted file mode 100644 index 01c42b716..000000000 --- a/app/api/access-queries.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { api, q } from '@oxide/api' - -import type * as PP from '~/util/path-params' - -export const accessQueries = { - siloPolicy: () => q(api.policyView, {}), - projectPolicy: ({ project }: PP.Project) => - q(api.projectPolicyView, { path: { project } }), - userList: () => q(api.userList, {}), - groupList: () => q(api.groupList, {}), -} diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index b3f9c580a..b26c7434e 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -13,16 +13,10 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' import { identityFilterLabel } from '~/util/access' -const titleMap = { - all: 'No authorized users or groups', - users: 'No authorized users', - groups: 'No authorized groups', -} as const - type AccessEmptyStateProps = { onClick: () => void scope: RoleSource - filter?: IdentityFilter + filter: IdentityFilter } export const AccessEmptyState = ({ @@ -33,7 +27,7 @@ export const AccessEmptyState = ({ } - title={titleMap[filter]} + title={`No authorized ${filter === 'all' ? 'users or groups' : filter}`} body={`Give permission to view, edit, or administer this ${scope}`} buttonText={`Add ${identityFilterLabel[filter]} to ${scope}`} onClick={onClick} diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 7623445ac..dd40489ca 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -7,11 +7,24 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react' +import * as R from 'remeda' -import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' +import { + api, + byGroupThenName, + deleteRole, + q, + queryClient, + roleOrder, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type IdentityType, + type Policy, + type UserAccessRow, +} from '@oxide/api' import { Badge } from '@oxide/design-system/ui' -import { accessQueries } from '~/api/access-queries' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' @@ -19,8 +32,6 @@ import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, } from '~/forms/project-access' -import { useProjectAccessMutations } from '~/hooks/use-access-mutations' -import { useProjectAccessRows } from '~/hooks/use-access-rows' import { useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -30,24 +41,67 @@ import type { IdentityFilter, ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' -import { identityFilterLabel, identityTypeLabel, roleColor } from '~/util/access' +import { + filterByIdentityType, + identityFilterLabel, + identityTypeLabel, + roleColor, +} from '~/util/access' +import { groupBy } from '~/util/array' type ProjectAccessTabProps = { filter: IdentityFilter children?: ReactNode } -/** - * Converts an identity type to a user-friendly label - */ -const getIdentityLabel = (identityType: string) => - identityType === 'silo_user' ? 'user' : 'group' +function useProjectAccessRows( + siloRows: UserAccessRow[], + projectRows: UserAccessRow[], + filter: IdentityFilter +): ProjectAccessRow[] { + return useMemo(() => { + const rows = groupBy(siloRows.concat(projectRows), (u) => u.id).map( + ([userId, userAssignments]) => { + // groupBy always produces non-empty arrays, but add guard for safety + if (userAssignments.length === 0) { + throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) + } + + const { name, identityType } = userAssignments[0] + + const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') + const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') + + // Filter out undefined values, then map to expected shape + const roleBadges = R.sortBy( + [siloAccessRow, projectAccessRow].filter( + (r): r is UserAccessRow => r !== undefined + ), + (r) => roleOrder[r.roleName] // sorts strongest role first + ).map((r) => ({ + roleSource: r.roleSource, + roleName: r.roleName, + })) + + return { + id: userId, + identityType, + name, + projectRole: projectAccessRow?.roleName, + roleBadges, + } satisfies ProjectAccessRow + } + ) + + return filterByIdentityType(rows, filter).sort(byGroupThenName) + }, [siloRows, projectRows, filter]) +} /** * Message explaining that an inherited silo role cannot be modified at the project level */ -const getInheritedRoleMessage = (action: 'change' | 'delete', identityType: string) => - `Cannot ${action} inherited silo role. This ${getIdentityLabel(identityType)}'s role is set at the silo level.` +const getInheritedRoleMessage = (action: 'change' | 'delete', identityType: IdentityType) => + `Cannot ${action} inherited silo role. This ${identityTypeLabel[identityType].toLowerCase()}'s role is set at the silo level.` function ProjectAccessTable({ filter, @@ -62,7 +116,11 @@ function ProjectAccessTable({ projectName: string onEditRow: (row: ProjectAccessRow) => void }) { - const { updatePolicy } = useProjectAccessMutations() + const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('projectPolicyView') + }, + }) const columns = useMemo(() => { const colHelper = createColumnHelper() @@ -71,11 +129,9 @@ function ProjectAccessTable({ colHelper.accessor('name', { header: 'Name' }), // TODO: Add member information for groups once API provides it. Ideally: // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. + // 2. A memberCount field on the Group type to show count, + // plus list of members in tooltip or expandable row + // TODO: Add lastAccessed column for users once API provides it. ...(filter === 'all' ? [ colHelper.accessor('identityType', { @@ -154,11 +210,11 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { const [addModalOpen, setAddModalOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) - const projectSelector = useProjectSelector() + const { project } = useProjectSelector() - const { data: siloPolicy } = usePrefetchedQuery(accessQueries.siloPolicy()) + const { data: siloPolicy } = usePrefetchedQuery(q(api.policyView, {})) const { data: projectPolicy } = usePrefetchedQuery( - accessQueries.projectPolicy(projectSelector) + q(api.projectPolicyView, { path: { project } }) ) const siloRows = useUserRows(siloPolicy.roleAssignments, 'silo') @@ -200,7 +256,7 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { filter={filter} rows={rows} policy={projectPolicy} - projectName={projectSelector.project} + projectName={project} onEditRow={setEditingRow} /> )} diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 19d9d75c8..3f57ecd73 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -8,18 +8,26 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useMemo, useState, type ReactNode } from 'react' -import { deleteRole, usePrefetchedQuery, useUserRows, type Policy } from '@oxide/api' +import { + api, + byGroupThenName, + deleteRole, + getEffectiveRole, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + useUserRows, + type Policy, +} from '@oxide/api' import { Badge } from '@oxide/design-system/ui' -import { accessQueries } from '~/api/access-queries' import { AccessEmptyState } from '~/components/AccessEmptyState' import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, } from '~/forms/silo-access' -import { useSiloAccessMutations } from '~/hooks/use-access-mutations' -import { useSiloAccessRows } from '~/hooks/use-access-rows' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' @@ -27,13 +35,60 @@ import { Table } from '~/table/Table' import type { IdentityFilter, SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' -import { identityFilterLabel, identityTypeLabel, roleColor } from '~/util/access' +import { + filterByIdentityType, + identityFilterLabel, + identityTypeLabel, + roleColor, +} from '~/util/access' +import { groupBy } from '~/util/array' type SiloAccessTabProps = { filter: IdentityFilter children?: ReactNode } +function useSiloAccessRows( + siloRows: ReturnType, + filter: IdentityFilter +) { + return useMemo(() => { + const rows = groupBy(siloRows, (u) => u.id) + .map(([userId, userAssignments]) => { + // groupBy always produces non-empty arrays, but add guard for safety + if (userAssignments.length === 0) { + throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) + } + + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName + const { name, identityType } = userAssignments[0] + + // Silo access tab only shows identities with explicit silo roles + if (!siloRole) { + return null + } + + const effectiveRole = getEffectiveRole([siloRole]) + if (!effectiveRole) { + return null + } + + const row: SiloAccessRow = { + id: userId, + identityType, + name, + siloRole, + effectiveRole, + } + + return row + }) + .filter((row): row is SiloAccessRow => row !== null) + + return filterByIdentityType(rows, filter).sort(byGroupThenName) + }, [siloRows, filter]) +} + function SiloAccessTable({ filter, rows, @@ -45,7 +100,11 @@ function SiloAccessTable({ policy: Policy onEditRow: (row: SiloAccessRow) => void }) { - const { updatePolicy } = useSiloAccessMutations() + const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { + onSuccess: () => { + queryClient.invalidateEndpoint('policyView') + }, + }) const columns = useMemo(() => { const colHelper = createColumnHelper() @@ -54,11 +113,9 @@ function SiloAccessTable({ colHelper.accessor('name', { header: 'Name' }), // TODO: Add member information for groups once API provides it. Ideally: // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type - // This would allow showing member count in the table and displaying members - // in a tooltip or expandable row. - // TODO: Add lastAccessed column for users once API provides it. The User type - // should include a lastAccessed timestamp to show when users last logged in. + // 2. A memberCount field on the Group type to show count, + // plus list of members in tooltip or expandable row + // TODO: Add lastAccessed column for users once API provides it. ...(filter === 'all' ? [ colHelper.accessor('identityType', { @@ -117,7 +174,7 @@ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { const [addModalOpen, setAddModalOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) - const { data: policy } = usePrefetchedQuery(accessQueries.siloPolicy()) + const { data: policy } = usePrefetchedQuery(q(api.policyView, {})) const siloRows = useUserRows(policy.roleAssignments, 'silo') const rows = useSiloAccessRows(siloRows, filter) diff --git a/app/hooks/use-access-mutations.ts b/app/hooks/use-access-mutations.ts deleted file mode 100644 index a2445b837..000000000 --- a/app/hooks/use-access-mutations.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { api, queryClient, useApiMutation } from '@oxide/api' - -export function useProjectAccessMutations() { - const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('projectPolicyView') - }, - }) - - return { updatePolicy } -} - -export function useSiloAccessMutations() { - const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { - onSuccess: () => { - queryClient.invalidateEndpoint('policyView') - }, - }) - - return { updatePolicy } -} diff --git a/app/hooks/use-access-rows.ts b/app/hooks/use-access-rows.ts deleted file mode 100644 index 5d5cdc3bf..000000000 --- a/app/hooks/use-access-rows.ts +++ /dev/null @@ -1,114 +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 { useMemo } from 'react' -import * as R from 'remeda' - -import { - byGroupThenName, - getEffectiveRole, - roleOrder, - type IdentityType, - type UserAccessRow, -} from '@oxide/api' - -import type { IdentityFilter, ProjectAccessRow, SiloAccessRow } from '~/types/access' -import { groupBy } from '~/util/array' - -/** Filter rows by identity type based on the filter parameter */ -function filterByIdentityType( - rows: T[], - filter: IdentityFilter -): T[] { - if (filter === 'users') return rows.filter((row) => row.identityType === 'silo_user') - if (filter === 'groups') return rows.filter((row) => row.identityType === 'silo_group') - return rows -} - -export function useSiloAccessRows( - siloRows: UserAccessRow[], - filter: IdentityFilter = 'all' -): SiloAccessRow[] { - return useMemo(() => { - const rows = groupBy(siloRows, (u) => u.id) - .map(([userId, userAssignments]) => { - // groupBy always produces non-empty arrays, but add guard for safety - if (userAssignments.length === 0) { - throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) - } - - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const { name, identityType } = userAssignments[0] - - // Silo access tab only shows identities with explicit silo roles - if (!siloRole) { - return null - } - - const effectiveRole = getEffectiveRole([siloRole]) - if (!effectiveRole) { - return null - } - - const row: SiloAccessRow = { - id: userId, - identityType, - name, - siloRole, - effectiveRole, - } - - return row - }) - .filter((row): row is SiloAccessRow => row !== null) - - return filterByIdentityType(rows, filter).sort(byGroupThenName) - }, [siloRows, filter]) -} - -export function useProjectAccessRows( - siloRows: UserAccessRow[], - projectRows: UserAccessRow[], - filter: IdentityFilter = 'all' -): ProjectAccessRow[] { - return useMemo(() => { - const rows = groupBy(siloRows.concat(projectRows), (u) => u.id).map( - ([userId, userAssignments]) => { - // groupBy always produces non-empty arrays, but add guard for safety - if (userAssignments.length === 0) { - throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) - } - - const { name, identityType } = userAssignments[0] - - const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') - const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') - - // Filter out undefined values, then map to expected shape - const roleBadges = R.sortBy( - [siloAccessRow, projectAccessRow].filter( - (r): r is UserAccessRow => r !== undefined - ), - (r) => roleOrder[r.roleName] // sorts strongest role first - ).map((r) => ({ - roleSource: r.roleSource, - roleName: r.roleName, - })) - - return { - id: userId, - identityType, - name, - projectRole: projectAccessRow?.roleName, - roleBadges, - } satisfies ProjectAccessRow - } - ) - - return filterByIdentityType(rows, filter).sort(byGroupThenName) - }, [siloRows, projectRows, filter]) -} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 598551ce5..76677efc3 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -7,10 +7,9 @@ */ import type { LoaderFunctionArgs } from 'react-router' -import { queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { accessQueries } from '~/api/access-queries' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' @@ -19,13 +18,13 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { - const selector = getProjectSelector(params) + const { project } = getProjectSelector(params) await Promise.all([ - queryClient.prefetchQuery(accessQueries.siloPolicy()), - queryClient.prefetchQuery(accessQueries.projectPolicy(selector)), + queryClient.prefetchQuery(q(api.policyView, {})), + queryClient.prefetchQuery(q(api.projectPolicyView, { path: { project } })), // used to resolve user names - queryClient.prefetchQuery(accessQueries.userList()), - queryClient.prefetchQuery(accessQueries.groupList()), + queryClient.prefetchQuery(q(api.userList, {})), + queryClient.prefetchQuery(q(api.groupList, {})), ]) return null } diff --git a/app/pages/silo/access/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessPage.tsx index 7e75a8429..c8461aab4 100644 --- a/app/pages/silo/access/SiloAccessPage.tsx +++ b/app/pages/silo/access/SiloAccessPage.tsx @@ -6,10 +6,9 @@ * Copyright Oxide Computer Company */ -import { queryClient } from '@oxide/api' +import { api, q, queryClient } from '@oxide/api' import { Access16Icon, Access24Icon } from '@oxide/design-system/icons/react' -import { accessQueries } from '~/api/access-queries' import { DocsPopover } from '~/components/DocsPopover' import { RouteTabs, Tab } from '~/components/RouteTabs' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -18,10 +17,10 @@ import { pb } from '~/util/path-builder' export async function clientLoader() { await Promise.all([ - queryClient.prefetchQuery(accessQueries.siloPolicy()), + queryClient.prefetchQuery(q(api.policyView, {})), // used to resolve user names - queryClient.prefetchQuery(accessQueries.userList()), - queryClient.prefetchQuery(accessQueries.groupList()), + queryClient.prefetchQuery(q(api.userList, {})), + queryClient.prefetchQuery(q(api.groupList, {})), ]) return null } diff --git a/app/util/access.ts b/app/util/access.ts index d7afaf46d..7e407e202 100644 --- a/app/util/access.ts +++ b/app/util/access.ts @@ -11,6 +11,16 @@ import { type BadgeColor } from '@oxide/design-system/ui' import type { IdentityType, RoleKey } from '~/api' import type { IdentityFilter } from '~/types/access' +/** Filter rows by identity type based on the filter parameter */ +export function filterByIdentityType( + rows: T[], + filter: IdentityFilter +): T[] { + if (filter === 'users') return rows.filter((row) => row.identityType === 'silo_user') + if (filter === 'groups') return rows.filter((row) => row.identityType === 'silo_group') + return rows +} + export const identityTypeLabel: Record = { silo_group: 'Group', silo_user: 'User', diff --git a/test/e2e/access-test-helpers.ts b/test/e2e/access-test-helpers.ts new file mode 100644 index 000000000..4fc13a255 --- /dev/null +++ b/test/e2e/access-test-helpers.ts @@ -0,0 +1,85 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { Page } from '@playwright/test' + +import { expect, expectNotVisible, expectRowVisible, expectVisible } from './utils' + +/** + * Shared test helper for verifying user addition and tab navigation + * Tests adding a user on the All tab, then verifying they appear correctly + * on Users tab but not Groups tab + */ +export async function testAddUserOnAllTabAndVerifyOnUsersTabs( + page: Page, + config: { + /** Base URL path (e.g., '/access/all' or '/projects/mock-project/access/all') */ + baseUrl: string + /** Role prefix (e.g., 'silo' or 'project') */ + rolePrefix: 'silo' | 'project' + } +) { + await page.goto(config.baseUrl) + + // Start on the All tab + await expectVisible(page, ['role=tab[name="All"][selected]']) + + const allTable = page.locator('table') + + // Add Hans Jonas as viewer + 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"]') + await page.click('role=option[name="Hans Jonas"]') + await page.getByRole('radio', { name: /^Viewer / }).click() + await page.click('role=button[name="Assign role"]') + + // User shows up in the All tab table + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: `${config.rolePrefix}.viewer`, + }) + + // Navigate to Users tab + await page.click('role=tab[name="Users"]') + await expectVisible(page, ['role=tab[name="Users"][selected]']) + + // Verify the URL changed to /users + await expect(page).toHaveURL(/\/access\/users$/) + + const usersTable = page.locator('table') + + // User should still be visible on Users tab + await expectRowVisible(usersTable, { + Name: 'Hans Jonas', + Role: `${config.rolePrefix}.viewer`, + }) + + // Type column should not be present on Users tab (since all are users) + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Navigate to Groups tab + await page.click('role=tab[name="Groups"]') + await expectVisible(page, ['role=tab[name="Groups"][selected]']) + + // Hans Jonas should NOT be visible on Groups tab + await expectNotVisible(page, ['role=cell[name="Hans Jonas"]']) + + // Type column should not be present on Groups tab either + await expectNotVisible(page, ['role=columnheader[name="Type"]']) + + // Go back to All tab and verify Hans Jonas is still there + await page.click('role=tab[name="All"]') + await expectVisible(page, ['role=tab[name="All"][selected]']) + await expectRowVisible(allTable, { + Name: 'Hans Jonas', + Type: 'User', + Role: `${config.rolePrefix}.viewer`, + }) +} diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index d1f43847c..ce105ef40 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -7,6 +7,7 @@ */ import { user3, user4 } from '@oxide/api-mocks' +import { testAddUserOnAllTabAndVerifyOnUsersTabs } from './access-test-helpers' import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' test('Click through project access page', async ({ page }) => { @@ -117,64 +118,9 @@ test('Click through project access page', async ({ page }) => { }) }) -test('Add user on All tab and verify on Users tab', async ({ page }) => { - await page.goto('/projects/mock-project/access/all') - - // Start on the All tab - await expectVisible(page, ['role=tab[name="All"][selected]']) - - const allTable = page.locator('table') - - // Add Hans Jonas as viewer - 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"]') - await page.click('role=option[name="Hans Jonas"]') - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Assign role"]') - - // User shows up in the All tab table - await expectRowVisible(allTable, { - Name: 'Hans Jonas', - Type: 'User', - Role: 'project.viewer', - }) - - // Navigate to Users tab - await page.click('role=tab[name="Users"]') - await expectVisible(page, ['role=tab[name="Users"][selected]']) - - // Verify the URL changed to /users - await expect(page).toHaveURL(/\/access\/users$/) - - const usersTable = page.locator('table') - - // User should still be visible on Users tab - await expectRowVisible(usersTable, { - Name: 'Hans Jonas', - Role: 'project.viewer', - }) - - // Type column should not be present on Users tab (since all are users) - await expectNotVisible(page, ['role=columnheader[name="Type"]']) - - // Navigate to Groups tab - await page.click('role=tab[name="Groups"]') - await expectVisible(page, ['role=tab[name="Groups"][selected]']) - - // Hans Jonas should NOT be visible on Groups tab - await expectNotVisible(page, ['role=cell[name="Hans Jonas"]']) - - // Type column should not be present on Groups tab either - await expectNotVisible(page, ['role=columnheader[name="Type"]']) - - // Go back to All tab and verify Hans Jonas is still there - await page.click('role=tab[name="All"]') - await expectVisible(page, ['role=tab[name="All"][selected]']) - await expectRowVisible(allTable, { - Name: 'Hans Jonas', - Type: 'User', - Role: 'project.viewer', +test('Add project user on All tab and verify on Users tab', async ({ page }) => { + await testAddUserOnAllTabAndVerifyOnUsersTabs(page, { + baseUrl: '/projects/mock-project/access/all', + rolePrefix: 'project', }) }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index 54f372aee..d2f22f848 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -7,6 +7,7 @@ */ import { user3, user4 } from '@oxide/api-mocks' +import { testAddUserOnAllTabAndVerifyOnUsersTabs } from './access-test-helpers' import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' test('Click through silo access page', async ({ page }) => { @@ -81,64 +82,9 @@ test('Click through silo access page', async ({ page }) => { await expect(user3Row).toBeHidden() }) -test('Add user on All tab and verify on Users tab', async ({ page }) => { - await page.goto('/access/all') - - // Start on the All tab - await expectVisible(page, ['role=tab[name="All"][selected]']) - - const allTable = page.locator('table') - - // Add Hans Jonas as viewer - 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"]') - await page.click('role=option[name="Hans Jonas"]') - await page.getByRole('radio', { name: /^Viewer / }).click() - await page.click('role=button[name="Assign role"]') - - // User shows up in the All tab table - await expectRowVisible(allTable, { - Name: 'Hans Jonas', - Type: 'User', - Role: 'silo.viewer', - }) - - // Navigate to Users tab - await page.click('role=tab[name="Users"]') - await expectVisible(page, ['role=tab[name="Users"][selected]']) - - // Verify the URL changed to /users - await expect(page).toHaveURL(/\/access\/users$/) - - const usersTable = page.locator('table') - - // User should still be visible on Users tab - await expectRowVisible(usersTable, { - Name: 'Hans Jonas', - Role: 'silo.viewer', - }) - - // Type column should not be present on Users tab (since all are users) - await expectNotVisible(page, ['role=columnheader[name="Type"]']) - - // Navigate to Groups tab - await page.click('role=tab[name="Groups"]') - await expectVisible(page, ['role=tab[name="Groups"][selected]']) - - // Hans Jonas should NOT be visible on Groups tab - await expectNotVisible(page, ['role=cell[name="Hans Jonas"]']) - - // Type column should not be present on Groups tab either - await expectNotVisible(page, ['role=columnheader[name="Type"]']) - - // Go back to All tab and verify Hans Jonas is still there - await page.click('role=tab[name="All"]') - await expectVisible(page, ['role=tab[name="All"][selected]']) - await expectRowVisible(allTable, { - Name: 'Hans Jonas', - Type: 'User', - Role: 'silo.viewer', +test('Add silo user on All tab and verify on Users tab', async ({ page }) => { + await testAddUserOnAllTabAndVerifyOnUsersTabs(page, { + baseUrl: '/access/all', + rolePrefix: 'silo', }) }) From 010424b68a3d3e0e9f47e35d8fcec36fa9d42cb7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 07:29:08 -0800 Subject: [PATCH 16/22] a little more cleanup --- app/components/ProjectAccessTab.tsx | 6 ------ app/components/SiloAccessTab.tsx | 7 +------ test/e2e/access-test-helpers.ts | 4 +++- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index dd40489ca..8b2c29c63 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -62,13 +62,7 @@ function useProjectAccessRows( return useMemo(() => { const rows = groupBy(siloRows.concat(projectRows), (u) => u.id).map( ([userId, userAssignments]) => { - // groupBy always produces non-empty arrays, but add guard for safety - if (userAssignments.length === 0) { - throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) - } - const { name, identityType } = userAssignments[0] - const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 3f57ecd73..0c0de85c7 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -55,13 +55,8 @@ function useSiloAccessRows( return useMemo(() => { const rows = groupBy(siloRows, (u) => u.id) .map(([userId, userAssignments]) => { - // groupBy always produces non-empty arrays, but add guard for safety - if (userAssignments.length === 0) { - throw new Error(`Unexpected empty userAssignments array for userId ${userId}`) - } - - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName const { name, identityType } = userAssignments[0] + const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName // Silo access tab only shows identities with explicit silo roles if (!siloRole) { diff --git a/test/e2e/access-test-helpers.ts b/test/e2e/access-test-helpers.ts index 4fc13a255..be592e26e 100644 --- a/test/e2e/access-test-helpers.ts +++ b/test/e2e/access-test-helpers.ts @@ -7,6 +7,8 @@ */ import type { Page } from '@playwright/test' +import type { RoleSource } from '~/api' + import { expect, expectNotVisible, expectRowVisible, expectVisible } from './utils' /** @@ -20,7 +22,7 @@ export async function testAddUserOnAllTabAndVerifyOnUsersTabs( /** Base URL path (e.g., '/access/all' or '/projects/mock-project/access/all') */ baseUrl: string /** Role prefix (e.g., 'silo' or 'project') */ - rolePrefix: 'silo' | 'project' + rolePrefix: RoleSource } ) { await page.goto(config.baseUrl) From 7abcc897b8d11c49bd2158b16c70a97451f29a28 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 16:52:42 -0800 Subject: [PATCH 17/22] Refactor forms to show filtered lists based on tab --- app/api/roles.ts | 23 ++++++++++++++++++----- app/components/ProjectAccessTab.tsx | 1 + app/components/SiloAccessTab.tsx | 1 + app/forms/access-util.tsx | 6 +++++- app/forms/project-access.tsx | 12 ++++++++---- app/forms/silo-access.tsx | 12 ++++++++---- 6 files changed, 41 insertions(+), 14 deletions(-) diff --git a/app/api/roles.ts b/app/api/roles.ts index 72daf5c2e..422ccf12a 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -14,6 +14,8 @@ import { useMemo } from 'react' import * as R from 'remeda' +import type { IdentityFilter } from '~/types/access' + import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' import { api, q, usePrefetchedQuery } from './client' @@ -139,9 +141,9 @@ export type Actor = { /** * Fetch lists of users and groups, filtering out the ones that are already in - * the given policy. + * the given policy. Optionally filter to only users or only groups. */ -export function useActorsNotInPolicy(policy: Policy): Actor[] { +export function useActorsNotInPolicy(policy: Policy, filter?: IdentityFilter): Actor[] { const { data: users } = usePrefetchedQuery(q(api.userList, {})) const { data: groups } = usePrefetchedQuery(q(api.groupList, {})) return useMemo(() => { @@ -155,9 +157,20 @@ export function useActorsNotInPolicy(policy: Policy): Actor[] { ...u, identityType: 'silo_user' as IdentityType, })) - // groups go before users - return allGroups.concat(allUsers).filter((u) => !actorsInPolicy.has(u.id)) || [] - }, [users, groups, policy]) + + // Select which actors to include based on filter + let actors: Actor[] + if (filter === 'users') { + actors = allUsers + } else if (filter === 'groups') { + actors = allGroups + } else { + // 'all' or undefined; groups go before users + actors = allGroups.concat(allUsers) + } + + return actors.filter((u) => !actorsInPolicy.has(u.id)) + }, [policy, users, groups, filter]) } export function userRoleFromPolicies( diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 8b2c29c63..3fffa71e3 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -226,6 +226,7 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { setAddModalOpen(false)} policy={projectPolicy} + filter={filter} /> )} {projectPolicy && editingRow && editingRow.projectRole && ( diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 0c0de85c7..a3303ae6f 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -184,6 +184,7 @@ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { setAddModalOpen(false)} policy={policy} + filter={filter} /> )} {policy && editingRow && editingRow.siloRole && ( diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index e30aa4478..06b6121d1 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -18,6 +18,7 @@ import { import { Badge } from '@oxide/design-system/ui' import { RadioFieldDyn } from '~/components/form/fields/RadioField' +import type { IdentityFilter } from '~/types/access' import { type ListboxItem } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' import { Radio } from '~/ui/lib/Radio' @@ -68,9 +69,12 @@ export const actorToItem = (actor: Actor): ListboxItem => ({ export type AddRoleModalProps = { onDismiss: () => void policy: Policy + filter: IdentityFilter } -export type EditRoleModalProps = AddRoleModalProps & { +export type EditRoleModalProps = { + onDismiss: () => void + policy: Policy name?: string identityId: string identityType: IdentityType diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 270fa9844..89e26d9b1 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -29,11 +29,13 @@ import { type AddRoleModalProps, type EditRoleModalProps, } from './access-util' +import { identityFilterLabel } from '~/util/access' +import { capitalize } from '~/util/str' -export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { +export function ProjectAccessAddUserSideModal({ onDismiss, policy, filter }: AddRoleModalProps) { const { project } = useProjectSelector() - const actors = useActorsNotInPolicy(policy) + const actors = useActorsNotInPolicy(policy, filter) const updatePolicy = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { @@ -46,9 +48,11 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const form = useForm({ defaultValues }) + const entityLabel = identityFilterLabel[filter] + return ( diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 150a9ca57..e597463a5 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -27,9 +27,11 @@ import { type AddRoleModalProps, type EditRoleModalProps, } from './access-util' +import { identityFilterLabel } from '~/util/access' +import { capitalize } from '~/util/str' -export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalProps) { - const actors = useActorsNotInPolicy(policy) +export function SiloAccessAddUserSideModal({ onDismiss, policy, filter }: AddRoleModalProps) { + const actors = useActorsNotInPolicy(policy, filter) const updatePolicy = useApiMutation(api.policyUpdate, { onSuccess: () => { @@ -40,12 +42,14 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr const form = useForm({ defaultValues }) + const entityLabel = identityFilterLabel[filter] + return ( { @@ -63,7 +67,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr From d290af58caf63e6c77c27eea0e34a9d5ec237419 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 18:22:51 -0800 Subject: [PATCH 18/22] formatting --- app/forms/project-access.tsx | 10 +++++++--- app/forms/silo-access.tsx | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 89e26d9b1..876826652 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -21,6 +21,8 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ResourceLabel } from '~/ui/lib/SideModal' +import { identityFilterLabel } from '~/util/access' +import { capitalize } from '~/util/str' import { actorToItem, @@ -29,10 +31,12 @@ import { type AddRoleModalProps, type EditRoleModalProps, } from './access-util' -import { identityFilterLabel } from '~/util/access' -import { capitalize } from '~/util/str' -export function ProjectAccessAddUserSideModal({ onDismiss, policy, filter }: AddRoleModalProps) { +export function ProjectAccessAddUserSideModal({ + onDismiss, + policy, + filter, +}: AddRoleModalProps) { const { project } = useProjectSelector() const actors = useActorsNotInPolicy(policy, filter) diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index e597463a5..f1dfa0e8a 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -19,6 +19,8 @@ import { Access16Icon } from '@oxide/design-system/icons/react' import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { ResourceLabel } from '~/ui/lib/SideModal' +import { identityFilterLabel } from '~/util/access' +import { capitalize } from '~/util/str' import { actorToItem, @@ -27,10 +29,12 @@ import { type AddRoleModalProps, type EditRoleModalProps, } from './access-util' -import { identityFilterLabel } from '~/util/access' -import { capitalize } from '~/util/str' -export function SiloAccessAddUserSideModal({ onDismiss, policy, filter }: AddRoleModalProps) { +export function SiloAccessAddUserSideModal({ + onDismiss, + policy, + filter, +}: AddRoleModalProps) { const actors = useActorsNotInPolicy(policy, filter) const updatePolicy = useApiMutation(api.policyUpdate, { From 5ea24660e51ad7fb6a7496aea4cb522fe9effa6c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 19:53:32 -0800 Subject: [PATCH 19/22] Adjust imports --- app/api/roles.ts | 2 +- app/components/AccessEmptyState.tsx | 3 +-- app/components/ProjectAccessTab.tsx | 12 +++++++++++- app/components/SiloAccessTab.tsx | 12 +++++++++++- app/forms/access-util.tsx | 2 +- app/types/access.ts | 26 -------------------------- app/util/access.ts | 3 ++- 7 files changed, 27 insertions(+), 33 deletions(-) delete mode 100644 app/types/access.ts diff --git a/app/api/roles.ts b/app/api/roles.ts index 422ccf12a..4870e2236 100644 --- a/app/api/roles.ts +++ b/app/api/roles.ts @@ -14,7 +14,7 @@ import { useMemo } from 'react' import * as R from 'remeda' -import type { IdentityFilter } from '~/types/access' +import type { IdentityFilter } from '~/util/access' import type { FleetRole, IdentityType, ProjectRole, SiloRole } from './__generated__/Api' import { api, q, usePrefetchedQuery } from './client' diff --git a/app/components/AccessEmptyState.tsx b/app/components/AccessEmptyState.tsx index b26c7434e..b415666fb 100644 --- a/app/components/AccessEmptyState.tsx +++ b/app/components/AccessEmptyState.tsx @@ -8,10 +8,9 @@ import type { RoleSource } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' -import type { IdentityFilter } from '~/types/access' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' -import { identityFilterLabel } from '~/util/access' +import { identityFilterLabel, type IdentityFilter } from '~/util/access' type AccessEmptyStateProps = { onClick: () => void diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 3fffa71e3..98ee5753c 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -21,6 +21,8 @@ import { useUserRows, type IdentityType, type Policy, + type RoleKey, + type RoleSource, type UserAccessRow, } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' @@ -37,7 +39,6 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' -import type { IdentityFilter, ProjectAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' @@ -46,9 +47,18 @@ import { identityFilterLabel, identityTypeLabel, roleColor, + type IdentityFilter, } from '~/util/access' import { groupBy } from '~/util/array' +type ProjectAccessRow = { + id: string + identityType: IdentityType + name: string + projectRole: RoleKey | undefined + roleBadges: { roleSource: RoleSource; roleName: RoleKey }[] +} + type ProjectAccessTabProps = { filter: IdentityFilter children?: ReactNode diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index a3303ae6f..5a6bf8c97 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -18,7 +18,9 @@ import { useApiMutation, usePrefetchedQuery, useUserRows, + type IdentityType, type Policy, + type RoleKey, } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' @@ -32,7 +34,6 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' -import type { IdentityFilter, SiloAccessRow } from '~/types/access' import { CreateButton } from '~/ui/lib/CreateButton' import { TableActions } from '~/ui/lib/Table' import { @@ -40,9 +41,18 @@ import { identityFilterLabel, identityTypeLabel, roleColor, + type IdentityFilter, } from '~/util/access' import { groupBy } from '~/util/array' +type SiloAccessRow = { + id: string + identityType: IdentityType + name: string + siloRole: RoleKey | undefined + effectiveRole: RoleKey +} + type SiloAccessTabProps = { filter: IdentityFilter children?: ReactNode diff --git a/app/forms/access-util.tsx b/app/forms/access-util.tsx index 06b6121d1..87fc34be6 100644 --- a/app/forms/access-util.tsx +++ b/app/forms/access-util.tsx @@ -18,10 +18,10 @@ import { import { Badge } from '@oxide/design-system/ui' import { RadioFieldDyn } from '~/components/form/fields/RadioField' -import type { IdentityFilter } from '~/types/access' import { type ListboxItem } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' import { Radio } from '~/ui/lib/Radio' +import { type IdentityFilter } from '~/util/access' import { links } from '~/util/links' import { capitalize } from '~/util/str' diff --git a/app/types/access.ts b/app/types/access.ts deleted file mode 100644 index 702c411c0..000000000 --- a/app/types/access.ts +++ /dev/null @@ -1,26 +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 type { IdentityType, RoleKey, RoleSource } from '@oxide/api' - -export type IdentityFilter = 'all' | 'users' | 'groups' - -export type AccessRowBase = { - id: string - identityType: IdentityType - name: string -} - -export type ProjectAccessRow = AccessRowBase & { - projectRole: RoleKey | undefined - roleBadges: { roleSource: RoleSource; roleName: RoleKey }[] -} - -export type SiloAccessRow = AccessRowBase & { - siloRole: RoleKey | undefined - effectiveRole: RoleKey -} diff --git a/app/util/access.ts b/app/util/access.ts index 7e407e202..a754be2c0 100644 --- a/app/util/access.ts +++ b/app/util/access.ts @@ -9,7 +9,8 @@ import { type BadgeColor } from '@oxide/design-system/ui' import type { IdentityType, RoleKey } from '~/api' -import type { IdentityFilter } from '~/types/access' + +export type IdentityFilter = 'all' | 'users' | 'groups' /** Filter rows by identity type based on the filter parameter */ export function filterByIdentityType( From 9c4ec12ec5c97cb3305956e76cf3c005596fdcc9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 7 Dec 2025 21:19:48 -0800 Subject: [PATCH 20/22] Remove extraneous comments --- app/pages/project/access/ProjectAccessPage.tsx | 5 ----- app/pages/silo/access/SiloAccessPage.tsx | 5 ----- 2 files changed, 10 deletions(-) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 76677efc3..fbf7733b3 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -22,7 +22,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { await Promise.all([ queryClient.prefetchQuery(q(api.policyView, {})), queryClient.prefetchQuery(q(api.projectPolicyView, { path: { project } })), - // used to resolve user names queryClient.prefetchQuery(q(api.userList, {})), queryClient.prefetchQuery(q(api.groupList, {})), ]) @@ -51,10 +50,6 @@ export default function ProjectAccessPage() { Users Groups - {/* TODO: Add routes for side modal forms to enable deep linking and browser back button: - - /access/all/users-new and /access/all/groups-new for adding - - /access/all/{id}/edit for editing roles - This would align with patterns like /instances-new, /idps-new, etc. */} ) } diff --git a/app/pages/silo/access/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessPage.tsx index c8461aab4..1625a70bd 100644 --- a/app/pages/silo/access/SiloAccessPage.tsx +++ b/app/pages/silo/access/SiloAccessPage.tsx @@ -18,7 +18,6 @@ import { pb } from '~/util/path-builder' export async function clientLoader() { await Promise.all([ queryClient.prefetchQuery(q(api.policyView, {})), - // used to resolve user names queryClient.prefetchQuery(q(api.userList, {})), queryClient.prefetchQuery(q(api.groupList, {})), ]) @@ -45,10 +44,6 @@ export default function SiloAccessPage() { Users Groups - {/* TODO: Add routes for side modal forms to enable deep linking and browser back button: - - /access/all/users-new and /access/all/groups-new for adding - - /access/all/{id}/edit for editing roles - This would align with patterns like /instances-new, /idps-new, etc. */} ) } From ef15ce6d62b5fe6528679b1d6b6f2e508184223a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 11 Dec 2025 12:27:10 -0800 Subject: [PATCH 21/22] Add modal showing group member list --- app/components/ProjectAccessTab.tsx | 33 +++++++++++++++--- app/components/SiloAccessTab.tsx | 34 ++++++++++++++++--- .../project/access/ProjectAccessPage.tsx | 2 +- app/pages/silo/access/SiloAccessPage.tsx | 2 +- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/app/components/ProjectAccessTab.tsx b/app/components/ProjectAccessTab.tsx index 98ee5753c..54b28f115 100644 --- a/app/components/ProjectAccessTab.tsx +++ b/app/components/ProjectAccessTab.tsx @@ -28,6 +28,7 @@ import { import { Badge } from '@oxide/design-system/ui' import { AccessEmptyState } from '~/components/AccessEmptyState' +import { GroupMembersModal } from '~/components/GroupMembersModal' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { @@ -37,6 +38,7 @@ import { import { useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { MembersCell } from '~/table/cells/MembersCell' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -113,12 +115,14 @@ function ProjectAccessTable({ policy, projectName, onEditRow, + onViewMembers, }: { filter: IdentityFilter rows: ProjectAccessRow[] policy: Policy projectName: string onEditRow: (row: ProjectAccessRow) => void + onViewMembers: (row: ProjectAccessRow) => void }) { const { mutateAsync: updatePolicy } = useApiMutation(api.projectPolicyUpdate, { onSuccess: () => { @@ -131,10 +135,6 @@ function ProjectAccessTable({ return [ colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type to show count, - // plus list of members in tooltip or expandable row // TODO: Add lastAccessed column for users once API provides it. ...(filter === 'all' ? [ @@ -164,6 +164,20 @@ function ProjectAccessTable({ ), }), + ...(filter === 'groups' + ? [ + colHelper.display({ + id: 'users', + header: 'Users', + cell: (info) => { + const row = info.row.original + return ( + onViewMembers(row)} /> + ) + }, + }), + ] + : []), getActionsCol((row: ProjectAccessRow) => [ { label: 'Change role', @@ -194,7 +208,7 @@ function ProjectAccessTable({ }, ]), ] - }, [filter, policy, projectName, updatePolicy, onEditRow]) + }, [filter, policy, projectName, updatePolicy, onEditRow, onViewMembers]) const tableInstance = useReactTable({ columns, @@ -213,6 +227,7 @@ function ProjectAccessTable({ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { const [addModalOpen, setAddModalOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) + const [viewingMembersRow, setViewingMembersRow] = useState(null) const { project } = useProjectSelector() @@ -249,6 +264,13 @@ export function ProjectAccessTab({ filter, children }: ProjectAccessTabProps) { defaultValues={{ roleName: editingRow.projectRole }} /> )} + {viewingMembersRow && ( + setViewingMembersRow(null)} + /> + )} {children} {rows.length === 0 ? ( )} diff --git a/app/components/SiloAccessTab.tsx b/app/components/SiloAccessTab.tsx index 5a6bf8c97..4f77ba2d8 100644 --- a/app/components/SiloAccessTab.tsx +++ b/app/components/SiloAccessTab.tsx @@ -25,6 +25,7 @@ import { import { Badge } from '@oxide/design-system/ui' import { AccessEmptyState } from '~/components/AccessEmptyState' +import { GroupMembersModal } from '~/components/GroupMembersModal' import { HL } from '~/components/HL' import { SiloAccessAddUserSideModal, @@ -32,6 +33,7 @@ import { } from '~/forms/silo-access' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { MembersCell } from '~/table/cells/MembersCell' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CreateButton } from '~/ui/lib/CreateButton' @@ -99,11 +101,13 @@ function SiloAccessTable({ rows, policy, onEditRow, + onViewMembers, }: { filter: IdentityFilter rows: SiloAccessRow[] policy: Policy onEditRow: (row: SiloAccessRow) => void + onViewMembers: (row: SiloAccessRow) => void }) { const { mutateAsync: updatePolicy } = useApiMutation(api.policyUpdate, { onSuccess: () => { @@ -116,10 +120,6 @@ function SiloAccessTable({ return [ colHelper.accessor('name', { header: 'Name' }), - // TODO: Add member information for groups once API provides it. Ideally: - // 1. A /groups/{groupId}/members endpoint to list members - // 2. A memberCount field on the Group type to show count, - // plus list of members in tooltip or expandable row // TODO: Add lastAccessed column for users once API provides it. ...(filter === 'all' ? [ @@ -136,6 +136,21 @@ function SiloAccessTable({ return role ? silo.{role} : null }, }), + // Show Users column only on groups filter + ...(filter === 'groups' + ? [ + colHelper.display({ + id: 'users', + header: 'Users', + cell: (info) => { + const row = info.row.original + return ( + onViewMembers(row)} /> + ) + }, + }), + ] + : []), getActionsCol((row: SiloAccessRow) => [ { label: 'Change role', @@ -160,7 +175,7 @@ function SiloAccessTable({ }, ]), ] - }, [filter, policy, updatePolicy, onEditRow]) + }, [filter, policy, updatePolicy, onEditRow, onViewMembers]) const tableInstance = useReactTable({ columns, @@ -178,6 +193,7 @@ function SiloAccessTable({ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { const [addModalOpen, setAddModalOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) + const [viewingMembersRow, setViewingMembersRow] = useState(null) const { data: policy } = usePrefetchedQuery(q(api.policyView, {})) const siloRows = useUserRows(policy.roleAssignments, 'silo') @@ -207,6 +223,13 @@ export function SiloAccessTab({ filter, children }: SiloAccessTabProps) { defaultValues={{ roleName: editingRow.siloRole }} /> )} + {viewingMembersRow && ( + setViewingMembersRow(null)} + /> + )} {children} {rows.length === 0 ? ( )} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index fbf7733b3..ab5abf7d4 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -47,8 +47,8 @@ export default function ProjectAccessPage() { All - Users Groups + Users ) diff --git a/app/pages/silo/access/SiloAccessPage.tsx b/app/pages/silo/access/SiloAccessPage.tsx index 1625a70bd..ed49026c4 100644 --- a/app/pages/silo/access/SiloAccessPage.tsx +++ b/app/pages/silo/access/SiloAccessPage.tsx @@ -41,8 +41,8 @@ export default function SiloAccessPage() { All - Users Groups + Users ) From 371f5701271071110f21311177e7e961b8d1dc7d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 12 Dec 2025 10:15:45 -0800 Subject: [PATCH 22/22] Add in some forgotten files --- app/components/GroupMembersModal.tsx | 72 ++++++++++++++++++++++++++++ app/table/cells/MembersCell.tsx | 38 +++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 app/components/GroupMembersModal.tsx create mode 100644 app/table/cells/MembersCell.tsx diff --git a/app/components/GroupMembersModal.tsx b/app/components/GroupMembersModal.tsx new file mode 100644 index 000000000..5c76b3c45 --- /dev/null +++ b/app/components/GroupMembersModal.tsx @@ -0,0 +1,72 @@ +/* + * 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 { Modal } from '~/ui/lib/Modal' +import { Spinner } from '~/ui/lib/Spinner' +import { ALL_ISH } from '~/util/consts' + +type GroupMembersModalProps = { + groupId: string + groupName: string + onDismiss: () => void +} + +export const GroupMembersModal = ({ + groupId, + groupName, + onDismiss, +}: GroupMembersModalProps) => { + const { data: users, isLoading } = useQuery( + q(api.userList, { query: { group: groupId, limit: ALL_ISH } }) + ) + + const hasMore = users ? !!users.nextPage : false + + return ( + + + + {isLoading ? ( +
+ +
+ ) : !users ? ( +
Failed to load members
+ ) : users.items.length === 0 ? ( +
No members in this group
+ ) : ( + <> + {hasMore && ( +
+ These are the first {users.items.length.toLocaleString()} results + returned. +
+ )} +
    + {users.items.map((user) => ( +
  • + {user.displayName} +
  • + ))} +
+ + )} +
+
+ +
+ ) +} diff --git a/app/table/cells/MembersCell.tsx b/app/table/cells/MembersCell.tsx new file mode 100644 index 000000000..e30b1e88e --- /dev/null +++ b/app/table/cells/MembersCell.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 { useQuery } from '@tanstack/react-query' + +import { api, q } from '~/api' +import { SkeletonCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' +import { ALL_ISH } from '~/util/consts' + +type MembersCellProps = { + groupId: string + onViewMembers: () => void +} + +/** + * Displays the member count for a group with lazy loading. + * Shows a loading skeleton while fetching, then displays the count + * with a "+" suffix if there are more results beyond the limit. + * TODO: API update in Omicron PR #9495 will provide total count directly. + */ +export const MembersCell = ({ groupId, onViewMembers }: MembersCellProps) => { + const { data: users } = useQuery( + q(api.userList, { query: { group: groupId, limit: ALL_ISH } }) + ) + + if (!users) return + + const count = users.items.length + const hasMore = !!users.nextPage + const displayCount = hasMore ? `${count.toLocaleString()}+` : count.toLocaleString() + + return {displayCount} +}