Users screen UI revision (#1961)

* Changed Users screen UI to selector based delete, table layout changes, updated testcafe permissions test to reflect new delete User sequence, fixed ListUsers checkbox permission issue
This commit is contained in:
jinapurapu
2022-05-16 11:28:57 -07:00
committed by GitHub
parent 6e4b8884e6
commit 448a80af4a
6 changed files with 254 additions and 111 deletions

View File

@@ -699,6 +699,7 @@ const TableWrapper = ({
inputProps={{ inputProps={{
"aria-label": "secondary checkbox", "aria-label": "secondary checkbox",
}} }}
className="TableCheckbox"
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={onSelect}
onClick={(e) => { onClick={(e) => {

View File

@@ -17,7 +17,6 @@
import React from "react"; import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { DialogContentText } from "@mui/material"; import { DialogContentText } from "@mui/material";
import { User } from "./types";
import { setErrorSnackMessage } from "../../../actions"; import { setErrorSnackMessage } from "../../../actions";
import useApi from "../Common/Hooks/useApi"; import useApi from "../Common/Hooks/useApi";
import ConfirmDialog from "../Common/ModalWrapper/ConfirmDialog"; import ConfirmDialog from "../Common/ModalWrapper/ConfirmDialog";
@@ -28,14 +27,14 @@ import { encodeURLString } from "../../../common/utils";
interface IDeleteUserProps { interface IDeleteUserProps {
closeDeleteModalAndRefresh: (refresh: boolean) => void; closeDeleteModalAndRefresh: (refresh: boolean) => void;
deleteOpen: boolean; deleteOpen: boolean;
selectedUser: User | null; selectedUsers: string[] | null;
setErrorSnackMessage: typeof setErrorSnackMessage; setErrorSnackMessage: typeof setErrorSnackMessage;
} }
const DeleteUser = ({ const DeleteUser = ({
closeDeleteModalAndRefresh, closeDeleteModalAndRefresh,
deleteOpen, deleteOpen,
selectedUser, selectedUsers,
setErrorSnackMessage, setErrorSnackMessage,
}: IDeleteUserProps) => { }: IDeleteUserProps) => {
const onDelSuccess = () => closeDeleteModalAndRefresh(true); const onDelSuccess = () => closeDeleteModalAndRefresh(true);
@@ -44,23 +43,34 @@ const DeleteUser = ({
const [deleteLoading, invokeDeleteApi] = useApi(onDelSuccess, onDelError); const [deleteLoading, invokeDeleteApi] = useApi(onDelSuccess, onDelError);
if (!selectedUser) { const userLoggedIn = localStorage.getItem("userLoggedIn") || "";
if (!selectedUsers) {
return null; return null;
} }
const renderUsers = selectedUsers.map((user) => (
<div key={user}>
<b>{user}</b>
</div>
));
const onConfirmDelete = () => { const onConfirmDelete = () => {
invokeDeleteApi( for (let user of selectedUsers) {
"DELETE", if (user === userLoggedIn) {
`/api/v1/user/${encodeURLString(selectedUser.accessKey)}`, setErrorSnackMessage({
{ errorMessage: "Cannot delete currently logged in user",
id: selectedUser.id, detailedError: `Cannot delete currently logged in user ${userLoggedIn}`,
});
closeDeleteModalAndRefresh(true);
} else {
invokeDeleteApi("DELETE", `/api/v1/user/${encodeURLString(user)}`);
} }
); }
}; };
return ( return (
<ConfirmDialog <ConfirmDialog
title={`Delete User`} title={`Delete User${selectedUsers.length > 1 ? "s" : ""}`}
confirmText={"Delete"} confirmText={"Delete"}
isOpen={deleteOpen} isOpen={deleteOpen}
titleIcon={<ConfirmDeleteIcon />} titleIcon={<ConfirmDeleteIcon />}
@@ -69,8 +79,9 @@ const DeleteUser = ({
onClose={onClose} onClose={onClose}
confirmationContent={ confirmationContent={
<DialogContentText> <DialogContentText>
Are you sure you want to delete user <br /> Are you sure you want to delete the following {selectedUsers.length}{" "}
<b>{selectedUser.accessKey}</b>? user{selectedUsers.length > 1 ? "s?" : "?"}
<b>{renderUsers}</b>
</DialogContentText> </DialogContentText>
} }
/> />

View File

@@ -26,7 +26,7 @@ import { ConfirmDeleteIcon } from "../../../icons";
import { IAM_PAGES } from "../../../common/SecureComponent/permissions"; import { IAM_PAGES } from "../../../common/SecureComponent/permissions";
import { encodeURLString } from "../../../common/utils"; import { encodeURLString } from "../../../common/utils";
interface IDeleteUserProps { interface IDeleteUserStringProps {
closeDeleteModalAndRefresh: (refresh: boolean) => void; closeDeleteModalAndRefresh: (refresh: boolean) => void;
deleteOpen: boolean; deleteOpen: boolean;
userName: string; userName: string;
@@ -38,7 +38,7 @@ const DeleteUserModal = ({
deleteOpen, deleteOpen,
userName, userName,
setErrorSnackMessage, setErrorSnackMessage,
}: IDeleteUserProps) => { }: IDeleteUserStringProps) => {
const onDelSuccess = () => { const onDelSuccess = () => {
history.push(IAM_PAGES.USERS); history.push(IAM_PAGES.USERS);
}; };

View File

@@ -23,7 +23,7 @@ import api from "../../../common/api";
import { Grid, LinearProgress } from "@mui/material"; import { Grid, LinearProgress } from "@mui/material";
import { User, UsersList } from "./types"; import { User, UsersList } from "./types";
import { usersSort } from "../../../utils/sortFunctions"; import { usersSort } from "../../../utils/sortFunctions";
import { GroupsIcon, AddIcon, UsersIcon } from "../../../icons"; import { GroupsIcon, AddIcon, DeleteIcon, UsersIcon } from "../../../icons";
import { import {
actionsTray, actionsTray,
containerForHeader, containerForHeader,
@@ -35,7 +35,7 @@ import { ErrorResponseHandler } from "../../../common/types";
import TableWrapper from "../Common/TableWrapper/TableWrapper"; import TableWrapper from "../Common/TableWrapper/TableWrapper";
import PageHeader from "../Common/PageHeader/PageHeader"; import PageHeader from "../Common/PageHeader/PageHeader";
import { decodeURLString, encodeURLString } from "../../../common/utils"; import { encodeURLString } from "../../../common/utils";
import HelpBox from "../../../common/HelpBox"; import HelpBox from "../../../common/HelpBox";
import AButton from "../Common/AButton/AButton"; import AButton from "../Common/AButton/AButton";
import PageLayout from "../Common/Layout/PageLayout"; import PageLayout from "../Common/Layout/PageLayout";
@@ -54,9 +54,6 @@ import {
SecureComponent, SecureComponent,
} from "../../../common/SecureComponent"; } from "../../../common/SecureComponent";
const SetPolicy = withSuspense(
React.lazy(() => import("../Policies/SetPolicy"))
);
const DeleteUser = withSuspense(React.lazy(() => import("./DeleteUser"))); const DeleteUser = withSuspense(React.lazy(() => import("./DeleteUser")));
const AddToGroup = withSuspense(React.lazy(() => import("./BulkAddToGroup"))); const AddToGroup = withSuspense(React.lazy(() => import("./BulkAddToGroup")));
@@ -83,20 +80,14 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
const [records, setRecords] = useState<User[]>([]); const [records, setRecords] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [deleteOpen, setDeleteOpen] = useState<boolean>(false); const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [addGroupOpen, setAddGroupOpen] = useState<boolean>(false); const [addGroupOpen, setAddGroupOpen] = useState<boolean>(false);
const [filter, setFilter] = useState<string>(""); const [filter, setFilter] = useState<string>("");
const [checkedUsers, setCheckedUsers] = useState<string[]>([]); const [checkedUsers, setCheckedUsers] = useState<string[]>([]);
const [policyOpen, setPolicyOpen] = useState<boolean>(false);
const displayListUsers = hasPermission(CONSOLE_UI_RESOURCE, [ const displayListUsers = hasPermission(CONSOLE_UI_RESOURCE, [
IAM_SCOPES.ADMIN_LIST_USERS, IAM_SCOPES.ADMIN_LIST_USERS,
]); ]);
const deleteUser = hasPermission(CONSOLE_UI_RESOURCE, [
IAM_SCOPES.ADMIN_DELETE_USER,
]);
const viewUser = hasPermission(CONSOLE_UI_RESOURCE, [ const viewUser = hasPermission(CONSOLE_UI_RESOURCE, [
IAM_SCOPES.ADMIN_GET_USER, IAM_SCOPES.ADMIN_GET_USER,
]); ]);
@@ -105,11 +96,16 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
IAM_SCOPES.ADMIN_ADD_USER_TO_GROUP, IAM_SCOPES.ADMIN_ADD_USER_TO_GROUP,
]); ]);
const deleteUser = hasPermission(CONSOLE_UI_RESOURCE, [
IAM_SCOPES.ADMIN_DELETE_USER,
]);
const closeDeleteModalAndRefresh = (refresh: boolean) => { const closeDeleteModalAndRefresh = (refresh: boolean) => {
setDeleteOpen(false); setDeleteOpen(false);
if (refresh) { if (refresh) {
setLoading(true); setLoading(true);
} }
setCheckedUsers([]);
}; };
const closeAddGroupBulk = (unCheckAll: boolean = false) => { const closeAddGroupBulk = (unCheckAll: boolean = false) => {
@@ -145,9 +141,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
); );
const selectionChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const selectionChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const targetD = e.target; const { target: { value = "", checked = false } = {} } = e;
const value = targetD.value;
const checked = targetD.checked;
let elements: string[] = [...checkedUsers]; // We clone the checkedUsers array let elements: string[] = [...checkedUsers]; // We clone the checkedUsers array
@@ -170,15 +164,6 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
); );
}; };
const deleteAction = (selectionElement: any): void => {
setDeleteOpen(true);
setSelectedUser(selectionElement);
};
const userLoggedIn = decodeURLString(
localStorage.getItem("userLoggedIn") || ""
);
const tableActions = [ const tableActions = [
{ {
type: "view", type: "view",
@@ -186,30 +171,18 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
disableButtonFunction: () => !viewUser, disableButtonFunction: () => !viewUser,
}, },
{ {
type: "delete", type: "edit",
onClick: deleteAction, onClick: viewAction,
disableButtonFunction: (topValue: any) => disableButtonFunction: () => !viewUser,
topValue === userLoggedIn || !deleteUser,
}, },
]; ];
return ( return (
<Fragment> <Fragment>
{policyOpen && (
<SetPolicy
open={policyOpen}
selectedUser={selectedUser}
selectedGroup={null}
closeModalAndRefresh={() => {
setPolicyOpen(false);
setLoading(true);
}}
/>
)}
{deleteOpen && ( {deleteOpen && (
<DeleteUser <DeleteUser
deleteOpen={deleteOpen} deleteOpen={deleteOpen}
selectedUser={selectedUser} selectedUsers={checkedUsers}
closeDeleteModalAndRefresh={(refresh: boolean) => { closeDeleteModalAndRefresh={(refresh: boolean) => {
closeDeleteModalAndRefresh(refresh); closeDeleteModalAndRefresh(refresh);
}} }}
@@ -233,6 +206,25 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
overrideClass={classes.searchField} overrideClass={classes.searchField}
value={filter} value={filter}
/> />
<SecureComponent
resource={CONSOLE_UI_RESOURCE}
scopes={[IAM_SCOPES.ADMIN_DELETE_USER]}
matchAll
errorProps={{ disabled: true }}
>
<RBIconButton
tooltip={"Delete Selected"}
onClick={() => {
setDeleteOpen(true);
}}
text={"Delete Selected"}
icon={<DeleteIcon />}
color="secondary"
disabled={checkedUsers.length === 0}
variant={"outlined"}
aria-label="delete-selected-users"
/>
</SecureComponent>
<SecureComponent <SecureComponent
scopes={[IAM_SCOPES.ADMIN_ADD_USER_TO_GROUP]} scopes={[IAM_SCOPES.ADMIN_ADD_USER_TO_GROUP]}
resource={CONSOLE_UI_RESOURCE} resource={CONSOLE_UI_RESOURCE}
@@ -280,7 +272,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
<Fragment> <Fragment>
{records.length > 0 && ( {records.length > 0 && (
<Fragment> <Fragment>
<Grid item xs={12} className={classes.tableBlock}> <Grid item xs={12} className={classes.tableBlock} marginBottom={"15px"}>
<SecureComponent <SecureComponent
scopes={[IAM_SCOPES.ADMIN_LIST_USERS]} scopes={[IAM_SCOPES.ADMIN_LIST_USERS]}
resource={CONSOLE_UI_RESOURCE} resource={CONSOLE_UI_RESOURCE}
@@ -291,7 +283,11 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
columns={[ columns={[
{ label: "Access Key", elementKey: "accessKey" }, { label: "Access Key", elementKey: "accessKey" },
]} ]}
onSelect={addUserToGroup ? selectionChanged : undefined} onSelect={
addUserToGroup || deleteUser
? selectionChanged
: undefined
}
selectedItems={checkedUsers} selectedItems={checkedUsers}
isLoading={loading} isLoading={loading}
records={filteredRecords} records={filteredRecords}
@@ -300,28 +296,30 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
/> />
</SecureComponent> </SecureComponent>
</Grid> </Grid>
<Grid item xs={12} marginTop={"25px"}> <HelpBox
<HelpBox
title={"Users"} title={"Users"}
iconComponent={<UsersIcon />} iconComponent={<UsersIcon />}
help={ help={
<Fragment> <Fragment>
A MinIO user consists of a unique access key (username) A MinIO user consists of a unique access key (username) and
and corresponding secret key (password). Clients must corresponding secret key (password). Clients must authenticate their
authenticate their identity by specifying both a valid identity by specifying both a valid access key (username) and the
access key (username) and the corresponding secret key corresponding secret key (password) of an existing MinIO user.
(password) of an existing MinIO user. <br />
<br /> Groups provide a simplified method for managing shared permissions among users with common access patterns and workloads.
<br /> <br />
Each user can have one or more assigned policies that <br />
explicitly list the actions and resources to which that Users inherit access permissions to data and resources through the groups they belong to.
user has access. Users can also inherit policies from <br />
the groups in which they have membership. MinIO uses Policy-Based Access Control (PBAC) to define the authorized actions and resources to which an authenticated user has access. Each policy describes one or more actions and conditions that outline the permissions of a user or group of users.
<br /> <br />
<br /> <br />
Each user can access only those resources and operations which are explicitly granted by the built-in role. MinIO denies access to any other resource or action by default.
<br />
<br />
You can learn more at our{" "} You can learn more at our{" "}
<a <a
href="https://docs.min.io/minio/baremetal/monitoring/bucket-notifications/bucket-notifications.html?ref=con" href="https://docs.min.io/minio/k8s/tutorials/user-management.html?ref=con"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
@@ -331,7 +329,6 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
</Fragment> </Fragment>
} }
/> />
</Grid>
</Fragment> </Fragment>
)} )}
{records.length === 0 && ( {records.length === 0 && (
@@ -339,7 +336,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
container container
justifyContent={"center"} justifyContent={"center"}
alignContent={"center"} alignContent={"center"}
alignItems={"center"} alignItems={"start"}
> >
<Grid item xs={8}> <Grid item xs={8}>
<HelpBox <HelpBox
@@ -347,46 +344,51 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
iconComponent={<UsersIcon />} iconComponent={<UsersIcon />}
help={ help={
<Fragment> <Fragment>
A MinIO user consists of a unique access key (username) A MinIO user consists of a unique access key (username) and
and corresponding secret key (password). Clients must corresponding secret key (password). Clients must authenticate their
authenticate their identity by specifying both a valid identity by specifying both a valid access key (username) and the
access key (username) and the corresponding secret key corresponding secret key (password) of an existing MinIO user.
(password) of an existing MinIO user. <br />
<br /> Groups provide a simplified method for managing shared permissions among users with common access patterns and workloads.
<br /> <br />
Each user can have one or more assigned policies that <br />
explicitly list the actions and resources to which that Users inherit access permissions to data and resources through the groups they belong to.
user has access. Users can also inherit policies from <br />
the groups in which they have membership. MinIO uses Policy-Based Access Control (PBAC) to define the authorized actions and resources to which an authenticated user has access. Each policy describes one or more actions and conditions that outline the permissions of a user or group of users.
<SecureComponent <br />
scopes={[ <br />
IAM_SCOPES.ADMIN_CREATE_USER, Each user can access only those resources and operations which are explicitly granted by the built-in role. MinIO denies access to any other resource or action by default.
IAM_SCOPES.ADMIN_LIST_USER_POLICIES,
IAM_SCOPES.ADMIN_LIST_GROUPS, <SecureComponent
]} scopes={[
matchAll IAM_SCOPES.ADMIN_CREATE_USER,
resource={CONSOLE_UI_RESOURCE} IAM_SCOPES.ADMIN_LIST_USER_POLICIES,
> IAM_SCOPES.ADMIN_LIST_GROUPS,
<Fragment> ]}
<br /> matchAll
<br /> resource={CONSOLE_UI_RESOURCE}
To get started,{" "} >
<AButton <br />
onClick={() => { <br />
history.push(`${IAM_PAGES.USER_ADD}`); To get started,{" "}
}} <AButton
> onClick={() => {
Create a User history.push(`${IAM_PAGES.USER_ADD}`);
</AButton> }}
. >
</Fragment> Create a User
</AButton>
.
</SecureComponent> </SecureComponent>
</Fragment> </Fragment>
} }
/> />
</Grid> </Grid>
</Grid> </Grid>
)} )}
</Fragment> </Fragment>
)} )}
</PageLayout> </PageLayout>

View File

@@ -0,0 +1,125 @@
// This file is part of MinIO Console Server
// Copyright (c) 2022 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { Box } from "@mui/material";
import {
HelpIconFilled,
UsersIcon,
ChangeAccessPolicyIcon,
GroupsIcon,
} from "../../../icons";
const FeatureItem = ({
icon,
description,
}: {
icon: any;
description: string;
}) => {
return (
<Box
sx={{
display: "flex",
"& .min-icon": {
marginRight: "10px",
height: "23px",
width: "23px",
marginBottom: "10px",
},
}}
>
{icon}{" "}
<div style={{ fontSize: "14px", fontStyle: "italic", color: "#5E5E5E" }}>
{description}
</div>
</Box>
);
};
const UsersHelpBox = () => {
return (
<Box
sx={{
flex: 1,
border: "1px solid #eaeaea",
borderRadius: "2px",
display: "flex",
flexFlow: "column",
padding: "20px",
marginTop: {
xs: "0px",
},
}}
>
<Box
sx={{
fontSize: "16px",
fontWeight: 600,
display: "flex",
alignItems: "center",
marginBottom: "16px",
"& .min-icon": {
height: "21px",
width: "21px",
marginRight: "15px",
},
}}
>
<HelpIconFilled />
<div>Learn more about the Users feature</div>
</Box>
<Box
sx={{
display: "flex",
flexFlow: "column",
}}
>
<FeatureItem icon={<UsersIcon />} description={`Create Users`} />
<Box sx={{ fontSize: "14px", marginBottom: "15px" }}>
A MinIO user consists of a unique access key (username) and
corresponding secret key (password). Clients must authenticate their
identity by specifying both a valid access key (username) and the
corresponding secret key (password) of an existing MinIO user.
<br />
</Box>
<FeatureItem icon={<GroupsIcon />} description={`Manage Groups`} />
<Box sx={{ fontSize: "14px", marginBottom: "15px" }}>
Groups provide a simplified method for managing shared permissions among users with common access patterns and workloads.
<br />
<br />
Users inherit access permissions to data and resources through the groups they belong to.
<br />
</Box>
<FeatureItem
icon={<ChangeAccessPolicyIcon />}
description={`Assign Policies`}
/>
<Box sx={{ fontSize: "14px", marginBottom: "15px" }}>
MinIO uses Policy-Based Access Control (PBAC) to define the authorized actions and resources to which an authenticated user has access. Each policy describes one or more actions and conditions that outline the permissions of a user or group of users.
<br />
<br />
Each user can access only those resources and operations which are explicitly granted by the built-in role. MinIO denies access to any other resource or action by default.
<br />
</Box>
</Box>
</Box>
);
};
export default UsersHelpBox;

View File

@@ -26,9 +26,10 @@ const userListItem = Selector(".ReactVirtualized__Table__rowColumn").withText(
); );
const userDeleteIconButton = userListItem const userDeleteIconButton = userListItem
.nextSibling() .child("checkbox")
.child("button") .withAttribute("aria-label", "secondary checkbox");
.withAttribute("aria-label", "delete");
const userCheckbox = Selector(".TableCheckbox");
fixture("For user with Users permissions") fixture("For user with Users permissions")
.page("http://localhost:9090") .page("http://localhost:9090")
@@ -78,12 +79,15 @@ test("Users table exists", async (t) => {
test("Created User can be viewed and deleted", async (t) => { test("Created User can be viewed and deleted", async (t) => {
const userListItemExists = userListItem.exists; const userListItemExists = userListItem.exists;
const deleteSelectedButton =
Selector("button:enabled").withExactText("Delete Selected");
await t await t
.navigateTo(usersPageUrl) .navigateTo(usersPageUrl)
.typeText(elements.searchResourceInput, constants.TEST_USER_NAME) .typeText(elements.searchResourceInput, constants.TEST_USER_NAME)
.expect(userListItemExists) .expect(userListItemExists)
.ok() .ok()
.click(userDeleteIconButton) .click(userCheckbox)
.click(deleteSelectedButton)
.click(elements.deleteButton) .click(elements.deleteButton)
.expect(userListItemExists) .expect(userListItemExists)
.notOk(); .notOk();