Groups Panel (#17)

* Added groups router component

* Created groups page

* Updated table rows

* Added group add modal

* Connected to add group API

* Connected add Group & fixed missing props

* Added user selector component

* Added connection to users list

* Fixed persistent group issue

* Group get data fix

* Added status selector & connected edit form

* Connected delete modal

Co-authored-by: Benjamin Perez <benjamin@bexsoft.net>
Co-authored-by: Daniel Valdivia <hola@danielvaldivia.com>
This commit is contained in:
Alex
2020-04-03 14:42:59 -06:00
committed by GitHub
parent 334b7ce671
commit fbd19f9fa9
8 changed files with 934 additions and 3 deletions

View File

@@ -52,6 +52,7 @@ import storage from "local-storage-fallback";
import NotFoundPage from "../NotFoundPage";
import ServiceAccounts from "./ServiceAccounts/ServiceAccounts";
import Users from "./Users/Users";
import Groups from "./Groups/Groups";
function Copyright() {
return (
@@ -222,6 +223,7 @@ class Console extends React.Component<
/>
<Route exact path="/users" component={Users} />
<Route exact path="/dashboard" component={Dashboard} />
<Route exct path="/groups" component={Groups} />
<Route exact path="/">
<Redirect to="/dashboard" />
</Route>

View File

@@ -0,0 +1,243 @@
// This file is part of MinIO Console Server
// Copyright (c) 2019 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, { useState, useEffect } from "react";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import {Button, Dialog, DialogContent, DialogTitle, LinearProgress, TextField} from "@material-ui/core";
import Radio from '@material-ui/core/Radio';
import RadioGroup from '@material-ui/core/RadioGroup';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import api from "../../../common/api";
import UsersSelectors from "./UsersSelectors";
import {GroupsList} from "./types";
import {groupsSort} from "../../../utils/sortFunctions";
import Title from "../../../common/Title";
interface IGroupProps {
open: boolean;
selectedGroup: any;
closeModalAndRefresh: any;
classes: any;
}
interface MainGroupProps {
members: string[];
name: string;
status: string;
}
const styles = (theme: Theme) =>
createStyles({
errorBlock: {
color: "red"
},
strongText: {
fontWeight: 700,
},
keyName: {
marginLeft: 5
}
});
const AddGroup = ({
open,
selectedGroup,
closeModalAndRefresh,
classes,
}: IGroupProps) => {
//Local States
const [groupName, setGroupName] = useState<string>("");
const [groupEnabled, setGroupEnabled] = useState<string>("");
const [saving, isSaving] = useState<boolean>(false);
const [addError, setError] = useState<string>("");
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [loadingGroup, isLoadingGroup] = useState<boolean>(false);
//Effects
useEffect(() => {
if(selectedGroup !== null) {
isLoadingGroup(true);
} else {
setGroupName("");
setSelectedUsers([]);
}
}, [selectedGroup]);
useEffect(() => {
if(saving) {
saveRecord();
}
}, [saving]);
useEffect(() => {
if(selectedGroup && loadingGroup) {
fetchGroupInfo();
}
}, [loadingGroup]);
//Fetch Actions
const setSaving = (event: React.FormEvent) => {
event.preventDefault();
isSaving(true);
}
const saveRecord = () => {
if (selectedGroup !== null) {
api
.invoke("PUT", `/api/v1/groups/${groupName}`, {
group: groupName,
members: selectedUsers,
status: groupEnabled,
})
.then(res => {
isSaving(false);
setError("");
closeModalAndRefresh();
})
.catch(err => {
isSaving(false);
setError(err);
});
} else {
api.invoke("POST", "/api/v1/groups", {
group: groupName,
members: selectedUsers,
})
.then(res => {
isSaving(false);
setError("");
closeModalAndRefresh();
})
.catch(err => {
isSaving(false);
setError(err);
});
}
};
const fetchGroupInfo = () => {
api
.invoke("GET", `/api/v1/groups/${selectedGroup}`)
.then((res: MainGroupProps) => {
setGroupEnabled(res.status);
setGroupName(res.name);
setSelectedUsers(res.members);
})
.catch(err => {
setError(err);
isLoadingGroup(false);
});
};
return (<Dialog
open={open}
onClose={closeModalAndRefresh}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{selectedGroup !== null ? `Group Edit - ${groupName}` : 'Add Group'}
</DialogTitle>
<DialogContent>
<form
noValidate
autoComplete="off"
onSubmit={setSaving}
>
<Grid container>
{addError !== "" && (
<Grid item xs={12}>
<Typography
component="p"
variant="body1"
className={classes.errorBlock}
>
{addError}
</Typography>
</Grid>
)}
{selectedGroup !== null ? (
<React.Fragment>
<Grid item xs={12}>
<Title>Status</Title>
<RadioGroup
aria-label="status"
name="status"
value={groupEnabled}
onChange={(e) => {
setGroupEnabled(e.target.value);
}}
>
<FormControlLabel value="enabled" control={<Radio color={'primary'} />} label="Enabled" />
<FormControlLabel value="disabled" control={<Radio color={'primary'} />} label="Disabled" />
</RadioGroup>
</Grid>
</React.Fragment>
) : (
<React.Fragment>
<Grid item xs={12}>
<TextField
id="standard-basic"
fullWidth
label="Name"
value={groupName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setGroupName(e.target.value);
}}
/>
</Grid>
</React.Fragment>
)}
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12}>
<UsersSelectors
selectedUsers={selectedUsers}
setSelectedUsers={setSelectedUsers}
/>
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12}>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
disabled={saving}
>
Save
</Button>
</Grid>
{saving && (
<Grid item xs={12}>
<LinearProgress />
</Grid>
)}
</Grid>
</form>
</DialogContent>
</Dialog>);
};
export default withStyles(styles)(AddGroup);

