frontend/src/user/ObjectUsers.tsx

724 lines
23 KiB
TypeScript

import {
Avatar,
Box,
Button,
Checkbox,
CircularProgress,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
FormControlLabel,
FormGroup,
FormLabel,
Grid2,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
PaletteColor,
Stack,
Theme,
Tooltip,
Typography,
useTheme
} from "@mui/material";
import RemoveUserIcon from "@mui/icons-material/RemoveCircle";
import EditIcon from "@mui/icons-material/EditOutlined";
import ShareIcon from "@mui/icons-material/Share";
import ResponsiveDialog from "common/ResponsiveDialog";
import { usePermissionAPI, usePrevious, useSnackbar, useUserAPI } from "hooks";
import { cloneDeep, isEqual } from "lodash";
import { Scope, User } from "models";
import { sortUsersByRole, userRoleFromPermissions } from "pbHelpers/User";
import { pond } from "protobuf-ts/pond";
import React, { useCallback, useEffect, useState } from "react";
import { or } from "utils/types";
import RemoveSelfFromObject from "user/RemoveSelfFromObject";
import ShareObject from "user/ShareObject";
import { useGlobalState } from "providers";
import { red, blue } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
const useStyles = makeStyles((theme: Theme) => {
const avatarBG = theme.palette.secondary["700" as keyof PaletteColor];
return {
dialogContent: {
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
[theme.breakpoints.up("sm")]: {
padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`
}
},
userAvatar: {
color: "#fff",
backgroundColor: avatarBG
},
userPermissions: {
paddingLeft: theme.spacing(4),
paddingBottom: theme.spacing(1)
},
textOverflowEllipsis: {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
removeIcon: {
color: red["500"],
"&:hover": {
color: red["600"]
}
},
verticalSpacing: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(3)
},
lessSpacing: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1)
},
iconButton: {
margin: theme.spacing(1)
},
shareIcon: {
color: blue["600"],
"&:hover": {
color: blue["700"]
}
},
editIcon: {
color: blue["300"],
"&:hover": {
color: blue["400"]
}
}
};
});
interface Props {
scope: Scope;
label: string;
permissions: pond.Permission[];
isDialogOpen: boolean;
closeDialogCallback: Function;
refreshCallback: Function;
userCallback?: (users?: User[] | undefined) => void;
dialog?: string;
cardMode?: boolean;
useImitation?: boolean;
shareLabel?: string; // s string to replace the share icon button with a labelled button
}
export default function ObjectUsers(props: Props) {
const classes = useStyles();
const theme = useTheme();
const permissionAPI = usePermissionAPI();
const [{ user, team }, dispatch] = useGlobalState();
const isAdmin = user.hasAdmin();
const userAPI = useUserAPI();
const { error, success, warning } = useSnackbar();
const {
scope,
label,
permissions,
isDialogOpen,
closeDialogCallback,
refreshCallback,
userCallback,
dialog,
cardMode,
//useImitation
shareLabel
} = props;
const prevPermissions = usePrevious(permissions);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const [initialUsers, setInitialUsers] = useState<User[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [canManageUsers, setCanManageUsers] = useState<boolean>(
permissions.includes(pond.Permission.PERMISSION_USERS)
);
const [canShare, setCanShare] = useState<boolean>(
permissions.includes(pond.Permission.PERMISSION_SHARE)
);
const [canShareBilling, setCanShareBilling] = useState<boolean>(
permissions.includes(pond.Permission.PERMISSION_SHARE) &&
permissions.includes(pond.Permission.PERMISSION_USERS) &&
permissions.includes(pond.Permission.PERMISSION_WRITE)
);
const [removedUsers, setRemovedUsers] = useState<string[]>([]);
const [removeSelfDialogIsOpen, setRemoveSelfDialogIsOpen] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isShareObjectDialogOpen, setIsShareObjectDialogOpen] = useState<boolean>(false);
const [numOwners, setNumOwners] = useState(0);
const [useImitation] = useState<boolean>(
props.useImitation !== undefined ? props.useImitation : true
);
// const [editUserDialog, setEditUserDialog] = useState(false);
const [userForDialog, setUserForDialog] = useState<User | undefined>(undefined);
const setDefaultState = () => {
setInitialUsers([]);
setUsers([]);
setRemovedUsers([]);
};
const load = useCallback(() => {
setIsLoading(true);
userAPI
.listObjectUsers(scope)
.then((response: any) => {
let rUsers: User[] = [];
or(response.data, { users: [] }).users.forEach((user: any) => {
rUsers.push(User.any(user));
});
rUsers = rUsers.filter(u => u.permissions.length > 0);
setInitialUsers(cloneDeep(rUsers));
let numOwners = 0;
rUsers.forEach(user => {
if (userRoleFromPermissions(user.permissions) === "Owner") numOwners++;
});
setNumOwners(numOwners);
setUsers(rUsers);
})
.catch((err: any) => {
console.log(err);
setInitialUsers([]);
setUsers([]);
})
.finally(() => setIsLoading(false));
}, [scope, userAPI]);
useEffect(() => {
if (!prevIsDialogOpen && isDialogOpen) {
load();
}
if (prevPermissions !== permissions) {
setCanManageUsers(permissions.includes(pond.Permission.PERMISSION_USERS));
setCanShare(permissions.includes(pond.Permission.PERMISSION_SHARE));
setCanShareBilling(
permissions.includes(pond.Permission.PERMISSION_SHARE) &&
permissions.includes(pond.Permission.PERMISSION_USERS) &&
permissions.includes(pond.Permission.PERMISSION_WRITE)
);
}
}, [isDialogOpen, load, permissions, prevIsDialogOpen, prevPermissions]);
const closeRemoveSelfDialog = () => {
setRemoveSelfDialogIsOpen(false);
};
const close = () => {
closeRemoveSelfDialog();
closeDialogCallback();
setDefaultState();
};
const submit = () => {
console.log(users);
permissionAPI
.updatePermissions(scope, users)
.then((_response: any) => {
success("Users were sucessfully updated for " + label);
close();
refreshCallback();
userCallback && userCallback(users);
users.forEach(u => {
if (u.id() === user.id()) {
if (scope.kind === "team" && scope.key === team.key()) {
dispatch({ key: "userTeamPermissions", value: u.permissions });
}
}
});
if (cardMode) {
load();
}
})
.catch((err: any) => {
err.response.data.error
? warning(err.response.data.error)
: error("Error occured when updating users for " + label);
close();
})
.finally(() => setUserForDialog(undefined));
};
const changeUserPermissions = (user: pond.IUser) => (event: any) => {
let updatedUsers = cloneDeep(users);
let permissionMapping = new Map<string, pond.Permission>([
["2", pond.Permission.PERMISSION_READ],
["3", pond.Permission.PERMISSION_WRITE],
["4", pond.Permission.PERMISSION_SHARE],
["1", pond.Permission.PERMISSION_USERS],
["5", pond.Permission.PERMISSION_BILLING],
["6", pond.Permission.PERMISSION_FILE_MANAGEMENT]
]);
let permission = or(
permissionMapping.get(event.target.value),
pond.Permission.PERMISSION_INVALID
);
for (let i = 0; i < updatedUsers.length; i++) {
let currUser = updatedUsers[i];
if (
user &&
user.settings &&
currUser &&
currUser.settings &&
currUser.settings.id === user.settings.id
) {
let permissions = user.permissions ? cloneDeep(user.permissions) : [];
if (permissions.includes(permission)) {
permissions = permissions.filter(
(curPermission: pond.Permission) => curPermission !== permission
);
} else {
permissions.push(permission);
}
updatedUsers[i].permissions = permissions;
user.permissions = permissions;
break;
}
}
setUsers(updatedUsers);
};
const removeUser = (user: string) => {
let updatedUsers = cloneDeep(users);
let updatedRemovedUsers = cloneDeep(removedUsers);
for (let i = 0; i < updatedUsers.length; i++) {
let currUser = updatedUsers[i];
if (currUser && currUser.settings && currUser.settings.id === user) {
updatedRemovedUsers.push(currUser.settings.id);
updatedUsers[i].permissions = [];
break;
}
}
setUsers(updatedUsers);
setRemovedUsers(updatedRemovedUsers);
};
const checkIsSelf = (checkUser: pond.IUser): boolean => {
const id = checkUser && checkUser.settings ? checkUser.settings.id : "";
return user.id() === id;
};
const userIsRemoved = (user: pond.IUser): boolean => {
const id = user && user.settings ? user.settings.id : "";
return removedUsers.includes(or(id, ""));
};
const objectUsersUnchanged = (): boolean => {
return isEqual(initialUsers, users);
};
const openRemoveSelfDialog = () => {
setRemoveSelfDialogIsOpen(true);
};
const openShareObjectDialog = () => {
setIsShareObjectDialogOpen(true);
};
const closeShareObjectDialog = (shared: boolean | undefined) => {
setIsShareObjectDialogOpen(false);
if (shared) {
load();
}
};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ UI BEGINS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
const title = () => {
return (
// <Grid container direction="column" justify="space-between">
// <Grid container direction="row" justify="space-between">
// <Grid item xs={8}>
// Users
// <Typography variant="body2" color="textSecondary">
// {label}
// </Typography>
// </Grid>
// <Grid item xs={4} container justify="flex-end">
// {canShare && (
// <Tooltip title={"Share " + label}>
// <IconButton
// aria-label="Share"
// className={classes.iconButton}
// onClick={openShareObjectDialog}>
// <ShareIcon className={classes.shareIcon} />
// </IconButton>
// </Tooltip>
// )}
// </Grid>
// </Grid>
// {dialog && (
// <Typography variant="body2" color="textSecondary" style={{ marginTop: "4px" }}>
// {dialog}
// </Typography>
// )}
// </Grid>
<Stack direction="column" spacing={2}>
<Stack direction="row" spacing={2} justifyContent="space-between">
<Box>
Users
<Typography variant="body2" color="textSecondary">
{label}
</Typography>
</Box>
<Box display="flex" justifyContent="flex-end">
{canShare && (
<Tooltip title={`Share ${label}`}>
{shareLabel ?
<Button variant="contained" color="primary" aria-label="Share" onClick={openShareObjectDialog}>
{shareLabel}
</Button>
:
<IconButton
aria-label="Share"
onClick={openShareObjectDialog}>
<ShareIcon />
</IconButton>
}
</Tooltip>
)}
</Box>
</Stack>
{dialog && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{dialog}
</Typography>
)}
</Stack>
);
};
const userPermissionManagement = (user: User) => {
let permissions = user.permissions;
if (
!canManageUsers ||
(checkIsSelf(user) && !canShareBilling) ||
userIsRemoved(user) ||
(permissions.includes(pond.Permission.PERMISSION_USERS) &&
permissions.includes(pond.Permission.PERMISSION_READ) &&
permissions.includes(pond.Permission.PERMISSION_WRITE) &&
permissions.includes(pond.Permission.PERMISSION_SHARE) &&
permissions.includes(pond.Permission.PERMISSION_BILLING) &&
permissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT) &&
!cardMode)
) {
return null;
}
const canRead = permissions.includes(pond.Permission.PERMISSION_READ);
const xs = cardMode ? 12 : 4;
const sm = cardMode ? 4 : 3;
const justify = cardMode ? "flex-start" : "center";
return (
<FormControl component="fieldset" fullWidth className={classes.userPermissions}>
<FormLabel component="legend"></FormLabel>
<FormGroup>
<Grid2 container direction="row" justifyContent={justify} spacing={2}>
<Grid2 sx={{ gridColumn: `span ${xs} / span ${sm}` }}>
<FormControlLabel
control={
<Checkbox
checked={permissions.includes(pond.Permission.PERMISSION_READ)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_READ}
/>
}
label="View"
labelPlacement="end"
/>
</Grid2>
<Grid2 sx={{ gridColumn: `span ${xs} / span ${sm}` }}>
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_WRITE)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_WRITE}
/>
}
label="Edit"
labelPlacement="end"
/>
</Grid2>
<Grid2 sx={{ gridColumn: `span ${xs} / span ${sm}` }}>
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_SHARE)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_SHARE}
/>
}
label="Share"
labelPlacement="end"
/>
</Grid2>
<Grid2 sx={{ gridColumn: `span ${xs} / span ${sm}` }}>
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_USERS)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_USERS}
/>
}
label="Manage Users"
labelPlacement="end"
/>
</Grid2>
<Grid2 sx={{ gridColumn: 'span 6 / span 3' }}>
<FormControlLabel
control={
<Checkbox
checked={permissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_FILE_MANAGEMENT}
/>
}
label="Files"
labelPlacement="end"
/>
</Grid2>
<Grid2 sx={{ gridColumn: `span ${xs} / span ${sm}` }}>
<FormControlLabel
control={
<Checkbox
disabled={!canShareBilling}
checked={permissions.includes(pond.Permission.PERMISSION_BILLING)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_BILLING}
/>
}
label="Billing"
labelPlacement="end"
/>
</Grid2>
</Grid2>
</FormGroup>
</FormControl>
);
};
const userListItem = (user: User) => {
let isSelf = checkIsSelf(user);
let isRemoved = userIsRemoved(user);
let name = user.name();
let permissions = user.permissions;
let userSettings = pond.UserSettings.create(user.settings !== null ? user.settings : undefined);
return (
<ListItem alignItems="flex-start" >
<ListItemAvatar>
<Avatar
alt={name}
src={
userSettings.avatar && userSettings.avatar !== "" ? userSettings.avatar : undefined
}
className={classes.userAvatar}>
{!(userSettings.avatar && userSettings.avatar !== "") && name}
</Avatar>
</ListItemAvatar>
<ListItemText
className={classes.textOverflowEllipsis}
disableTypography
primary={
<Typography component="div" variant="body1" color="textPrimary">
{name + (isSelf ? " (you)" : "")}
</Typography>
}
secondary={
<React.Fragment>
<Typography component="div" variant="caption" color="textSecondary">
{user.settings.email}
</Typography>
<Typography component="div" variant="caption" color="textSecondary">
{isRemoved ? "REMOVED " : userRoleFromPermissions(permissions)}
</Typography>
</React.Fragment>
}
/>
{isSelf && !canShareBilling ? (
<ListItemSecondaryAction>
<Tooltip title={"Remove yourself from " + label}>
<IconButton
edge="end"
aria-label={"Remove yourself from " + label}
onClick={openRemoveSelfDialog}>
<RemoveUserIcon className={classes.removeIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
) : (canManageUsers || isAdmin) && !isRemoved && !cardMode ? (
<ListItemSecondaryAction>
<Tooltip title="Revoke user's access">
<IconButton
edge="end"
aria-label="Remove user from device"
onClick={() => removeUser(or(user.settings, { id: "" }).id)}>
<RemoveUserIcon className={classes.removeIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
) : (
(canManageUsers || isAdmin) && (
<ListItemSecondaryAction>
<Tooltip title="Edit user's permissions">
<IconButton
edge="end"
aria-label="Edit user permissions"
disabled={!canManageUsers}
onClick={() => setUserForDialog(user)}>
<EditIcon className={classes.editIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
)
)}
</ListItem>
);
};
const objectUsersList = () => {
const sortedUsers = sortUsersByRole(users);
let userListItems: any = [];
for (var i = 0; i < sortedUsers.length; i++) {
let user = sortedUsers[i];
if (user) {
userListItems.push(
<React.Fragment key={i}>
{userListItem(user)}
{!cardMode && userPermissionManagement(user)}
<Divider />
</React.Fragment>
);
}
}
return <List>{userListItems}</List>;
};
const content = () => {
if (isLoading) {
return (
<Grid2 container justifyContent="center" alignContent="center" className={classes.verticalSpacing}>
<CircularProgress />
</Grid2>
);
}
if (users.length < 1) {
return (
<React.Fragment>
<Typography
variant="h6"
align="center"
color="textSecondary"
className={!cardMode ? classes.verticalSpacing : ""}
style={{ padding: cardMode ? 0 : "" }}>
No users found.
</Typography>
</React.Fragment>
);
}
return objectUsersList();
};
const actions = () => {
return (
<Grid2 container direction="row" justifyContent="space-between">
<Grid2 sx={{ gridColumn: `span ${4} ` }} ></Grid2>
<Grid2 sx={{ gridColumn: `span ${8} ` }} container justifyContent="flex-end">
{!cardMode && (
<Button onClick={close} color="primary">
Cancel
</Button>
)}
{canManageUsers && !cardMode && (
<Button onClick={submit} color="primary" disabled={objectUsersUnchanged()}>
{cardMode ? "Update" : "Submit"}
</Button>
)}
</Grid2>
</Grid2>
);
};
const dialogs = () => {
return (
<React.Fragment>
<RemoveSelfFromObject
scope={scope}
label={label}
isDialogOpen={removeSelfDialogIsOpen}
closeDialogCallback={closeRemoveSelfDialog}
numberOfOwners={numOwners}
/>
<ShareObject
scope={scope}
label={label}
permissions={permissions}
isDialogOpen={isShareObjectDialogOpen}
closeDialogCallback={closeShareObjectDialog}
useImitation={useImitation}
sharedUsers={users}
/>
</React.Fragment>
);
};
if (cardMode) {
return (
<React.Fragment>
<Typography variant="h6">{title()}</Typography>
<div style={{ overflowY: "scroll" }}>
<Divider />
{content()}
{actions()}
{dialogs()}
<div style={{ marginTop: theme.spacing(2) }} />
</div>
<ResponsiveDialog
open={userForDialog !== undefined}
onClose={() => setUserForDialog(undefined)}>
<DialogTitle>Change {userForDialog?.name()}'s Permissions?</DialogTitle>
<DialogContent>{userForDialog && userPermissionManagement(userForDialog)}</DialogContent>
<DialogActions>
<Button onClick={() => setUserForDialog(undefined)}>Close</Button>
<Button color="primary" onClick={submit}>
Submit
</Button>
</DialogActions>
</ResponsiveDialog>
</React.Fragment>
);
}
return (
<ResponsiveDialog
fullWidth
maxWidth="xs"
open={props.isDialogOpen}
onClose={close}
aria-labelledby="object-users-dialog">
<DialogTitle id="object-users-title">{title()}</DialogTitle>
<Divider />
<DialogContent className={classes.dialogContent}>{content()}</DialogContent>
<DialogActions>{actions()}</DialogActions>
{dialogs()}
</ResponsiveDialog>
);
}