Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/51602-api-endpoint-restricted-users
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion frontend/components/Tag/Tag.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const meta: Meta<typeof Tag> = {
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" },
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions frontend/components/Tag/Tag.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Tag size="xsmall">Inherited</Tag>);

expect(screen.getByText("Inherited")).toHaveClass("tag--xsmall");
});

it("does not wrap the tag in a tooltip when tooltip is omitted", () => {
const { container } = render(<Tag>Inherited</Tag>);

Expand Down
6 changes: 4 additions & 2 deletions frontend/components/Tag/Tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions frontend/components/Tag/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const renderApiUserIndicator = () => {
/>
</>
}
size="small"
size="xsmall"
>
API
</Tag>
Expand Down
26 changes: 19 additions & 7 deletions frontend/pages/admin/ManageUsersPage/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
&.actions__header {
padding-left: 0;
}
&.status__header,
&.role__header {
&.status__header {
width: 86px; // set to prevent expanding
}
}
Expand All @@ -19,16 +18,21 @@
tbody {
// need specificity to override datatable css
td.name__cell,
td.role__cell,
td.teams__cell,
td.status__cell,
td.email__cell {
max-width: $col-sm;
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
}

Expand Down Expand Up @@ -83,8 +87,8 @@
}

@media (max-width: ($break-mobile-sm - 1)) {
.role__header,
.role__cell {
.permissions__header,
.permissions__cell {
display: none;
width: 0;
}
Expand Down Expand Up @@ -138,6 +142,15 @@
}
}

.users-table {
// Not to be confused with .permissions__cell, the react-table <td> this sits in
&__permissions-content {
display: flex;
align-items: center;
gap: $pad-small;
}
}

.create-user-page,
.create-api-user-page,
.edit-user-page {
Expand Down Expand Up @@ -292,4 +305,3 @@
}
}
}

Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -48,6 +49,10 @@ const EmptyUsersTable = () => (
/>
);

interface IRowProps extends Row {
original: IUserTableData;
}

interface IUsersTableProps {
router: InjectedRouter; // v3
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -347,6 +360,8 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
isAllPagesSelected={false}
isClientSidePagination
renderCount={renderUsersCount}
disableMultiRowSelect
onClickRow={(row: IRowProps) => goToEditUser(row.original)}
/>
)}
{showDeleteUserModal && renderDeleteUserModal()}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<Cell cell={{ value: row.role }} row={{ original: row }} />);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const createMockInvite = (overrides?: Partial<IInvite>): IInvite => ({
created_at: daysAgo(1),
updated_at: daysAgo(1),
Expand Down Expand Up @@ -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();
});
});
Loading
Loading