View File

@@ -0,0 +1,134 @@
// This file is part of MinIO Console Server
// Copyright (c) 2019 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, { useState, useEffect } from "react";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
LinearProgress
} from "@material-ui/core";
import api from "../../../common/api";
import Typography from "@material-ui/core/Typography";
import {UsersList} from "../Users/types";
interface IDeleteGroup {
selectedGroup: string;
deleteOpen: boolean;
closeDeleteModalAndRefresh: any;
classes: any;
}
const styles = (theme: Theme) =>
createStyles({
errorBlock: {
color: "red"
}
});
const DeleteGroup = ({
selectedGroup,
deleteOpen,
closeDeleteModalAndRefresh,
classes,
}:IDeleteGroup) => {
const [isDeleting, setDeleteLoading] = useState<boolean>(false);
const [deleteError, setError] = useState<string>("");
useEffect(() => {
if(isDeleting) {
removeRecord();
}
}, [isDeleting]);
const removeRecord = () => {
if (!selectedGroup) {
return;
}
api
.invoke("DELETE", `/api/v1/groups/${selectedGroup}`)
.then((res: UsersList) => {
setDeleteLoading(false);
setError("");
closeDeleteModalAndRefresh(true);
})
.catch(err => {
setDeleteLoading(false);
setError(err);
});
};
const closeNoAction = () => {
setError("");
closeDeleteModalAndRefresh(false);
};
return (<React.Fragment>
<Dialog
open={deleteOpen}
onClose={closeNoAction}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Delete User</DialogTitle>
<DialogContent>
{isDeleting && <LinearProgress />}
<DialogContentText id="alert-dialog-description">
Are you sure you want to delete group <b>{selectedGroup}</b>
?
{deleteError !== "" && (
<React.Fragment>
<br />
<Typography
component="p"
variant="body1"
className={classes.errorBlock}
>
{deleteError}
</Typography>
</React.Fragment>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={closeNoAction}
color="primary"
disabled={isDeleting}
>
Cancel
</Button>
<Button
onClick={() => {
setDeleteLoading(true);
}}
color="secondary"
autoFocus
>
Delete
</Button>
</DialogActions>
</Dialog>
</React.Fragment>);
};
export default withStyles(styles)(DeleteGroup)

View File

@@ -0,0 +1,295 @@
// This file is part of MinIO Console Server
// Copyright (c) 2019 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, { useState, useEffect } from "react";
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import TextField from "@material-ui/core/TextField";
import InputAdornment from "@material-ui/core/InputAdornment";
import SearchIcon from "@material-ui/icons/Search";
import {Button, IconButton, LinearProgress, TableFooter, TablePagination} from "@material-ui/core";
import Paper from "@material-ui/core/Paper";
import Table from "@material-ui/core/Table";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableCell from "@material-ui/core/TableCell";
import TableBody from "@material-ui/core/TableBody";
import Checkbox from "@material-ui/core/Checkbox";
import ViewIcon from "@material-ui/icons/Visibility";
import DeleteIcon from "@material-ui/icons/Delete";
import {CreateIcon} from "../../../icons";
import api from "../../../common/api";
import {MinTablePaginationActions} from "../../../common/MinTablePaginationActions";
import {GroupsList} from "./types";
import {groupsSort, usersSort} from "../../../utils/sortFunctions";
import {UsersList} from "../Users/types";
import AddGroup from "../Groups/AddGroup";
import DeleteGroup from "./DeleteGroup";
interface IGroupsProps {
classes: any;
openGroupModal: any;
}
const styles = (theme: Theme) =>
createStyles({
seeMore: {
marginTop: theme.spacing(3)
},
paper: {
// padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column"
},
addSideBar: {
width: "320px",
padding: "20px"
},
errorBlock: {
color: "red"
},
tableToolbar: {
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(0)
},
wrapCell: {
maxWidth: "200px",
whiteSpace: "normal",
wordWrap: "break-word"
},
minTableHeader: {
color: "#393939",
"& tr": {
"& th": {
fontWeight:'bold'
}
}
},
actionsTray: {
textAlign: "right",
"& button": {
marginLeft: 10,
}
},
searchField: {
background: "#FFFFFF",
padding: 12,
borderRadius: 5,
boxShadow: "0px 3px 6px #00000012",
}
});
const Groups = ({
classes,
}: IGroupsProps) => {
const [addGroupOpen, setGroupOpen] = useState<boolean>(false);
const [selectedGroup, setSelectedGroup] = useState<any>(null);
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [loading, isLoading] = useState<boolean>(false);
const [records, setRecords] = useState<any[]>([]);
const [totalRecords, setTotalRecords] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const [page, setPage] = useState<number>(0);
const [error, setError] = useState<string>("");
const handleChangePage = (event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>
) => {
const rPP = parseInt(event.target.value, 10);
setPage(0);
setRowsPerPage(rPP);
};
useEffect(() => {
isLoading(true);
}, []);
useEffect(() => {
isLoading(true);
}, [page, rowsPerPage]);
useEffect(() => {
if(loading) {
fetchRecords();
}
}, [loading]);
const fetchRecords = () => {
const offset = page * rowsPerPage;
api
.invoke("GET", `/api/v1/groups?offset=${offset}&limit=${rowsPerPage}`)
.then((res: GroupsList) => {
setRecords(res.groups.sort(groupsSort));
setTotalRecords(res.total);
setError("");
isLoading(false);
// if we get 0 results, and page > 0 , go down 1 page
if ((!res.groups || res.groups.length === 0) && page > 0) {
const newPage = page - 1;
setPage(newPage);
}
})
.catch(err => {
setError(err);
isLoading(false);
});
};
const closeAddModalAndRefresh = () => {
setGroupOpen(false);
isLoading(true);
};
const closeDeleteModalAndRefresh = (refresh: boolean) => {
setDeleteOpen(false);
if (refresh) {
isLoading(true);
}
};
return (<React.Fragment>
{ addGroupOpen &&
<AddGroup
open={addGroupOpen}
selectedGroup={selectedGroup}
closeModalAndRefresh={closeAddModalAndRefresh}
/>
}
{ deleteOpen &&
<DeleteGroup
deleteOpen={deleteOpen}
selectedGroup={selectedGroup}
closeDeleteModalAndRefresh={closeDeleteModalAndRefresh}
/>
}
<Grid container>
<Grid item xs={12}>
<Typography variant="h6">Groups</Typography>
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12} className={classes.actionsTray}>
<TextField
placeholder="Search Groups"
className={classes.searchField}
id="search-resource"
label=""
InputProps={{
disableUnderline: true,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<Button
variant="contained"
color="primary"
startIcon={ <CreateIcon /> }
onClick={() => {
setSelectedGroup(null);
setGroupOpen(true);
}}
>
Create Group
</Button>
</Grid>
<Grid item xs={12}>
<br />
</Grid>
<Grid item xs={12}>
<Paper className={classes.paper}>
{loading && <LinearProgress />}
{records != null && records.length > 0 ? (
<Table size="medium">
<TableHead className={classes.minTableHeader}>
<TableRow>
<TableCell>Name</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map(group => (
<TableRow key={`user-${group}`}>
<TableCell className={classes.wrapCell}>
{group}
</TableCell>
<TableCell align="right">
<IconButton
aria-label="view"
onClick={() => {
setGroupOpen(true);
setSelectedGroup(group);
}}
>
<ViewIcon />
</IconButton>
<IconButton
aria-label="delete"
onClick={() => {
setDeleteOpen(true);
setSelectedGroup(group);
}}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={[5, 10, 25]}
colSpan={3}
count={totalRecords}
rowsPerPage={rowsPerPage}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={MinTablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
) : (
<div>No Groups Available</div>
)}
</Paper>
</Grid>
</Grid>
</React.Fragment>)
};
export default withStyles(styles)(Groups);

View File

@@ -0,0 +1,193 @@
// This file is part of MinIO Kubernetes Cloud
// Copyright (c) 2020 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, { useState, useEffect } from 'react';
import { createStyles, Theme, withStyles } from "@material-ui/core/styles";
import { LinearProgress } from "@material-ui/core";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Grid from "@material-ui/core/Grid";
import Title from "../../../common/Title";
import Checkbox from "@material-ui/core/Checkbox";
import { UsersList } from "../Users/types";
import { usersSort } from "../../../utils/sortFunctions";
import api from "../../../common/api";
interface IGroupsProps {
classes: any;
selectedUsers: string[];
setSelectedUsers: any;
}
const styles = (theme: Theme) =>
createStyles({
seeMore: {
marginTop: theme.spacing(3)
},
paper: {
// padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column"
},
addSideBar: {
width: "320px",
padding: "20px"
},
errorBlock: {
color: "red"
},
tableToolbar: {
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(0)
},
wrapCell: {
maxWidth: "200px",
whiteSpace: "normal",
wordWrap: "break-word"
},
minTableHeader: {
color: "#393939",
"& tr": {
"& th": {
fontWeight:'bold'
}
}
},
actionsTray: {
textAlign: "left",
"& button": {
marginLeft: 10,
}
},
filterField: {
background: "#FFFFFF",
padding: 12,
borderRadius: 5,
boxShadow: "0px 3px 6px #00000012",
width: '100%'
},
noFound: {
textAlign: "center",
padding: "10px 0",
}
});
const UsersSelectors = ({
classes,
selectedUsers,
setSelectedUsers,
}: IGroupsProps) => {
//Local States
const [records, setRecords] = useState<any[]>([]);
const [loading, isLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
//Effects
useEffect(() => {
isLoading(true);
}, []);
useEffect(() => {
if(loading) {
fetchUsers();
}
},[loading]);
//Fetch Actions
const selectionChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const targetD = e.target;
const value = targetD.value;
const checked = targetD.checked;
let elements : string[] = [...selectedUsers]; // We clone the selectedGroups array
if(checked) { // If the user has checked this field we need to push this to selectedGroupsList
elements.push(value);
} else { // User has unchecked this field, we need to remove it from the list
elements = elements.filter(element => element !== value);
}
setSelectedUsers(elements);
return elements;
};
const fetchUsers = () => {
api
.invoke("GET", `/api/v1/users`)
.then((res: UsersList) => {
setRecords(res.users.sort(usersSort));
setError("");
isLoading(false);
})
.catch(err => {
setError(err);
isLoading(false);
});
};
return (
<React.Fragment>
<Title>Members</Title>
<Grid item xs={12}>
<Paper className={classes.paper}>
{loading && <LinearProgress />}
{records != null && records.length > 0 ? (
<React.Fragment>
<Table size="medium">
<TableHead className={classes.minTableHeader}>
<TableRow>
<TableCell>Select</TableCell>
<TableCell>Access Key</TableCell>
</TableRow>
</TableHead>
<TableBody>
{records.map(row => (
<TableRow key={`group-${row.accessKey}`}>
<TableCell padding="checkbox">
<Checkbox
value={row.accessKey}
color="primary"
inputProps={{
'aria-label': 'secondary checkbox'
}}
onChange={ selectionChanged }
checked={selectedUsers.includes(row.accessKey)}
/>
</TableCell>
<TableCell className={classes.wrapCell}>
{row.accessKey}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</React.Fragment>
) : (
<div className={classes.noFound}>No Users Available</div>
)}
</Paper>
</Grid>
</React.Fragment>
);
};
export default withStyles(styles)(UsersSelectors);

View File

@@ -0,0 +1,30 @@
// This file is part of MinIO Console Server
// Copyright (c) 2020 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/>.
export interface Group {
name: string;
id: string;
email: string;
is_me: boolean;
enabled: boolean;
accessKey: string;
secretKey: string;
}
export interface GroupsList {
groups: string[];
total:number;
}

View File

@@ -120,7 +120,7 @@ class Menu extends React.Component<MenuProps> {
<ListItemIcon>
<PermissionIcon />
</ListItemIcon>
<ListItemText primary="policies" />
<ListItemText primary="Policies" />
</ListItem>
<ListItem button component={NavLink} to="/users">
<ListItemIcon>
@@ -128,6 +128,12 @@ class Menu extends React.Component<MenuProps> {
</ListItemIcon>
<ListItemText primary="Users" />
</ListItem>
<ListItem button component={NavLink} to="/groups">
<ListItemIcon>
<UsersIcon />
</ListItemIcon>
<ListItemText primary="Groups" />
</ListItem>
<Divider />
<ListItem
button

View File

@@ -1,8 +1,24 @@
interface sortInterface {
// This file is part of MinIO Console Server
// Copyright (c) 2020 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/>.
interface userInterface {
accessKey: string;
}
export const usersSort = (a: sortInterface, b: sortInterface) => {
export const usersSort = (a: userInterface, b: userInterface) => {
if (a.accessKey > b.accessKey) {
return 1;
}
@@ -13,3 +29,15 @@ export const usersSort = (a: sortInterface, b: sortInterface) => {
return 0;
};
export const groupsSort = (a: string, b: string) => {
if (a > b) {
return 1;
}
if (a < b) {
return -1;
}
// a must be equal to b
return 0;
};