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:
Saumya Pandey 2021-08-29 03:05:37 +05:30 committed by GitHub
parent 00ba65f3ef
commit e4b7aa6761
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 151 additions and 52 deletions

View File

@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden"; import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { Outline, LabelText } from "./Input"; import { Outline, LabelText } from "./Input";
const Select = styled.select` const Select = styled.select`
@ -15,6 +16,7 @@ const Select = styled.select`
background: none; background: none;
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
height: 30px; height: 30px;
font-size: 14px;
option { option {
background: ${(props) => props.theme.buttonNeutralBackground}; background: ${(props) => props.theme.buttonNeutralBackground};
@ -24,6 +26,10 @@ const Select = styled.select`
&::placeholder { &::placeholder {
color: ${(props) => props.theme.placeholder}; color: ${(props) => props.theme.placeholder};
} }
${breakpoint("mobile", "tablet")`
font-size: 16px;
`};
`; `;
const Wrapper = styled.label` const Wrapper = styled.label`

View 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;

View File

@ -49,7 +49,7 @@ function UserMenu({ user }: Props) {
) { ) {
return; return;
} }
users.demote(user, "Member"); users.demote(user, "member");
}, },
[users, user, t] [users, user, t]
); );
@ -69,7 +69,7 @@ function UserMenu({ user }: Props) {
) { ) {
return; return;
} }
users.demote(user, "Viewer"); users.demote(user, "viewer");
}, },
[users, user, t] [users, user, t]
); );
@ -119,21 +119,21 @@ function UserMenu({ user }: Props) {
userName: user.name, userName: user.name,
}), }),
onClick: handleMember, onClick: handleMember,
visible: can.demote && user.rank !== "Member", visible: can.demote && user.role !== "member",
}, },
{ {
title: t("Make {{ userName }} a viewer", { title: t("Make {{ userName }} a viewer", {
userName: user.name, userName: user.name,
}), }),
onClick: handleViewer, onClick: handleViewer,
visible: can.demote && user.rank !== "Viewer", visible: can.demote && user.role !== "viewer",
}, },
{ {
title: t("Make {{ userName }} an admin…", { title: t("Make {{ userName }} an admin…", {
userName: user.name, userName: user.name,
}), }),
onClick: handlePromote, onClick: handlePromote,
visible: can.promote && user.rank !== "Admin", visible: can.promote && user.role !== "admin",
}, },
{ {
type: "separator", type: "separator",

View File

@ -1,6 +1,6 @@
// @flow // @flow
import { computed } from "mobx"; import { computed } from "mobx";
import type { Rank } from "shared/types"; import type { Role } from "shared/types";
import BaseModel from "./BaseModel"; import BaseModel from "./BaseModel";
class User extends BaseModel { class User extends BaseModel {
@ -21,13 +21,13 @@ class User extends BaseModel {
} }
@computed @computed
get rank(): Rank { get role(): Role {
if (this.isAdmin) { if (this.isAdmin) {
return "Admin"; return "admin";
} else if (this.isViewer) { } else if (this.isViewer) {
return "Viewer"; return "viewer";
} else { } else {
return "Member"; return "member";
} }
} }
} }

View File

@ -5,11 +5,13 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import type { Role } from "shared/types";
import Button from "components/Button"; import Button from "components/Button";
import CopyToClipboard from "components/CopyToClipboard"; import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex"; import Flex from "components/Flex";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import Input from "components/Input"; import Input from "components/Input";
import InputSelectRole from "components/InputSelectRole";
import NudeButton from "components/NudeButton"; import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip"; import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam"; import useCurrentTeam from "hooks/useCurrentTeam";
@ -26,15 +28,16 @@ type Props = {|
type InviteRequest = { type InviteRequest = {
email: string, email: string,
name: string, name: string,
role: Role,
}; };
function Invite({ onSubmit }: Props) { function Invite({ onSubmit }: Props) {
const [isSaving, setIsSaving] = React.useState(); const [isSaving, setIsSaving] = React.useState();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false); const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const [invites, setInvites] = React.useState<InviteRequest[]>([ const [invites, setInvites] = React.useState<InviteRequest[]>([
{ email: "", name: "" }, { email: "", name: "", role: "member" },
{ email: "", name: "" }, { email: "", name: "", role: "member" },
{ email: "", name: "" }, { email: "", name: "", role: "member" },
]); ]);
const { users, policies } = useStores(); const { users, policies } = useStores();
@ -84,7 +87,7 @@ function Invite({ onSubmit }: Props) {
setInvites((prevInvites) => { setInvites((prevInvites) => {
const newInvites = [...prevInvites]; const newInvites = [...prevInvites];
newInvites.push({ email: "", name: "" }); newInvites.push({ email: "", name: "", role: "member" });
return newInvites; return newInvites;
}); });
}, [showToast, invites, t]); }, [showToast, invites, t]);
@ -109,6 +112,14 @@ function Invite({ onSubmit }: Props) {
}); });
}, [showToast, t]); }, [showToast, t]);
const handleRoleChange = React.useCallback((ev, index) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index]["role"] = ev.target.value;
return newInvites;
});
}, []);
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{team.guestSignin ? ( {team.guestSignin ? (
@ -160,7 +171,7 @@ function Invite({ onSubmit }: Props) {
</CopyBlock> </CopyBlock>
)} )}
{invites.map((invite, index) => ( {invites.map((invite, index) => (
<Flex key={index}> <Flex key={index} gap={8}>
<Input <Input
type="email" type="email"
name="email" name="email"
@ -173,7 +184,6 @@ function Invite({ onSubmit }: Props) {
autoFocus={index === 0} autoFocus={index === 0}
flex flex
/> />
&nbsp;&nbsp;
<Input <Input
type="text" type="text"
name="name" name="name"
@ -182,7 +192,12 @@ function Invite({ onSubmit }: Props) {
onChange={(ev) => handleChange(ev, index)} onChange={(ev) => handleChange(ev, index)}
value={invite.name} value={invite.name}
required={!!invite.email} required={!!invite.email}
flex />
<InputSelectRole
onChange={(ev) => handleRoleChange(ev, index)}
value={invite.role}
labelHidden={index !== 0}
short
/> />
{index !== 0 && ( {index !== 0 && (
<Remove> <Remove>

View File

@ -2,7 +2,7 @@
import invariant from "invariant"; import invariant from "invariant";
import { filter, orderBy } from "lodash"; import { filter, orderBy } from "lodash";
import { observable, computed, action, runInAction } from "mobx"; 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 User from "models/User";
import BaseStore from "./BaseStore"; import BaseStore from "./BaseStore";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
@ -68,20 +68,20 @@ export default class UsersStore extends BaseStore<User> {
@action @action
promote = async (user: User) => { promote = async (user: User) => {
try { try {
this.updateCounts("Admin", user.rank); this.updateCounts("admin", user.role);
await this.actionOnUser("promote", user); await this.actionOnUser("promote", user);
} catch { } catch {
this.updateCounts(user.rank, "Admin"); this.updateCounts(user.role, "admin");
} }
}; };
@action @action
demote = async (user: User, to: Rank) => { demote = async (user: User, to: Role) => {
try { try {
this.updateCounts(to, user.rank); this.updateCounts(to, user.role);
await this.actionOnUser("demote", user, to); await this.actionOnUser("demote", user, to);
} catch { } catch {
this.updateCounts(user.rank, to); this.updateCounts(user.role, to);
} }
}; };
@ -110,7 +110,7 @@ export default class UsersStore extends BaseStore<User> {
}; };
@action @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 }); const res = await client.post(`/users.invite`, { invites });
invariant(res && res.data, "Data should be available"); invariant(res && res.data, "Data should be available");
runInAction(`invite`, () => { runInAction(`invite`, () => {
@ -152,24 +152,24 @@ export default class UsersStore extends BaseStore<User> {
} }
@action @action
updateCounts = (to: Rank, from: Rank) => { updateCounts = (to: Role, from: Role) => {
if (to === "Admin") { if (to === "admin") {
this.counts.admins += 1; this.counts.admins += 1;
if (from === "Viewer") { if (from === "viewer") {
this.counts.viewers -= 1; this.counts.viewers -= 1;
} }
} }
if (to === "Viewer") { if (to === "viewer") {
this.counts.viewers += 1; this.counts.viewers += 1;
if (from === "Admin") { if (from === "admin") {
this.counts.admins -= 1; this.counts.admins -= 1;
} }
} }
if (to === "Member") { if (to === "member") {
if (from === "Viewer") { if (from === "viewer") {
this.counts.viewers -= 1; this.counts.viewers -= 1;
} }
if (from === "Admin") { if (from === "admin") {
this.counts.admins -= 1; this.counts.admins -= 1;
} }
} }
@ -233,7 +233,7 @@ export default class UsersStore extends BaseStore<User> {
return queriedUsers(users, query); 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}`, { const res = await client.post(`/users.${action}`, {
id: user.id, id: user.id,
to, to,

View File

@ -181,7 +181,7 @@ router.post("users.demote", auth(), async (ctx) => {
const actor = ctx.state.user; const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required"); ctx.assertPresent(userId, "id is required");
to = to === "Viewer" ? "Viewer" : "Member"; to = to === "viewer" ? "viewer" : "member";
const user = await User.findByPk(userId); const user = await User.findByPk(userId);
@ -262,7 +262,7 @@ router.post("users.invite", auth(), async (ctx) => {
const { user } = ctx.state; const { user } = ctx.state;
const team = await Team.findByPk(user.teamId); const team = await Team.findByPk(user.teamId);
authorize(user, "invite", team); authorize(user, "inviteUser", team);
const response = await userInviter({ user, invites, ip: ctx.request.ip }); const response = await userInviter({ user, invites, ip: ctx.request.ip });

View File

@ -159,7 +159,7 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", { const res = await server.post("/api/users.invite", {
body: { body: {
token: user.getJwtToken(), token: user.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", guest: false }], invites: [{ email: "test@example.com", name: "Test", role: "member" }],
}, },
}); });
const body = await res.json(); const body = await res.json();
@ -168,27 +168,74 @@ describe("#users.invite", () => {
}); });
it("should require invites to be an array", async () => { it("should require invites to be an array", async () => {
const user = await buildUser(); const admin = await buildAdmin();
const res = await server.post("/api/users.invite", { const res = await server.post("/api/users.invite", {
body: { body: {
token: user.getJwtToken(), token: admin.getJwtToken(),
invites: { email: "test@example.com", name: "Test", guest: false }, invites: { email: "test@example.com", name: "Test", role: "member" },
}, },
}); });
expect(res.status).toEqual(400); expect(res.status).toEqual(400);
}); });
it("should require admin", async () => { it("should require admin", async () => {
const user = await buildUser(); const admin = await buildUser();
const res = await server.post("/api/users.invite", { const res = await server.post("/api/users.invite", {
body: { body: {
token: user.getJwtToken(), token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", guest: false }], invites: [{ email: "test@example.com", name: "Test", role: "member" }],
}, },
}); });
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("should invite user as an admin", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "admin" }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isAdmin).toBeTruthy();
expect(body.data.users[0].isViewer).toBeFalsy();
});
it("should invite user as a viewer", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "viewer" }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isViewer).toBeTruthy();
expect(body.data.users[0].isAdmin).toBeFalsy();
});
it("should invite user as a member if role is any arbitary value", async () => {
const admin = await buildAdmin();
const res = await server.post("/api/users.invite", {
body: {
token: admin.getJwtToken(),
invites: [
{ email: "test@example.com", name: "Test", role: "arbitary" },
],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sent.length).toEqual(1);
expect(body.data.users[0].isViewer).toBeFalsy();
expect(body.data.users[0].isAdmin).toBeFalsy();
});
it("should require authentication", async () => { it("should require authentication", async () => {
const res = await server.post("/api/users.invite"); const res = await server.post("/api/users.invite");
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
@ -325,7 +372,7 @@ describe("#users.demote", () => {
body: { body: {
token: admin.getJwtToken(), token: admin.getJwtToken(),
id: user.id, id: user.id,
to: "Viewer", to: "viewer",
}, },
}); });
const body = await res.json(); const body = await res.json();
@ -342,7 +389,7 @@ describe("#users.demote", () => {
body: { body: {
token: admin.getJwtToken(), token: admin.getJwtToken(),
id: user.id, id: user.id,
to: "Member", to: "member",
}, },
}); });
const body = await res.json(); const body = await res.json();

View File

@ -1,9 +1,14 @@
// @flow // @flow
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import type { Role } from "shared/types";
import mailer from "../mailer"; import mailer from "../mailer";
import { User, Event, Team } from "../models"; import { User, Event, Team } from "../models";
type Invite = { name: string, email: string }; type Invite = {
name: string,
email: string,
role: Role,
};
export default async function userInviter({ export default async function userInviter({
user, user,
@ -52,6 +57,8 @@ export default async function userInviter({
name: invite.name, name: invite.name,
email: invite.email, email: invite.email,
service: null, service: null,
isAdmin: invite.role === "admin",
isViewer: invite.role === "viewer",
}); });
users.push(newUser); users.push(newUser);
@ -62,6 +69,7 @@ export default async function userInviter({
data: { data: {
email: invite.email, email: invite.email,
name: invite.name, name: invite.name,
role: invite.role,
}, },
ip, ip,
}); });

View File

@ -298,7 +298,7 @@ User.getCounts = async function (teamId: string) {
User.prototype.demote = async function ( User.prototype.demote = async function (
teamId: string, teamId: string,
to: "Member" | "Viewer" to: "member" | "viewer"
) { ) {
const res = await User.findAndCountAll({ const res = await User.findAndCountAll({
where: { where: {
@ -312,9 +312,9 @@ User.prototype.demote = async function (
}); });
if (res.count >= 1) { if (res.count >= 1) {
if (to === "Member") { if (to === "member") {
return this.update({ isAdmin: false, isViewer: false }); return this.update({ isAdmin: false, isViewer: false });
} else if (to === "Viewer") { } else if (to === "viewer") {
return this.update({ isAdmin: false, isViewer: true }); return this.update({ isAdmin: false, isViewer: true });
} }
} else { } else {

View File

@ -130,6 +130,10 @@
"View and edit": "View and edit", "View and edit": "View and edit",
"View only": "View only", "View only": "View only",
"No access": "No access", "No access": "No access",
"Role": "Role",
"Member": "Member",
"Viewer": "Viewer",
"Admin": "Admin",
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?", "Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
"Change Language": "Change Language", "Change Language": "Change Language",
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
@ -306,7 +310,6 @@
"Active <1></1> ago": "Active <1></1> ago", "Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in", "Never signed in": "Never signed in",
"Invited": "Invited", "Invited": "Invited",
"Admin": "Admin",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection", "{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
"Could not remove user": "Could not remove user", "Could not remove user": "Could not remove user",
"{{ userName }} permissions were updated": "{{ userName }} permissions were updated", "{{ userName }} permissions were updated": "{{ userName }} permissions were updated",
@ -476,8 +479,6 @@
"All collections": "All collections", "All collections": "All collections",
"{{userName}} requested": "{{userName}} requested", "{{userName}} requested": "{{userName}} requested",
"Last active": "Last active", "Last active": "Last active",
"Role": "Role",
"Viewer": "Viewer",
"Suspended": "Suspended", "Suspended": "Suspended",
"Shared": "Shared", "Shared": "Shared",
"by {{ name }}": "by {{ name }}", "by {{ name }}": "by {{ name }}",

View File

@ -1,2 +1,2 @@
// @flow // @flow
export type Rank = "Admin" | "Viewer" | "Member"; export type Role = "admin" | "viewer" | "member";