fix: Add ability to choose user permission level when inviting (#2473)
* Select user role while sending invite * Add tests to check for role * Update app/scenes/Invite.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * Use select * Use inviteUser policy * Remove unnecessary code * Normalize rank/role Fix text sizing of select input, fix alignment on users invite form * Move component to root * cleanup Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
@@ -15,6 +16,7 @@ const Select = styled.select`
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
font-size: 14px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
@@ -24,6 +26,10 @@ const Select = styled.select`
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
font-size: 16px;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label`
|
||||
|
||||
22
app/components/InputSelectRole.js
Normal file
22
app/components/InputSelectRole.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InputSelect, { type Props, type Option } from "components/InputSelect";
|
||||
|
||||
const InputSelectRole = (props: $Rest<Props, { options: Array<Option> }>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
label={t("Role")}
|
||||
options={[
|
||||
{ label: t("Member"), value: "member" },
|
||||
{ label: t("Viewer"), value: "viewer" },
|
||||
{ label: t("Admin"), value: "admin" },
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputSelectRole;
|
||||
@@ -49,7 +49,7 @@ function UserMenu({ user }: Props) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user, "Member");
|
||||
users.demote(user, "member");
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
@@ -69,7 +69,7 @@ function UserMenu({ user }: Props) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
users.demote(user, "Viewer");
|
||||
users.demote(user, "viewer");
|
||||
},
|
||||
[users, user, t]
|
||||
);
|
||||
@@ -119,21 +119,21 @@ function UserMenu({ user }: Props) {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handleMember,
|
||||
visible: can.demote && user.rank !== "Member",
|
||||
visible: can.demote && user.role !== "member",
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} a viewer", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handleViewer,
|
||||
visible: can.demote && user.rank !== "Viewer",
|
||||
visible: can.demote && user.role !== "viewer",
|
||||
},
|
||||
{
|
||||
title: t("Make {{ userName }} an admin…", {
|
||||
userName: user.name,
|
||||
}),
|
||||
onClick: handlePromote,
|
||||
visible: can.promote && user.rank !== "Admin",
|
||||
visible: can.promote && user.role !== "admin",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import { computed } from "mobx";
|
||||
import type { Rank } from "shared/types";
|
||||
import type { Role } from "shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
class User extends BaseModel {
|
||||
@@ -21,13 +21,13 @@ class User extends BaseModel {
|
||||
}
|
||||
|
||||
@computed
|
||||
get rank(): Rank {
|
||||
get role(): Role {
|
||||
if (this.isAdmin) {
|
||||
return "Admin";
|
||||
return "admin";
|
||||
} else if (this.isViewer) {
|
||||
return "Viewer";
|
||||
return "viewer";
|
||||
} else {
|
||||
return "Member";
|
||||
return "member";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import type { Role } from "shared/types";
|
||||
import Button from "components/Button";
|
||||
import CopyToClipboard from "components/CopyToClipboard";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import InputSelectRole from "components/InputSelectRole";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
@@ -26,15 +28,16 @@ type Props = {|
|
||||
type InviteRequest = {
|
||||
email: string,
|
||||
name: string,
|
||||
role: Role,
|
||||
};
|
||||
|
||||
function Invite({ onSubmit }: Props) {
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
|
||||
const [invites, setInvites] = React.useState<InviteRequest[]>([
|
||||
{ email: "", name: "" },
|
||||
{ email: "", name: "" },
|
||||
{ email: "", name: "" },
|
||||
{ email: "", name: "", role: "member" },
|
||||
{ email: "", name: "", role: "member" },
|
||||
{ email: "", name: "", role: "member" },
|
||||
]);
|
||||
|
||||
const { users, policies } = useStores();
|
||||
@@ -84,7 +87,7 @@ function Invite({ onSubmit }: Props) {
|
||||
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites.push({ email: "", name: "" });
|
||||
newInvites.push({ email: "", name: "", role: "member" });
|
||||
return newInvites;
|
||||
});
|
||||
}, [showToast, invites, t]);
|
||||
@@ -109,6 +112,14 @@ function Invite({ onSubmit }: Props) {
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleRoleChange = React.useCallback((ev, index) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index]["role"] = ev.target.value;
|
||||
return newInvites;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{team.guestSignin ? (
|
||||
@@ -160,7 +171,7 @@ function Invite({ onSubmit }: Props) {
|
||||
</CopyBlock>
|
||||
)}
|
||||
{invites.map((invite, index) => (
|
||||
<Flex key={index}>
|
||||
<Flex key={index} gap={8}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
@@ -173,7 +184,6 @@ function Invite({ onSubmit }: Props) {
|
||||
autoFocus={index === 0}
|
||||
flex
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
@@ -182,7 +192,12 @@ function Invite({ onSubmit }: Props) {
|
||||
onChange={(ev) => handleChange(ev, index)}
|
||||
value={invite.name}
|
||||
required={!!invite.email}
|
||||
flex
|
||||
/>
|
||||
<InputSelectRole
|
||||
onChange={(ev) => handleRoleChange(ev, index)}
|
||||
value={invite.role}
|
||||
labelHidden={index !== 0}
|
||||
short
|
||||
/>
|
||||
{index !== 0 && (
|
||||
<Remove>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import invariant from "invariant";
|
||||
import { filter, orderBy } from "lodash";
|
||||
import { observable, computed, action, runInAction } from "mobx";
|
||||
import type { Rank } from "shared/types";
|
||||
import type { Role } from "shared/types";
|
||||
import User from "models/User";
|
||||
import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
@@ -68,20 +68,20 @@ export default class UsersStore extends BaseStore<User> {
|
||||
@action
|
||||
promote = async (user: User) => {
|
||||
try {
|
||||
this.updateCounts("Admin", user.rank);
|
||||
this.updateCounts("admin", user.role);
|
||||
await this.actionOnUser("promote", user);
|
||||
} catch {
|
||||
this.updateCounts(user.rank, "Admin");
|
||||
this.updateCounts(user.role, "admin");
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
demote = async (user: User, to: Rank) => {
|
||||
demote = async (user: User, to: Role) => {
|
||||
try {
|
||||
this.updateCounts(to, user.rank);
|
||||
this.updateCounts(to, user.role);
|
||||
await this.actionOnUser("demote", user, to);
|
||||
} catch {
|
||||
this.updateCounts(user.rank, to);
|
||||
this.updateCounts(user.role, to);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
};
|
||||
|
||||
@action
|
||||
invite = async (invites: { email: string, name: string }[]) => {
|
||||
invite = async (invites: { email: string, name: string, role: Role }[]) => {
|
||||
const res = await client.post(`/users.invite`, { invites });
|
||||
invariant(res && res.data, "Data should be available");
|
||||
runInAction(`invite`, () => {
|
||||
@@ -152,24 +152,24 @@ export default class UsersStore extends BaseStore<User> {
|
||||
}
|
||||
|
||||
@action
|
||||
updateCounts = (to: Rank, from: Rank) => {
|
||||
if (to === "Admin") {
|
||||
updateCounts = (to: Role, from: Role) => {
|
||||
if (to === "admin") {
|
||||
this.counts.admins += 1;
|
||||
if (from === "Viewer") {
|
||||
if (from === "viewer") {
|
||||
this.counts.viewers -= 1;
|
||||
}
|
||||
}
|
||||
if (to === "Viewer") {
|
||||
if (to === "viewer") {
|
||||
this.counts.viewers += 1;
|
||||
if (from === "Admin") {
|
||||
if (from === "admin") {
|
||||
this.counts.admins -= 1;
|
||||
}
|
||||
}
|
||||
if (to === "Member") {
|
||||
if (from === "Viewer") {
|
||||
if (to === "member") {
|
||||
if (from === "viewer") {
|
||||
this.counts.viewers -= 1;
|
||||
}
|
||||
if (from === "Admin") {
|
||||
if (from === "admin") {
|
||||
this.counts.admins -= 1;
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export default class UsersStore extends BaseStore<User> {
|
||||
return queriedUsers(users, query);
|
||||
};
|
||||
|
||||
actionOnUser = async (action: string, user: User, to?: Rank) => {
|
||||
actionOnUser = async (action: string, user: User, to?: Role) => {
|
||||
const res = await client.post(`/users.${action}`, {
|
||||
id: user.id,
|
||||
to,
|
||||
|
||||
Reference in New Issue
Block a user