frontend/src/pages/Users.tsx
2025-10-21 12:19:39 -06:00

442 lines
13 KiB
TypeScript

import {
Avatar,
Box,
Button,
Chip,
darken,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Grid2,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
PaletteColor,
Theme,
} from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import SearchSelect, { Option } from "common/SearchSelect";
import { useMobile, useSnackbar, useUserAPI } from "hooks";
import { User } from "models";
import PageContainer from "pages/PageContainer";
// import { ListUsersResponse } from "providers/pond/userAPI";
import { useEffect, useState } from "react";
import { makeStyles } from "@mui/styles";
import { getSecondaryColour, getSignatureAccentColour } from "services/whiteLabel";
import { Add, Face, Remove } from "@mui/icons-material";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
const useStyles = makeStyles((theme: Theme) => {
// const isMobile = useMobile()
return ({
gutter: {
backgroundColor: darken(theme.palette.background.paper, 0.03),
border: "none"
},
dataTable: {
backgroundColor: theme.palette.background.paper
},
tableContainer: {
margin: theme.spacing(1),
[theme.breakpoints.up("sm")]: {
margin: theme.spacing(2)
},
width: "auto"
},
title: {
padding: theme.spacing(1)
},
titleContainer: {
border: "none"
},
chipContainer: {
maxWidth: "100%",
maxHeight: "100%",
justifyContent: "flex-start",
alignItems: "flex-start"
},
deleteIcon: {
color: "var(--status-alert)"
},
userAvatar: {
color: "#fff",
backgroundColor: getSecondaryColour()["700" as keyof PaletteColor],
[theme.breakpoints.down("xs")]: {
width: "32px",
height: "32px"
}
},
listItemButton: {
'&:hover': {
color: getSignatureAccentColour()
}
},
avatarContainer: {
textAlign: "center",
justifyContent: "center",
},
addIcon: {
color: "var(--status-ok)"
}})},
);
export default function Users() {
const classes = useStyles();
const isMobile = useMobile();
const userAPI = useUserAPI();
// let tableRef: any = React.createRef();
const { error, success } = useSnackbar();
const [pageSize, setPageSize] = useState(5);
const [isLoading, setIsLoading] = useState(false);
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
const [showAllowAction, setShowAllowAction] = useState(false);
const [selectedAction, setSelectedAction] = useState<Option | undefined>();
const [showEnableFeature, setShowEnableFeature] = useState(false);
const [selectedFeature, setSelectedFeature] = useState<Option | undefined>();
const [selectedUser, setSelectedUser] = useState(User.create());
const [searchText, setSearchText] = useState("");
const actions = [
"provision",
"upload-firmware",
"remove-devices",
"copy-token",
"recluse",
"pause-data",
"access-object"
].sort();
const features = [
"admin",
"beta",
"billing",
"security",
"dev-channel",
"docs",
"sleep",
"support",
"tasks",
"teams",
"maps",
"json",
"john-deere",
"grain-composition",
"developer",
"marketplace",
"installer",
"cnhi",
"libra-cart"
].sort();
const [rows, setRows] = useState<User[]>([])
const loadUsers = () => {
setIsLoading(true)
userAPI.listUsers(pageSize, page*pageSize, "desc", "name", undefined, searchText).then(resp => {
let r: User[] = []
resp.users.forEach((user, _index) => {
r.push(user)
})
setRows(r)
setTotal(resp.total)
}).then(() => {
setIsLoading(false);
})
}
useEffect(() => {
loadUsers();
}, [page, pageSize, searchText]); // Trigger fetching data when page or pageSize changes
const handleRowsPerPageChange = (event: any) => {
const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value
setPageSize(newRowsPerPage);
setPage(0); // Reset to the first page
};
const allow = () => {
const action = selectedAction ? selectedAction.label : "";
if (selectedUser.id() === "" || action === "") return;
if (!selectedUser.settings.actions.includes(action)) {
selectedUser.settings.actions.push(action);
}
userAPI
.updateUser(selectedUser.id(), selectedUser.protobuf())
.then(() => {
success("Allowed " + selectedUser.name() + " to " + action);
setShowAllowAction(false);
setSelectedAction({} as Option);
})
.catch(() => error("Failed to allow " + selectedUser.name() + " to " + action));
};
const enable = () => {
const feature = selectedFeature ? selectedFeature.label : "";
if (selectedUser.id() === "" || feature === "") return;
if (!selectedUser.settings.features.includes(feature)) {
selectedUser.settings.features.push(feature);
}
userAPI
.updateUser(selectedUser.id(), selectedUser.protobuf())
.then(() => {
success("Enabled " + feature + " for " + selectedUser.name());
setShowEnableFeature(false);
setSelectedFeature({} as Option);
})
.catch(() => error("Failed to enable " + feature + " for " + selectedUser.name()));
};
const remove = (user: User, type: "action" | "feature", key: string) => {
if (user.id() === "" || key === "") return;
if (type === "action") {
user.settings.actions = user.settings.actions.filter(v => v !== key);
}
if (type === "feature") {
user.settings.features = user.settings.features.filter(v => v !== key);
}
userAPI
.updateUser(user.id(), user.protobuf())
.then(() => {
if (type === "action") {
success("Disallowed " + user.name() + " from " + key);
setSelectedAction({} as Option);
}
if (type === "feature") {
success("Disabled " + key + " for " + user.name());
setSelectedFeature({} as Option);
}
})
.catch(() => {
if (type === "action") {
error("Failed to disallow " + user.name() + " from " + key);
}
if (type === "feature") {
error("Failed to disable " + key + " for " + user.name());
}
});
};
const flagChip = (flag: any) => {
return (
<Grid2 key={flag} size={{xs: 12}} >
<Chip size="small" label={flag} color={"default"} />
</Grid2>
);
};
const columns = (): Column<User>[] => {
return [
{
title: "Profile",
render: user => {
const avatarURL = user.settings.avatar;
const avatar = avatarURL ? (
<Avatar alt={user.name()} src={avatarURL} />
) : (
<Avatar alt={user.name()}>
<Face />
</Avatar>
);
return (
<ListItem sx={{ padding: 0 }}>
<ListItemAvatar>{avatar}</ListItemAvatar>
<ListItemText primary={user.name()} secondary={user.settings.email} />
</ListItem>
);
}
},
{
title: "Actions",
cellStyle: { verticalAlign: "top" },
render: user => {
const actions = user.settings.actions;
return (
<Grid2 container spacing={1} className={classes.chipContainer} padding={1}>
{actions.slice(0, 2).map(action => flagChip(action))}
{actions.length > 3 && flagChip("+" + (actions.length - 3) + " more")}
</Grid2>
);
}
},
{
title: "Features",
cellStyle: { verticalAlign: "top" },
render: user => {
const features = user.settings.features;
return (
<Grid2 container spacing={1} className={classes.chipContainer} padding={1}>
{features.slice(0, 2).map(feature => flagChip(feature))}
{features.length > 3 && flagChip("+" + (features.length - 3) + " more")}
</Grid2>
);
}
}
];
};
const details = (user: User) => {
const item = (text: string, type: "action" | "feature") => (
<ListItem key={text}>
<ListItemIcon>
<IconButton
onClick={() => {
setSelectedUser(user);
remove(user, type, text);
}}>
<Remove className={classes.deleteIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>{text}</ListItemText>
</ListItem>
);
const actions = user.settings.actions;
const features = user.settings.features;
return (
<List disablePadding dense>
<ListSubheader className={classes.gutter}>Actions</ListSubheader>
{actions.map(action => item(action, "action"))}
<ListItemButton
onClick={() => {
setSelectedUser(user);
setShowAllowAction(true);
}}>
<ListItemIcon>
<IconButton>
<Add className={classes.addIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>Allow an action</ListItemText>
</ListItemButton>
<Divider />
<ListSubheader className={classes.gutter}>Features</ListSubheader>
{features.map(feature => item(feature, "feature"))}
<ListItemButton
onClick={() => {
setSelectedUser(user);
setShowEnableFeature(true);
}}>
<ListItemIcon>
<IconButton>
<Add className={classes.addIcon} />
</IconButton>
</ListItemIcon>
<ListItemText>Enable a feature</ListItemText>
</ListItemButton>
</List>
);
};
const renderMobile = (user: User) => {
const avatarURL = user.settings.avatar;
const avatar = avatarURL ? (
<Avatar alt={user.name()} src={avatarURL} />
) : (
<Avatar alt={user.name()}>
<Face />
</Avatar>
);
return (
<Box sx={{padding: 1}}>
<ListItem>
<ListItemAvatar>{avatar}</ListItemAvatar>
<ListItemText primary={user.name()} secondary={user.settings.email} />
</ListItem>
</Box>
)
}
return (
<PageContainer spacing={isMobile ? 0 : 2}>
<ResponsiveTable<User>
title="Users"
rows={rows}
columns={columns}
total={total}
page={page}
pageSize={pageSize}
setPage={setPage}
handleRowsPerPageChange={handleRowsPerPageChange}
setSearchText={setSearchText}
isLoading={isLoading}
renderGutter={details}
renderMobile={renderMobile}
// multiGutter
/>
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={showAllowAction}
onClose={() => setShowAllowAction(false)}
aria-labelledby="allow-action">
<DialogTitle id="allow-action-title">Allow an action</DialogTitle>
<DialogContent>
<SearchSelect
label="Select an action to allow"
selected={selectedAction}
options={actions
.filter(a => !selectedUser.settings.actions.includes(a))
.map(a => {
return { value: a, label: a };
})}
changeSelection={(option: Option | null) =>
setSelectedAction(option ? option : undefined)
}
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowAllowAction(false)}>
Close
</Button>
<Button
color="primary"
disabled={
!selectedAction || !actions.includes(selectedAction ? selectedAction.label : "")
}
onClick={allow}>
Allow
</Button>
</DialogActions>
</ResponsiveDialog>
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={showEnableFeature}
onClose={() => setShowEnableFeature(false)}
aria-labelledby="enable-feature">
<DialogTitle id="enable-feature-title">Enable a feature</DialogTitle>
<DialogContent>
<SearchSelect
label="Select a feature to enable"
selected={selectedFeature}
options={features
.filter(a => !selectedUser.settings.features.includes(a))
.map(a => {
return { value: a, label: a };
})}
changeSelection={(option: Option | null) =>
setSelectedFeature(option ? option : undefined)
}
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => setShowEnableFeature(false)}>
Close
</Button>
<Button
color="primary"
disabled={
!selectedFeature || !features.includes(selectedFeature ? selectedFeature.label : "")
}
onClick={enable}>
Enable
</Button>
</DialogActions>
</ResponsiveDialog>
</PageContainer>
);
}