Moved AddUser from modal to screen (#1869)

Co-authored-by: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com>
This commit is contained in:
jinapurapu
2022-04-22 21:40:20 -07:00
committed by GitHub
parent fda090f7dd
commit 66df609d4a
9 changed files with 437 additions and 335 deletions

View File

@@ -120,6 +120,7 @@ export const IAM_PAGES = {
IDENTITY: "/identity",
USERS: "/identity/users",
USERS_VIEW: "/identity/users/:userName+",
USER_ADD: "/identity/add-user",
GROUPS: "/identity/groups",
GROUPS_ADD: "/identity/create-group",
GROUPS_VIEW: "/identity/groups/:groupName+",
@@ -313,6 +314,8 @@ export const IAM_PAGES_PERMISSIONS = {
IAM_SCOPES.ADMIN_DISABLE_USER,
IAM_SCOPES.ADMIN_DELETE_USER,
],
[IAM_PAGES.USER_ADD]: [
IAM_SCOPES.ADMIN_CREATE_USER,], // displays create user button
[IAM_PAGES.ACCOUNT_ADD]: [
IAM_SCOPES.ADMIN_CREATE_SERVICEACCOUNT,
],

View File

@@ -281,6 +281,10 @@ const Console = ({
component: Users,
path: IAM_PAGES.USERS_VIEW,
},
{
component: Users,
path: IAM_PAGES.USER_ADD,
},
{
component: Users,
path: IAM_PAGES.USERS,

View File

@@ -145,7 +145,7 @@ const PolicySelectors = ({
<span className={classes.fieldLabel}>Assign Policies</span>
<div className={classes.searchBox}>
<SearchBox
placeholder="Filter Policy"
placeholder="Start typing to search for a Policy"
onChange={(value) => {
setFilter(value);
}}

View File

@@ -1,311 +0,0 @@
// This file is part of MinIO Console Server
// Copyright (c) 2021 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, { useCallback, useEffect, useState } from "react";
import { connect } from "react-redux";
import Grid from "@mui/material/Grid";
import { Button, LinearProgress, Tab, Tabs } from "@mui/material";
import { Theme } from "@mui/material/styles";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import {
formFieldStyles,
modalStyleUtils,
spacingUtils,
} from "../Common/FormComponents/common/styleLibrary";
import { User } from "./types";
import { setModalErrorSnackMessage } from "../../../actions";
import { ErrorResponseHandler } from "../../../common/types";
import api from "../../../common/api";
import GroupsSelectors from "./GroupsSelectors";
import ModalWrapper from "../Common/ModalWrapper/ModalWrapper";
import InputBoxWrapper from "../Common/FormComponents/InputBoxWrapper/InputBoxWrapper";
import FormSwitchWrapper from "../Common/FormComponents/FormSwitchWrapper/FormSwitchWrapper";
import PredefinedList from "../Common/FormComponents/PredefinedList/PredefinedList";
import PolicySelectors from "../Policies/PolicySelectors";
import { TabPanel } from "../../shared/tabs";
import { CreateUserIcon } from "../../../icons";
const styles = (theme: Theme) =>
createStyles({
tabsHeader: {
marginBottom: "1rem",
},
...modalStyleUtils,
...formFieldStyles,
...spacingUtils,
});
interface IAddUserContentProps {
classes: any;
closeModalAndRefresh: () => void;
selectedUser: User | null;
open: boolean;
setModalErrorSnackMessage: typeof setModalErrorSnackMessage;
}
const AddUser = ({
classes,
closeModalAndRefresh,
selectedUser,
open,
setModalErrorSnackMessage,
}: IAddUserContentProps) => {
const [addLoading, setAddLoading] = useState<boolean>(false);
const [accessKey, setAccessKey] = useState<string>("");
const [secretKey, setSecretKey] = useState<string>("");
const [enabled, setEnabled] = useState<boolean>(false);
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [selectedPolicies, setSelectedPolicies] = useState<string[]>([]);
const [currentGroups, setCurrentGroups] = useState<string[]>([]);
const [currenTab, setCurrenTab] = useState<number>(0);
const getUserInformation = useCallback(() => {
if (!selectedUser) {
return null;
}
api
.invoke("GET", `/api/v1/user?name=${encodeURI(selectedUser.accessKey)}`)
.then((res) => {
setAddLoading(false);
setAccessKey(res.accessKey);
setSelectedGroups(res.memberOf || []);
setCurrentGroups(res.memberOf || []);
setEnabled(res.status === "enabled");
})
.catch((err: ErrorResponseHandler) => {
setAddLoading(false);
setModalErrorSnackMessage(err);
});
}, [selectedUser, setModalErrorSnackMessage]);
useEffect(() => {
if (selectedUser === null) {
setAccessKey("");
setSecretKey("");
setSelectedGroups([]);
} else {
getUserInformation();
}
}, [selectedUser, getUserInformation]);
const saveRecord = (event: React.FormEvent) => {
event.preventDefault();
if (secretKey.length < 8) {
setModalErrorSnackMessage({
errorMessage: "Passwords must be at least 8 characters long",
detailedError: "",
});
setAddLoading(false);
return;
}
if (addLoading) {
return;
}
setAddLoading(true);
if (selectedUser !== null) {
api
.invoke(
"PUT",
`/api/v1/user?name=${encodeURI(selectedUser.accessKey)}`,
{
status: enabled ? "enabled" : "disabled",
groups: selectedGroups,
policies: selectedPolicies,
}
)
.then((res) => {
setAddLoading(false);
closeModalAndRefresh();
})
.catch((err: ErrorResponseHandler) => {
setAddLoading(false);
setModalErrorSnackMessage(err);
});
} else {
api
.invoke("POST", "/api/v1/users", {
accessKey,
secretKey,
groups: selectedGroups,
policies: selectedPolicies,
})
.then((res) => {
setAddLoading(false);
closeModalAndRefresh();
})
.catch((err: ErrorResponseHandler) => {
setAddLoading(false);
setModalErrorSnackMessage(err);
});
}
};
const resetForm = () => {
if (selectedUser !== null) {
setSelectedGroups([]);
return;
}
setAccessKey("");
setSecretKey("");
setSelectedGroups([]);
};
const sendEnabled =
accessKey.trim() !== "" &&
((secretKey.trim() !== "" && selectedUser === null) ||
selectedUser !== null);
return (
<ModalWrapper
onClose={() => {
closeModalAndRefresh();
}}
modalOpen={open}
title={selectedUser !== null ? "Edit User" : "Create User"}
titleIcon={<CreateUserIcon />}
>
{selectedUser !== null && (
<div className={classes.floatingEnabled}>
<FormSwitchWrapper
indicatorLabels={["Enabled", "Disabled"]}
checked={enabled}
value={"user_enabled"}
id="user-status"
name="user-status"
onChange={(e) => {
setEnabled(e.target.checked);
}}
switchOnly
/>
</div>
)}
<React.Fragment>
<form
noValidate
autoComplete="off"
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
saveRecord(e);
}}
>
<Grid container>
<Grid item xs={12}>
<div className={classes.formFieldRow}>
<InputBoxWrapper
id="accesskey-input"
name="accesskey-input"
label="Access Key"
value={accessKey}
autoFocus={true}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setAccessKey(e.target.value);
}}
disabled={selectedUser !== null}
/>
</div>
{selectedUser !== null ? (
<PredefinedList
label={"Current Groups"}
content={currentGroups.join(", ")}
/>
) : (
<div className={classes.formFieldRow}>
<InputBoxWrapper
id="standard-multiline-static"
name="standard-multiline-static"
label="Secret Key"
type="password"
value={secretKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSecretKey(e.target.value);
}}
autoComplete="current-password"
/>
</div>
)}
<Grid item xs={12} className={classes.tabsHeader}>
<Tabs
value={currenTab}
onChange={(e, nv) => {
setCurrenTab(nv);
}}
>
<Tab label="Policies" />
<Tab label="Groups" />
</Tabs>
</Grid>
<TabPanel value={currenTab} index={0}>
<Grid item xs={12}>
<PolicySelectors
selectedPolicy={selectedPolicies}
setSelectedPolicy={setSelectedPolicies}
/>
</Grid>
</TabPanel>
<TabPanel value={currenTab} index={1}>
<Grid item xs={12}>
<GroupsSelectors
selectedGroups={selectedGroups}
setSelectedGroups={(elements: string[]) => {
setSelectedGroups(elements);
}}
/>
</Grid>
</TabPanel>
{addLoading && (
<Grid item xs={12}>
<LinearProgress />
</Grid>
)}
</Grid>
<Grid item xs={12} className={classes.modalButtonBar}>
<Button
type="button"
variant="outlined"
color="primary"
onClick={resetForm}
>
Clear
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={addLoading || !sendEnabled}
>
Save
</Button>
</Grid>
</Grid>
</form>
</React.Fragment>
</ModalWrapper>
);
};
const mapDispatchToProps = {
setModalErrorSnackMessage,
};
const connector = connect(null, mapDispatchToProps);
export default withStyles(styles)(connector(AddUser));

View File

@@ -0,0 +1,119 @@
// 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 AddUserHelpBox = ({ hasMargin = true }: { hasMargin?: boolean }) => {
return (
<Box
sx={{
flex: 1,
border: "1px solid #eaeaea",
borderRadius: "2px",
display: "flex",
flexFlow: "column",
padding: "20px",
marginLeft: {
xs: "0px",
sm: "0px",
md: hasMargin ? "30px" : "",
},
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={{ 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 />
<br />
Each user can have one or more assigned policies that explicitly list
the actions and resources to which that user has access. Users can also
inherit policies from the groups in which they have membership.
<br />
</Box>
<Box
sx={{
display: "flex",
flexFlow: "column",
}}
>
<FeatureItem icon={<UsersIcon />} description={`Create Users`} />
<FeatureItem icon={<GroupsIcon />} description={`Manage Groups`} />
<FeatureItem
icon={<ChangeAccessPolicyIcon />}
description={`Assign Policies`}
/>
</Box>
</Box>
);
};
export default AddUserHelpBox;

View File

@@ -0,0 +1,304 @@
// 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, { Fragment, useState } from "react";
import { Theme } from "@mui/material/styles";
import createStyles from "@mui/styles/createStyles";
import withStyles from "@mui/styles/withStyles";
import {
formFieldStyles,
modalStyleUtils,
} from "../Common/FormComponents/common/styleLibrary";
import Grid from "@mui/material/Grid";
import { Button, LinearProgress, Box } from "@mui/material";
import { CreateUserIcon } from "../../../icons";
import PageHeader from "../Common/PageHeader/PageHeader";
import PageLayout from "../Common/Layout/PageLayout";
import history from "../../../../src/history";
import InputBoxWrapper from "../Common/FormComponents/InputBoxWrapper/InputBoxWrapper";
import AddUserHelpBox from "./AddUserHelpBox";
import PolicySelectors from "../Policies/PolicySelectors";
import BackLink from "../../../common/BackLink";
import GroupsSelectors from "./GroupsSelectors";
import { connect } from "react-redux";
import { User } from "./types";
import RemoveRedEyeIcon from "@mui/icons-material/RemoveRedEye";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import { IAM_PAGES } from "../../../common/SecureComponent/permissions";
import { ErrorResponseHandler } from "../../../../src/common/types";
import api from "../../../../src/common/api";
import { setErrorSnackMessage } from "../../../../src/actions";
interface IAddUserProps {
classes: any;
setErrorSnackMessage: typeof setErrorSnackMessage;
selectedUser: User | null;
}
const styles = (theme: Theme) =>
createStyles({
buttonContainer: {
textAlign: "right",
},
bottomContainer: {
display: "flex",
flexGrow: 1,
alignItems: "center",
margin: "auto",
justifyContent: "center",
"& div": {
width: 150,
"@media (max-width: 900px)": {
flexFlow: "column",
},
},
},
factorElements: {
display: "flex",
justifyContent: "flex-start",
marginLeft: 30,
},
sizeNumber: {
fontSize: 35,
fontWeight: 700,
textAlign: "center",
},
sizeDescription: {
fontSize: 14,
color: "#777",
textAlign: "center",
},
pageBox: {
border: "1px solid #EAEAEA",
borderTop: 0,
},
addPoolTitle: {
border: "1px solid #EAEAEA",
borderBottom: 0,
},
headTitle: {
fontWeight: "bold",
fontSize: 16,
paddingLeft: 8,
},
...formFieldStyles,
...modalStyleUtils,
});
const AddUser = ({
classes,
setErrorSnackMessage,
}: IAddUserProps) => {
const [addLoading, setAddLoading] = useState<boolean>(false);
const [accessKey, setAccessKey] = useState<string>("");
const [secretKey, setSecretKey] = useState<string>("");
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
const [selectedPolicies, setSelectedPolicies] = useState<string[]>([]);
const [showPassword, setShowPassword] = useState<boolean>(false);
const sendEnabled = accessKey.trim() !== "";
const saveRecord = (event: React.FormEvent) => {
event.preventDefault();
if (secretKey.length < 8) {
setErrorSnackMessage({
errorMessage: "Passwords must be at least 8 characters long",
detailedError: "",
});
setAddLoading(false);
return;
}
if (addLoading) {
return;
}
setAddLoading(true);
api
.invoke("POST", "/api/v1/users", {
accessKey,
secretKey,
groups: selectedGroups,
policies: selectedPolicies,
})
.then((res) => {
setAddLoading(false);
history.push(`${IAM_PAGES.USERS}`);
})
.catch((err: ErrorResponseHandler) => {
setAddLoading(false);
setErrorSnackMessage(err);
});
};
const resetForm = () => {
setSelectedGroups([]);
setAccessKey("");
setSecretKey("");
setSelectedPolicies([]);
setShowPassword(false);
};
return (
<Fragment>
<Grid item xs={12}>
<PageHeader label={<BackLink to={IAM_PAGES.USERS} label={"Users"} />} />
<PageLayout>
<Grid
item
xs={12}
container
className={classes.title}
align-items="baseline"
>
<Grid item xs={"auto"}>
<CreateUserIcon />
</Grid>
<Grid
item
xs={"auto"}
align-self="end"
className={classes.headTitle}
>
Create User
</Grid>
</Grid>
<Grid container align-items="center">
<Grid item xs={8}>
<Box>
<form
noValidate
autoComplete="off"
onSubmit={(e: React.FormEvent<HTMLFormElement>) => {
saveRecord(e);
}}
>
<Grid container>
<Grid item xs={12}>
<div className={classes.formFieldRow}>
<InputBoxWrapper
className={classes.spacerBottom}
classes={{
inputLabel: classes.sizedLabel,
}}
id="accesskey-input"
name="accesskey-input"
label="User Name"
value={accessKey}
autoFocus={true}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
setAccessKey(e.target.value);
}}
/>
</div>
<div className={classes.formFieldRow}>
<InputBoxWrapper
className={classes.spacerBottom}
classes={{
inputLabel: classes.sizedLabel,
}}
id="standard-multiline-static"
name="standard-multiline-static"
label="Password"
type={showPassword ? "text" : "password"}
value={secretKey}
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
setSecretKey(e.target.value);
}}
autoComplete="current-password"
overlayIcon={
showPassword ? (
<VisibilityOffIcon />
) : (
<RemoveRedEyeIcon />
)
}
overlayAction={() => setShowPassword(!showPassword)}
/>
</div>
<Grid container item spacing="20">
<Grid item xs={12}>
<PolicySelectors
selectedPolicy={selectedPolicies}
setSelectedPolicy={setSelectedPolicies}
/>
</Grid>
<Grid item xs={12}>
<GroupsSelectors
selectedGroups={selectedGroups}
setSelectedGroups={(elements: string[]) => {
setSelectedGroups(elements);
}}
/>
</Grid>
</Grid>
{addLoading && (
<Grid item xs={12}>
<LinearProgress />
</Grid>
)}
</Grid>
<Grid item xs={12} className={classes.modalButtonBar}>
<Button
type="button"
variant="outlined"
color="primary"
onClick={resetForm}
>
Clear
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={addLoading || !sendEnabled}
>
Save
</Button>
</Grid>
</Grid>
</form>
</Box>
</Grid>
<Grid item xs={4}>
<Box>
<AddUserHelpBox />
</Box>
</Grid>
</Grid>
</PageLayout>
</Grid>
</Fragment>
);
};
const mapDispatchToProps = {
setErrorSnackMessage,
};
const connector = connect(null, mapDispatchToProps);
export default withStyles(styles)(connector(AddUser));

