Skip to content

Commit 5860447

Browse files
authored
Invite user enhancements (#2520)
* task: minor ui changes * task: create no permissions to invite component * task: immediately show the no permissions dialog when user can't invite * task: handle free-typed email addresses * task: only allow owners to invite new owners * task: convert to email chip when pressing space * task: replace placeholder text * task: delete email on backspace * task: filter role to current instance * task: refetch user roles on every instance load * task: autofocus input field on error * task: remove unnecessary useMemo * task: prevent random spaces from triggering error message * task: handle tag deletion on backspace keydown * task: sort by role and first name * task: minor style updates * task: minor style updates * task: focus input field on error * task: change header text * task: remove placeholder when an email chip is present * task: change to input field * fix: delete chip on click not working * task: check if aside from email chips there are other inputs * task: update list spacing * task: simplified iwOwner check * task: create separate component for confirmation modal * task: create failed invites state * chore: todo * task: update confirmation modal * task: update zesty material version * task: added helper function to pluralize words * task: handle error message when user invites themselves * task: update error msg * task: handle comma or space separated emails pasted on the input field
1 parent 0df5e4f commit 5860447

File tree

7 files changed

+538
-121
lines changed

7 files changed

+538
-121
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
Button,
3+
Box,
4+
Dialog,
5+
DialogActions,
6+
DialogContent,
7+
DialogTitle,
8+
ListItem,
9+
ListItemIcon,
10+
ListItemText,
11+
Typography,
12+
SvgIcon,
13+
} from "@mui/material";
14+
import { ErrorRounded, CheckRounded } from "@mui/icons-material";
15+
import PersonAddRoundedIcon from "@mui/icons-material/PersonAddRounded";
16+
import MailIcon from "@mui/icons-material/Mail";
17+
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
18+
import CheckRoundedIcon from "@mui/icons-material/CheckRounded";
19+
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
20+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
21+
import pluralizeWord from "../../../utility/pluralizeWord";
22+
23+
type ConfirmationModalProps = {
24+
sentEmails: string[];
25+
onClose: () => void;
26+
onResetSentEmails: () => void;
27+
roleName: string;
28+
failedInvites: Record<string, string>;
29+
};
30+
export const ConfirmationModal = ({
31+
sentEmails,
32+
onClose,
33+
onResetSentEmails,
34+
roleName,
35+
failedInvites,
36+
}: ConfirmationModalProps) => {
37+
const hasFailedInvites = !!Object.keys(failedInvites).length;
38+
const hasSuccessfulInvites = !!sentEmails.length;
39+
40+
const generateHeaderText = () => {
41+
if (hasFailedInvites && hasSuccessfulInvites) {
42+
return `Invites set to ${Object.keys(failedInvites).length} out of ${
43+
Object.keys(failedInvites).length + sentEmails.length
44+
} ${pluralizeWord(
45+
"user",
46+
Object.keys(failedInvites).length + sentEmails.length
47+
)} (via email)`;
48+
}
49+
50+
if (hasFailedInvites && !hasSuccessfulInvites) {
51+
return `Unable to invite ${
52+
Object.keys(failedInvites).length
53+
} ${pluralizeWord("user", Object.keys(failedInvites).length)}`;
54+
}
55+
56+
if (!hasFailedInvites && hasSuccessfulInvites) {
57+
return `Invite sent to ${pluralizeWord(
58+
"user",
59+
sentEmails.length
60+
)} (via email)`;
61+
}
62+
};
63+
64+
return (
65+
<Dialog open onClose={onClose} fullWidth maxWidth={"xs"}>
66+
<DialogTitle>
67+
<SvgIcon
68+
component={hasFailedInvites ? ErrorRounded : CheckRounded}
69+
color={hasFailedInvites ? "error" : "success"}
70+
sx={{
71+
padding: 1,
72+
borderRadius: "20px",
73+
backgroundColor: hasFailedInvites ? "red.100" : "green.100",
74+
display: "block",
75+
}}
76+
/>
77+
<Typography variant="h5" fontWeight={700} sx={{ mt: 1.5 }}>
78+
{generateHeaderText()}
79+
</Typography>
80+
81+
<Typography sx={{ mt: 1 }} variant="body2" color="text.secondary">
82+
See list below
83+
</Typography>
84+
</DialogTitle>
85+
<DialogContent>
86+
{sentEmails.map((email) => (
87+
<ListItem divider dense disableGutters sx={{ borderColor: "border" }}>
88+
<ListItemIcon sx={{ minWidth: 28 }}>
89+
<CheckCircleRoundedIcon fontSize="small" color="success" />
90+
</ListItemIcon>
91+
<ListItemText
92+
sx={{
93+
"& .MuiListItemText-primary": {
94+
display: "flex",
95+
justifyContent: "space-between",
96+
},
97+
}}
98+
>
99+
<Typography variant="body2" color="text.primary">
100+
{email}
101+
</Typography>
102+
<Typography variant="body2">Invite sent</Typography>
103+
</ListItemText>
104+
</ListItem>
105+
))}
106+
{Object.entries(failedInvites)?.map(([email, reason]) => (
107+
<ListItem divider dense disableGutters sx={{ borderColor: "border" }}>
108+
<ListItemIcon sx={{ minWidth: 28 }}>
109+
<ErrorRoundedIcon fontSize="small" color="error" />
110+
</ListItemIcon>
111+
<ListItemText
112+
sx={{
113+
"& .MuiListItemText-primary": {
114+
display: "flex",
115+
justifyContent: "space-between",
116+
},
117+
}}
118+
>
119+
<Typography variant="body2" color="text.primary">
120+
{email}
121+
</Typography>
122+
<Typography variant="body2">{reason}</Typography>
123+
</ListItemText>
124+
</ListItem>
125+
))}
126+
</DialogContent>
127+
<DialogActions>
128+
<Button color="inherit" variant="outlined" onClick={onResetSentEmails}>
129+
Invite More People
130+
</Button>
131+
<Button color="primary" variant="contained" onClick={() => onClose()}>
132+
Done
133+
</Button>
134+
</DialogActions>
135+
</Dialog>
136+
);
137+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useMemo } from "react";
2+
import {
3+
DialogTitle,
4+
DialogActions,
5+
DialogContent,
6+
Typography,
7+
Button,
8+
Box,
9+
Avatar,
10+
List,
11+
ListItem,
12+
ListItemText,
13+
ListItemAvatar,
14+
Dialog,
15+
} from "@mui/material";
16+
import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded";
17+
18+
import { useGetUsersRolesQuery } from "../../services/accounts";
19+
import { MD5 } from "../../../utility/md5";
20+
21+
type NoPermissionProps = {
22+
onClose: () => void;
23+
};
24+
25+
export const NoPermission = ({ onClose }: NoPermissionProps) => {
26+
const { data: users } = useGetUsersRolesQuery();
27+
28+
const ownersAndAdmins = useMemo(() => {
29+
if (users?.length) {
30+
const owners = users
31+
.filter((user) => user.role?.name?.toLowerCase() === "owner")
32+
.sort((a, b) => a.firstName.localeCompare(b.firstName));
33+
const admins = users
34+
.filter((user) => user.role?.name?.toLowerCase() === "admin")
35+
.sort((a, b) => a.firstName.localeCompare(b.firstName));
36+
37+
return [...owners, ...admins];
38+
}
39+
}, [users]);
40+
41+
return (
42+
<Dialog open onClose={onClose} maxWidth="xs">
43+
<DialogTitle>
44+
<ErrorRoundedIcon
45+
color="error"
46+
sx={{
47+
padding: 1,
48+
borderRadius: "20px",
49+
backgroundColor: "red.100",
50+
display: "block",
51+
}}
52+
/>
53+
<Box fontWeight={700} mb={1} mt={1.5}>
54+
You do not have permission to invite users
55+
</Box>
56+
<Typography color="text.secondary" variant="body2">
57+
Contact your instance owners or administrators listed below to change
58+
your role to Admin or Owner on this instance for user invitation
59+
priveleges.
60+
</Typography>
61+
</DialogTitle>
62+
<DialogContent>
63+
<List>
64+
{ownersAndAdmins?.map((user) => (
65+
<ListItem
66+
key={user.ZUID}
67+
dense
68+
disableGutters
69+
sx={{
70+
borderBottom: "1px solid",
71+
borderColor: "border",
72+
}}
73+
>
74+
<ListItemAvatar>
75+
<Avatar
76+
alt={`${user.firstName} ${user.lastName}`}
77+
src={`https://www.gravatar.com/avatar/${MD5(
78+
user.email || ""
79+
)}?s=40`}
80+
/>
81+
</ListItemAvatar>
82+
<ListItemText
83+
primary={`${user.firstName} ${user.lastName}`}
84+
primaryTypographyProps={{
85+
sx: {
86+
color: "text.primary",
87+
},
88+
}}
89+
secondary={`${user.role.name}${user.email}`}
90+
/>
91+
</ListItem>
92+
))}
93+
</List>
94+
</DialogContent>
95+
<DialogActions>
96+
<Button color="primary" variant="contained" onClick={onClose}>
97+
Done
98+
</Button>
99+
</DialogActions>
100+
</Dialog>
101+
);
102+
};

