diff --git a/changes/51602-api-endpoint-restricted-users b/changes/51602-api-endpoint-restricted-users new file mode 100644 index 00000000000..5c5b1bb20b6 --- /dev/null +++ b/changes/51602-api-endpoint-restricted-users @@ -0,0 +1,2 @@ +* Renamed the "Role" column to "Permissions" on the Settings > Users page, and added a badge showing how many API endpoints an API-only user is restricted to. +* Updated the Settings > Users table so the actions dropdown only appears on row hover and clicking anywhere else in a row opens that user's edit page. diff --git a/frontend/components/Tag/Tag.stories.tsx b/frontend/components/Tag/Tag.stories.tsx index 7c8f81b1963..3aa26d67382 100644 --- a/frontend/components/Tag/Tag.stories.tsx +++ b/frontend/components/Tag/Tag.stories.tsx @@ -11,7 +11,7 @@ const meta: Meta = { title: "Components/Tag", argTypes: { children: { control: "text" }, - size: { control: "radio", options: ["large", "small"] }, + size: { control: "radio", options: ["large", "small", "xsmall"] }, disabled: { control: "boolean" }, tooltip: { control: "text" }, className: { control: "text" }, @@ -38,6 +38,13 @@ export const Small: Story = { }, }; +export const XSmall: Story = { + args: { + children: "16 API endpoints", + size: "xsmall", + }, +}; + export const WithTooltip: Story = { args: { children: "Inherited", diff --git a/frontend/components/Tag/Tag.tests.tsx b/frontend/components/Tag/Tag.tests.tsx index 0d7eb11d5ba..f65d4494ab2 100644 --- a/frontend/components/Tag/Tag.tests.tsx +++ b/frontend/components/Tag/Tag.tests.tsx @@ -24,6 +24,12 @@ describe("Tag", () => { expect(screen.getByText("Inherited")).toHaveClass("tag--small"); }); + it("adds the xsmall modifier class when size is set to xsmall", () => { + render(Inherited); + + expect(screen.getByText("Inherited")).toHaveClass("tag--xsmall"); + }); + it("does not wrap the tag in a tooltip when tooltip is omitted", () => { const { container } = render(Inherited); diff --git a/frontend/components/Tag/Tag.tsx b/frontend/components/Tag/Tag.tsx index 2fd3556deda..f0697093610 100644 --- a/frontend/components/Tag/Tag.tsx +++ b/frontend/components/Tag/Tag.tsx @@ -8,8 +8,9 @@ const baseClass = "tag"; interface ITagBaseProps { children: React.ReactNode; - /** Default: "large" (28px). Per design, use "small" (24px) sparingly. */ - size?: "large" | "small"; + /** Default: "large" (28px). Per design, use "small" (24px) sparingly and + * "xsmall" (20px) only inline with table cell text. */ + size?: "large" | "small" | "xsmall"; className?: string; /** Wraps the tag in a tooltip that shows this content on hover */ tooltip?: JSX.Element | string; @@ -51,6 +52,7 @@ const Tag = (props: ITagProps) => { [`${baseClass}--clickable`]: props.type === "clickable", [`${baseClass}--dismissible`]: props.type === "dismissible", [`${baseClass}--small`]: props.size === "small", + [`${baseClass}--xsmall`]: props.size === "xsmall", }); let content: JSX.Element; diff --git a/frontend/components/Tag/_styles.scss b/frontend/components/Tag/_styles.scss index 32ac51cb9af..e0d9a712150 100644 --- a/frontend/components/Tag/_styles.scss +++ b/frontend/components/Tag/_styles.scss @@ -19,6 +19,13 @@ height: 24px; } + &--xsmall { + height: 20px; + padding: 0 $pad-xsmall; + gap: $pad-xsmall; + font-size: $xx-small; + } + &--clickable { background: none; cursor: pointer; diff --git a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx index 5072f42ba22..f0716a58baf 100644 --- a/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx +++ b/frontend/pages/admin/ManageFleetsPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx @@ -74,7 +74,7 @@ export const renderApiUserIndicator = () => { /> } - size="small" + size="xsmall" > API diff --git a/frontend/pages/admin/ManageUsersPage/_styles.scss b/frontend/pages/admin/ManageUsersPage/_styles.scss index fd7e1343de8..2d52bd60852 100644 --- a/frontend/pages/admin/ManageUsersPage/_styles.scss +++ b/frontend/pages/admin/ManageUsersPage/_styles.scss @@ -9,8 +9,7 @@ &.actions__header { padding-left: 0; } - &.status__header, - &.role__header { + &.status__header { width: 86px; // set to prevent expanding } } @@ -19,7 +18,6 @@ tbody { // need specificity to override datatable css td.name__cell, - td.role__cell, td.teams__cell, td.status__cell, td.email__cell { @@ -27,8 +25,14 @@ white-space: nowrap; } + // Wider than its siblings so the role plus the API endpoint count tag + // fit on one line + td.permissions__cell { + max-width: $col-md; + } + td.status__cell, - td.role__cell { + td.permissions__cell { white-space: nowrap; // Prevent No access from wrapping } @@ -83,8 +87,8 @@ } @media (max-width: ($break-mobile-sm - 1)) { - .role__header, - .role__cell { + .permissions__header, + .permissions__cell { display: none; width: 0; } @@ -138,6 +142,15 @@ } } +.users-table { + // Not to be confused with .permissions__cell, the react-table this sits in + &__permissions-content { + display: flex; + align-items: center; + gap: $pad-small; + } +} + .create-user-page, .create-api-user-page, .edit-user-page { @@ -292,4 +305,3 @@ } } } - diff --git a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx index 03028d37984..29cf25be104 100644 --- a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTable.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useContext, useMemo } from "react"; import { InjectedRouter } from "react-router"; +import { Row } from "react-table"; import { useQuery } from "react-query"; import PATHS from "router/paths"; @@ -48,6 +49,10 @@ const EmptyUsersTable = () => ( /> ); +interface IRowProps extends Row { + original: IUserTableData; +} + interface IUsersTableProps { router: InjectedRouter; // v3 } @@ -121,16 +126,27 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { // FUNCTIONS + const goToEditUser = useCallback( + (user: IUserTableData) => { + if (user.type === "user" && user.apiId === currentUser?.id) { + router.push(PATHS.ACCOUNT); + return; + } + const editPath = PATHS.ADMIN_USERS_EDIT(user.apiId); + router.push( + user.type === "invite" ? `${editPath}?type=invite` : editPath + ); + }, + [router, currentUser?.id] + ); + const onActionSelect = useCallback( (value: string, user: IUserTableData) => { switch (value) { - case "edit": { - const editPath = PATHS.ADMIN_USERS_EDIT(user.apiId); - router.push( - user.type === "invite" ? `${editPath}?type=invite` : editPath - ); + case "edit": + case "editMyAccount": + goToEditUser(user); break; - } case "delete": toggleDeleteUserModal(user); break; @@ -140,16 +156,13 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { case "resetSessions": toggleResetSessionsUserModal(user); break; - case "editMyAccount": - router.push(PATHS.ACCOUNT); - break; default: return null; } return null; }, [ - router, + goToEditUser, toggleDeleteUserModal, toggleResetPasswordUserModal, toggleResetSessionsUserModal, @@ -347,6 +360,8 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => { isAllPagesSelected={false} isClientSidePagination renderCount={renderUsersCount} + disableMultiRowSelect + onClickRow={(row: IRowProps) => goToEditUser(row.original)} /> )} {showDeleteUserModal && renderDeleteUserModal()} diff --git a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tests.tsx b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tests.tsx index eac26ffd5fe..96832d7524d 100644 --- a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tests.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tests.tsx @@ -1,11 +1,37 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + import createMockUser from "__mocks__/userMock"; import { IInvite } from "interfaces/invite"; +import { IApiEndpointRef } from "interfaces/api_endpoint"; -import { combineDataSets } from "./UsersTableConfig"; +import { + combineDataSets, + generateTableHeaders, + IUserTableData, +} from "./UsersTableConfig"; const daysAgo = (days: number): string => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); +const mockEndpoints = (count: number): IApiEndpointRef[] => + Array.from({ length: count }, (_unused, i) => ({ + method: "GET", + path: `/api/v1/fleet/endpoint-${i}`, + })); + +const renderPermissionsCell = (row: IUserTableData) => { + const column = generateTableHeaders(jest.fn(), true).find( + (c) => c.id === "permissions" + ); + const Cell = column?.Cell as (props: { + cell: { value: string }; + row: { original: IUserTableData }; + }) => JSX.Element; + + render(); +}; + const createMockInvite = (overrides?: Partial): IInvite => ({ created_at: daysAgo(1), updated_at: daysAgo(1), @@ -56,3 +82,64 @@ describe("UsersTableConfig - combineDataSets", () => { expect(row.status).toBe("No access"); }); }); + +describe("UsersTableConfig - API endpoint restrictions", () => { + it("counts the endpoints a user is restricted to", () => { + const users = [ + createMockUser({ api_only: true, api_endpoints: mockEndpoints(3) }), + ]; + const [row] = combineDataSets(users, [], 99); + expect(row.apiEndpointCount).toBe(3); + }); + + it("counts zero endpoints for a user with unrestricted API access", () => { + const users = [createMockUser({ api_only: true })]; + const [row] = combineDataSets(users, [], 99); + expect(row.apiEndpointCount).toBe(0); + }); + + it("counts zero endpoints for invites", () => { + const [row] = combineDataSets([], [createMockInvite()], 99); + expect(row.apiEndpointCount).toBe(0); + }); + + it("names the role column 'Permissions'", () => { + const column = generateTableHeaders(jest.fn(), true).find( + (c) => c.id === "permissions" + ); + expect(column?.title).toBe("Permissions"); + expect(column?.Header).toBe("Permissions"); + }); + + it("shows a badge with the endpoint count in the Permissions cell", () => { + const users = [ + createMockUser({ api_only: true, api_endpoints: mockEndpoints(16) }), + ]; + const [row] = combineDataSets(users, [], 99); + + renderPermissionsCell(row); + + expect(screen.getByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("16 API endpoints")).toBeInTheDocument(); + }); + + it("singularizes the badge when the user is restricted to one endpoint", () => { + const users = [ + createMockUser({ api_only: true, api_endpoints: mockEndpoints(1) }), + ]; + const [row] = combineDataSets(users, [], 99); + + renderPermissionsCell(row); + + expect(screen.getByText("1 API endpoint")).toBeInTheDocument(); + }); + + it("omits the badge for a user with unrestricted API access", () => { + const users = [createMockUser({ api_only: true })]; + const [row] = combineDataSets(users, [], 99); + + renderPermissionsCell(row); + + expect(screen.queryByText(/API endpoint/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tsx b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tsx index bee6474f676..c7095bfe0de 100644 --- a/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tsx +++ b/frontend/pages/admin/ManageUsersPage/components/UsersTable/UsersTableConfig.tsx @@ -22,14 +22,20 @@ import { import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants"; import ActionsDropdown from "../../../../../components/ActionsDropdown"; +const baseClass = "users-table"; + const renderApiUserIndicator = () => { return ( - + API ); }; +const renderApiEndpointCount = (count: number) => ( + {`${count} API endpoint${count === 1 ? "" : "s"}`} +); + interface IHeaderProps { column: { title: string; @@ -59,6 +65,7 @@ interface IDataColumn { title: string; Header: ((props: IHeaderProps) => JSX.Element) | string; accessor: string; + id?: string; Cell: | ((props: ICellProps) => JSX.Element) | ((props: IActionsDropdownProps) => JSX.Element); @@ -81,6 +88,8 @@ export interface IUserTableData { apiId: number; type: string; api_only: boolean; + /** Number of API endpoints this user is restricted to; 0 means unrestricted */ + apiEndpointCount: number; } // The inactivity window is enforced server-side (see UserInactiveAfter in @@ -106,8 +115,68 @@ const generateInviteStatus = (invite: IInvite): string => ? "No access" : "Invite pending"; -// NOTE: cellProps come from react-table -// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties +const renderRole = (cellProps: ICellProps) => { + if (cellProps.cell.value === "GitOps") { + return ( + + The GitOps role is only available for API-only +
+ users. This user has no access to the UI. + + } + > + GitOps +
+ ); + } + if (cellProps.cell.value === "Observer+") { + return ( + + Users with the Observer+ role have access to all of +
+ the same functions as an Observer, with the added +
+ ability to run any live report against all hosts. + + } + > + {cellProps.cell.value} +
+ ); + } + if (cellProps.cell.value === ROLE_VARIOUS) { + const { roleGroups } = cellProps.row.original; + return ( + ( + + {role}: {names.join(", ")} +
+
+ ))} + underline={false} + showArrow + position="top" + tipOffset={10} + fixedPositionStrategy + > + +
+ ); + } + return ( + + ); +}; + const generateTableHeaders = ( actionSelectHandler: (value: string, user: IUserTableData) => void, isPremiumTier: boolean | undefined @@ -133,69 +202,22 @@ const generateTableHeaders = ( }, }, { - title: "Role", - Header: "Role", + title: "Permissions", + Header: "Permissions", accessor: "role", + // react-table derives the cell/header DOM classes and the sort key from + // `id`, so this keeps them as `permissions__*` without renaming the + // underlying row field. + id: "permissions", disableSortBy: true, Cell: (cellProps: ICellProps) => { - if (cellProps.cell.value === "GitOps") { - return ( - - The GitOps role is only available for API-only -
- users. This user has no access to the UI. - - } - > - GitOps -
- ); - } - if (cellProps.cell.value === "Observer+") { - return ( - - Users with the Observer+ role have access to all of -
- the same functions as an Observer, with the added -
- ability to run any live report against all hosts. - - } - > - {cellProps.cell.value} -
- ); - } - if (cellProps.cell.value === ROLE_VARIOUS) { - const { roleGroups } = cellProps.row.original; - return ( - ( - - {role}: {names.join(", ")} -
-
- ))} - underline={false} - showArrow - position="top" - tipOffset={10} - fixedPositionStrategy - > - -
- ); - } + const { apiEndpointCount } = cellProps.row.original; + return ( - +
+ {renderRole(cellProps)} + {apiEndpointCount > 0 && renderApiEndpointCount(apiEndpointCount)} +
); }, }, @@ -249,15 +271,22 @@ const generateTableHeaders = ( disableSortBy: true, accessor: "actions", Cell: (cellProps: IActionsDropdownProps) => ( - - actionSelectHandler(value, cellProps.row.original) - } - placeholder="Actions" - menuAlign="right" - variant="secondary" - /> +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + actionSelectHandler(value, cellProps.row.original) + } + placeholder="Actions" + menuAlign="right" + variant="secondary" + /> +
), }, ]; @@ -381,6 +410,7 @@ const enhanceUserData = ( apiId: user.id, type: "user", api_only: user.api_only, + apiEndpointCount: user.api_endpoints?.length ?? 0, }; }); }; @@ -405,6 +435,7 @@ const enhanceInviteData = (invites: IInvite[]): IUserTableData[] => { apiId: invite.id, type: "invite", api_only: false, // api only users are created through fleetctl and not invites + apiEndpointCount: 0, }; }); };