View File

@@ -145,7 +145,7 @@ const GroupsSelectors = ({
<div className={classes.searchBox}>
<SearchBox
placeholder="Filter Groups"
placeholder="Start typing to search for Groups"
adornmentPosition="end"
onChange={setFilter}
value={filter}

View File

@@ -54,7 +54,6 @@ import {
SecureComponent,
} from "../../../common/SecureComponent";
const AddUser = withSuspense(React.lazy(() => import("./AddUser")));
const SetPolicy = withSuspense(
React.lazy(() => import("../Policies/SetPolicy"))
);
@@ -83,8 +82,6 @@ interface IUsersProps {
const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
const [records, setRecords] = useState<User[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [addScreenOpen, setAddScreenOpen] = useState<boolean>(false);
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [addGroupOpen, setAddGroupOpen] = useState<boolean>(false);
@@ -108,12 +105,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
IAM_SCOPES.ADMIN_ADD_USER_TO_GROUP,
]);
const closeAddModalAndRefresh = () => {
setAddScreenOpen(false);
setLoading(true);
};
const closeDeleteModalAndRefresh = (refresh: boolean) => {
const closeDeleteModalAndRefresh = (refresh: boolean) => {
setDeleteOpen(false);
if (refresh) {
setLoading(true);
@@ -201,15 +193,6 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
return (
<Fragment>
{addScreenOpen && (
<AddUser
open={addScreenOpen}
selectedUser={selectedUser}
closeModalAndRefresh={() => {
closeAddModalAndRefresh();
}}
/>
)}
{policyOpen && (
<SetPolicy
open={policyOpen}
@@ -283,8 +266,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
icon={<AddIcon />}
color="primary"
onClick={() => {
setAddScreenOpen(true);
setSelectedUser(null);
history.push(`${IAM_PAGES.USER_ADD}`);
}}
variant={"contained"}
/>
@@ -389,8 +371,7 @@ const ListUsers = ({ classes, setErrorSnackMessage, history }: IUsersProps) => {
To get started,{" "}
<AButton
onClick={() => {
setAddScreenOpen(true);
setSelectedUser(null);
history.push(`${IAM_PAGES.USER_ADD}`);
}}
>
Create a User

View File

@@ -25,6 +25,7 @@ import NotFoundPage from "../../NotFoundPage";
import ListUsers from "./ListUsers";
import UserDetails from "./UserDetails";
import { IAM_PAGES } from "../../../common/SecureComponent/permissions";
import AddUserScreen from "./AddUserScreen";
const mapState = (state: AppState) => ({
open: state.system.sidebarOpen,
@@ -38,6 +39,7 @@ const Users = () => {
<Switch>
<Route path={IAM_PAGES.USERS_VIEW} component={UserDetails} />
<Route path={IAM_PAGES.USERS} component={ListUsers} />
<Route path={IAM_PAGES.USER_ADD} component={AddUserScreen} />
<Route component={NotFoundPage} />
</Switch>
</Router>