diff --git a/app/components/ListPlusCell.tsx b/app/components/ListPlusCell.tsx new file mode 100644 index 0000000000..a5e1e80914 --- /dev/null +++ b/app/components/ListPlusCell.tsx @@ -0,0 +1,39 @@ +/* + * 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 React from 'react' + +import { Tooltip } from '~/ui/lib/Tooltip' + +type ListPlusCellProps = { + tooltipTitle: string + children: React.ReactNode +} + +/** + * Gives a count with a tooltip that expands to show details when the user hovers over it + */ +export const ListPlusCell = ({ tooltipTitle, children }: ListPlusCellProps) => { + const [first, ...rest] = React.Children.toArray(children) + const content = ( +
+
{tooltipTitle}
+ {...rest} +
+ ) + return ( +
+ {first} + {rest.length > 0 && ( + +
+{rest.length}
+
+ )} +
+ ) +} diff --git a/app/components/RoleBadgeCell.tsx b/app/components/RoleBadgeCell.tsx deleted file mode 100644 index 67cc858bc1..0000000000 --- a/app/components/RoleBadgeCell.tsx +++ /dev/null @@ -1,30 +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 { CellContext } from '@tanstack/react-table' - -import type { RoleKey } from '@oxide/api' - -import { Badge } from '~/ui/lib/Badge' - -/** - * Highlight the "effective" role in green, others gray. - * - * Example: User has collab on org and viewer on project. Collab supersedes - * because it is the "stronger" role, i.e., it strictly includes the perms on - * viewer. So collab is highlighted as the "effective" role. - */ -export const RoleBadgeCell = ( - info: CellContext -) => { - const cellRole = info.getValue() - if (!cellRole) return null - const effectiveRole = info.row.original.effectiveRole - return ( - {cellRole} - ) -} diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 9ac407e749..1f02b7c1f0 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -107,7 +107,7 @@ export function ProjectAccessEditUserSideModal({ form={form} formType="edit" resourceName="role" - title={`Change role for ${name}`} + title={`Change project role for ${name}`} onSubmit={({ roleName }) => { updatePolicy.mutate({ path: { project }, diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index d1bd8c3f0f..1001d9d5ab 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -102,7 +102,7 @@ export function SiloAccessEditUserSideModal({ form={form} formType="edit" resourceName="role" - title={`Change role for ${name}`} + title={`Change silo role for ${name}`} onSubmit={({ roleName }) => { updatePolicy.mutate({ body: updateRole({ identityId, identityType, roleName }, policy), diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index f331a3d877..a9b14f0d09 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -23,7 +23,6 @@ import { import { Access24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { RoleBadgeCell } from '~/components/RoleBadgeCell' import { SiloAccessAddUserSideModal, SiloAccessEditUserSideModal, @@ -31,11 +30,12 @@ import { import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { accessTypeLabel } from '~/util/access' +import { accessTypeLabel, getBadgeColor } from '~/util/access' import { groupBy, isTruthy } from '~/util/array' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -117,8 +117,11 @@ export function SiloAccessPage() { cell: (props) => accessTypeLabel(props.getValue()), }), colHelper.accessor('siloRole', { - header: 'Silo role', - cell: RoleBadgeCell, + header: 'Role', + cell: (props) => { + const role = props.getValue() + return role ? silo.{role} : null + }, }), // TODO: tooltips on disabled elements explaining why getActionsCol((row: UserRow) => [ diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index e81c245d40..3cffabd7ce 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -14,7 +14,7 @@ import { apiQueryClient, byGroupThenName, deleteRole, - getEffectiveRole, + roleOrder, useApiMutation, useApiQueryClient, usePrefetchedApiQuery, @@ -25,7 +25,7 @@ import { import { Access24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { RoleBadgeCell } from '~/components/RoleBadgeCell' +import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, @@ -34,12 +34,14 @@ import { getProjectSelector, useProjectSelector } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { accessTypeLabel } from '~/util/access' -import { groupBy, isTruthy } from '~/util/array' +import { TipIcon } from '~/ui/lib/TipIcon' +import { accessTypeLabel, getBadgeColor } from '~/util/access' +import { groupBy, isTruthy, sortBy } from '~/util/array' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -69,9 +71,8 @@ type UserRow = { id: string identityType: IdentityType name: string - siloRole: RoleKey | undefined projectRole: RoleKey | undefined - effectiveRole: RoleKey + roleBadges: { roleSource: string; roleName: RoleKey }[] } const colHelper = createColumnHelper() @@ -92,26 +93,23 @@ export function ProjectAccessPage() { const rows = useMemo(() => { return groupBy(siloRows.concat(projectRows), (u) => u.id) .map(([userId, userAssignments]) => { - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const projectRole = userAssignments.find( - (a) => a.roleSource === 'project' - )?.roleName + const { name, identityType } = userAssignments[0] - const roles = [siloRole, projectRole].filter(isTruthy) + const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') + const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') - const { name, identityType } = userAssignments[0] + const roleBadges = sortBy( + [siloAccessRow, projectAccessRow].filter(isTruthy), + (r) => roleOrder[r.roleName] // sorts strongest role first + ) - const row: UserRow = { + return { id: userId, identityType, name, - siloRole, - projectRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, - } - - return row + projectRole: projectAccessRow?.roleName, + roleBadges, + } satisfies UserRow }) .sort(byGroupThenName) }, [siloRows, projectRows]) @@ -132,14 +130,27 @@ export function ProjectAccessPage() { header: 'Type', cell: (props) => accessTypeLabel(props.getValue()), }), - colHelper.accessor('siloRole', { - header: 'Silo role', - cell: RoleBadgeCell, - }), - colHelper.accessor('projectRole', { - header: 'Project role', - cell: RoleBadgeCell, + 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: (props) => ( + + {props.getValue().map(({ roleName, roleSource }) => ( + + {roleSource}.{roleName} + + ))} + + ), }), + // TODO: tooltips on disabled elements explaining why getActionsCol((row: UserRow) => [ { diff --git a/app/ui/lib/FieldLabel.tsx b/app/ui/lib/FieldLabel.tsx index 2ae97ae5e6..5fb298051c 100644 --- a/app/ui/lib/FieldLabel.tsx +++ b/app/ui/lib/FieldLabel.tsx @@ -8,9 +8,7 @@ import cn from 'classnames' import type { ElementType, PropsWithChildren } from 'react' -import { Question12Icon } from '@oxide/design-system/icons/react' - -import { Tooltip } from '~/ui/lib/Tooltip' +import { TipIcon } from './TipIcon' interface FieldLabelProps { id: string @@ -43,13 +41,7 @@ export const FieldLabel = ({ )} - {tip && ( - - - - )} + {tip && {tip}} ) } diff --git a/app/ui/lib/TipIcon.tsx b/app/ui/lib/TipIcon.tsx new file mode 100644 index 0000000000..c704d54073 --- /dev/null +++ b/app/ui/lib/TipIcon.tsx @@ -0,0 +1,29 @@ +/* + * 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 cn from 'classnames' + +import { Question12Icon } from '@oxide/design-system/icons/react' + +import { Tooltip } from './Tooltip' + +type TipIconProps = { + children: React.ReactNode + className?: string +} +export function TipIcon({ children, className }: TipIconProps) { + return ( + + + + ) +} diff --git a/app/util/access.spec.tsx b/app/util/access.spec.tsx index 2f0ed47884..b819a13625 100644 --- a/app/util/access.spec.tsx +++ b/app/util/access.spec.tsx @@ -7,9 +7,15 @@ */ import { expect, test } from 'vitest' -import { accessTypeLabel } from './access' +import { accessTypeLabel, getBadgeColor } from './access' test('accessTypeLabel', () => { expect(accessTypeLabel('silo_group')).toEqual('Group') expect(accessTypeLabel('silo_user')).toEqual('User') }) + +test('getBadgeColor', () => { + expect(getBadgeColor('admin')).toEqual('default') + expect(getBadgeColor('collaborator')).toEqual('purple') + expect(getBadgeColor('viewer')).toEqual('blue') +}) diff --git a/app/util/access.ts b/app/util/access.ts index 0816af6136..63d9a75d71 100644 --- a/app/util/access.ts +++ b/app/util/access.ts @@ -5,7 +5,18 @@ * * Copyright Oxide Computer Company */ + import type { IdentityType } from '~/api' +import type { BadgeColor } from '~/ui/lib/Badge' export const accessTypeLabel = (identityType: IdentityType) => identityType === 'silo_group' ? 'Group' : 'User' + +export const getBadgeColor = (role: 'admin' | 'collaborator' | 'viewer'): BadgeColor => { + const badgeColor = { + admin: 'default', + collaborator: 'purple', + viewer: 'blue', + } + return badgeColor[role] as BadgeColor +} diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index cc177ced09..c08d675b25 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -13,36 +13,34 @@ test('Click through project access page', async ({ page }) => { await page.goto('/projects/mock-project') await page.click('role=link[name*="Access & IAM"]') - // page is there, we see user 1-3 but not 4 + // page is there, we see user 1 and 3 but not 2 or 4 await expectVisible(page, ['role=heading[name*="Access & IAM"]']) const table = page.locator('table') await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', - 'Silo role': 'admin', - 'Project role': '', + Role: 'silo.admin', }) await expectRowVisible(table, { Name: 'Jacob Klein', Type: 'User', - 'Silo role': '', - 'Project role': 'collaborator', + Role: 'project.collaborator', }) await expectRowVisible(table, { - // no space because expectRowVisible uses textContent, not accessible name Name: 'real-estate-devs', Type: 'Group', - 'Silo role': 'collaborator', + Role: 'silo.collaborator', }) await expectRowVisible(table, { - // no space because expectRowVisible uses textContent, not accessible name Name: 'kernel-devs', Type: 'Group', - 'Silo role': '', - 'Project role': 'viewer', + Role: 'project.viewer', }) - await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`]) + await expectNotVisible(page, [ + `role=cell[name="Hans Jonas"]`, + `role=cell[name="Simone de Beauvoir"]`, + ]) // Add user 4 as collab await page.click('role=button[name="Add user or group"]') @@ -51,6 +49,7 @@ test('Click through project access page', async ({ page }) => { await page.click('role=button[name*="User or group"]') // only users not already on the project should be visible await expectNotVisible(page, ['role=option[name="Jacob Klein"]']) + await expectVisible(page, [ 'role=option[name="Hannah Arendt"]', 'role=option[name="Hans Jonas"]', @@ -72,7 +71,8 @@ test('Click through project access page', async ({ page }) => { // User 4 shows up in the table await expectRowVisible(table, { Name: 'Simone de Beauvoir', - 'Project role': 'collaborator', + Type: 'User', + Role: 'project.collaborator', }) // now change user 4 role from collab to viewer @@ -82,14 +82,16 @@ test('Click through project access page', async ({ page }) => { .click() await page.click('role=menuitem[name="Change role"]') - await expectVisible(page, ['role=heading[name*="Change role for Simone de Beauvoir"]']) + await expectVisible(page, [ + 'role=heading[name*="Change project role for Simone de Beauvoir"]', + ]) await expectVisible(page, ['button:has-text("Collaborator")']) await page.click('role=button[name*="Role"]') await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { Name: user4.display_name, 'Project role': 'viewer' }) + await expectRowVisible(table, { Name: user4.display_name, Role: 'project.viewer' }) // now delete user 3. has to be 3 or 4 because they're the only ones that come // from the project policy @@ -107,9 +109,10 @@ test('Click through project access page', async ({ page }) => { await page.click('role=button[name*="Role"]') await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Assign role"]') + // because we only show the "effective" role, we should still see the silo admin role, but should now have an additional count value await expectRowVisible(table, { Name: 'Hannah Arendt', - 'Silo role': 'admin', - 'Project role': 'viewer', + Type: 'User', + Role: 'silo.admin+1', }) }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index ec45ede7b7..5db838b65b 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -19,15 +19,14 @@ test('Click through silo access page', async ({ page }) => { await expectVisible(page, ['role=heading[name*="Access & IAM"]']) await expectRowVisible(table, { - // no space because expectRowVisible uses textContent, not accessible name Name: 'real-estate-devs', Type: 'Group', - 'Silo role': 'collaborator', + Role: 'silo.collaborator', }) await expectRowVisible(table, { Name: 'Hannah Arendt', Type: 'User', - 'Silo role': 'admin', + Role: 'silo.admin', }) await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`]) @@ -59,7 +58,8 @@ test('Click through silo access page', async ({ page }) => { // User 3 shows up in the table await expectRowVisible(table, { Name: 'Jacob Klein', - 'Silo role': 'collaborator', + Role: 'silo.collaborator', + Type: 'User', }) // now change user 3's role from collab to viewer @@ -69,14 +69,14 @@ test('Click through silo access page', async ({ page }) => { .click() await page.click('role=menuitem[name="Change role"]') - await expectVisible(page, ['role=heading[name*="Change role for Jacob Klein"]']) + await expectVisible(page, ['role=heading[name*="Change silo role for Jacob Klein"]']) await expectVisible(page, ['button:has-text("Collaborator")']) await page.click('role=button[name*="Role"]') await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(table, { Name: user3.display_name, 'Silo role': 'viewer' }) + await expectRowVisible(table, { Name: user3.display_name, Role: 'silo.viewer' }) // now delete user 3 const user3Row = page.getByRole('row', { name: user3.display_name, exact: false })