src/shell/components/InviteMembersModal/RoleAccessInfo.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const RoleAccessInfo = ({ role }: Props) => {
6161
<Box
6262
component="ul"
6363
sx={{
64-
listStylePosition: "inside",
64+
pl: 2,
6565
li: {
6666
marginTop: 2,
6767
},
@@ -70,7 +70,7 @@ export const RoleAccessInfo = ({ role }: Props) => {
7070
<Typography component="li" variant="body2" sx={{ marginBottom: 2 }}>
7171
Has access to:
7272
</Typography>
73-
<Box display="flex" flexWrap="wrap" gap={2} ml={2}>
73+
<Box display="flex" flexWrap="wrap" gap={2}>
7474
{roleAccess[role].map((access) => (
7575
<Box display="flex" width={120} alignItems="center">
7676
{accessIcon[access as keyof typeof accessIcon]}

src/shell/components/InviteMembersModal/RoleSelectModal.tsx

+44-30
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import {
77
Typography,
88
} from "@mui/material";
99
import { alpha } from "@mui/material/styles";
10-
import { useState } from "react";
10+
import { useMemo, useState } from "react";
1111
import CheckIcon from "@mui/icons-material/Check";
1212
import AdminPanelSettingsRoundedIcon from "@mui/icons-material/AdminPanelSettingsRounded";
1313
import { RoleAccessInfo } from "./RoleAccessInfo";
1414
import EditRoundedIcon from "@mui/icons-material/EditRounded";
1515
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
1616
import RecommendRoundedIcon from "@mui/icons-material/RecommendRounded";
1717

18+
import { useGetCurrentUserRolesQuery } from "../../services/accounts";
19+
import instanceZUID from "../../../utility/instanceZUID";
20+
1821
const roles = [
1922
{
2023
name: "Owner",
@@ -94,44 +97,55 @@ interface Props {
9497
}
9598

9699
export const RoleSelectModal = ({ role, onSelect, onClose }: Props) => {
100+
const { data: currentUserRoles } = useGetCurrentUserRolesQuery();
97101
const [hoveredRoleIndex, setHoveredRoleIndex] = useState(role);
98102

103+
const isOwner = currentUserRoles
104+
?.filter((role) => role.entityZUID === instanceZUID)
105+
?.some((role) => role.name?.toLowerCase() === "owner");
106+
99107
return (
100108
<Dialog open={true} onClose={onClose} fullWidth maxWidth={"xs"}>
101109
<Box display="flex" sx={{ height: "400px" }}>
102110
<Box sx={{ mt: 1 }}>
103-
{roles.map((roleItem, index) => (
104-
<ListItemButton
105-
onMouseLeave={() => setHoveredRoleIndex(role)}
106-
onMouseEnter={() => setHoveredRoleIndex(index)}
107-
onClick={() => onSelect(index)}
108-
sx={{
109-
width: "171px",
110-
...(role === index && {
111-
backgroundColor: (theme) =>
112-
alpha(
113-
theme.palette.primary.main,
114-
theme.palette.action.hoverOpacity
115-
),
116-
}),
117-
}}
118-
>
119-
<ListItemText
120-
primary={roleItem.name}
121-
primaryTypographyProps={{
122-
variant: "body1",
123-
}}
124-
/>
125-
<ListItemIcon
111+
{roles.map((roleItem, index) => {
112+
if (roleItem.name === "Owner" && !isOwner) {
113+
return <></>;
114+
}
115+
116+
return (
117+
<ListItemButton
118+
onMouseLeave={() => setHoveredRoleIndex(role)}
119+
onMouseEnter={() => setHoveredRoleIndex(index)}
120+
onClick={() => onSelect(index)}
126121
sx={{
127-
visibility: role !== index && "hidden",
128-
justifyContent: "flex-end",
122+
width: "171px",
123+
...(role === index && {
124+
backgroundColor: (theme) =>
125+
alpha(
126+
theme.palette.primary.main,
127+
theme.palette.action.hoverOpacity
128+
),
129+
}),
129130
}}
130131
>
131-
<CheckIcon color="primary" />
132-
</ListItemIcon>
133-
</ListItemButton>
134-
))}
132+
<ListItemText
133+
primary={roleItem.name}
134+
primaryTypographyProps={{
135+
variant: "body1",
136+
}}
137+
/>
138+
<ListItemIcon
139+
sx={{
140+
visibility: role !== index && "hidden",
141+
justifyContent: "flex-end",
142+
}}
143+
>
144+
<CheckIcon color="primary" />
145+
</ListItemIcon>
146+
</ListItemButton>
147+
);
148+
})}
135149
</Box>
136150
<Box sx={{ mt: 2, px: 2 }}>
137151
<Box display="flex" gap={1.5} alignItems="center">

0 commit comments

Comments
 (0)