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 { 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`

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;
}
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",

View File

@ -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";
}
}
}

View File

@ -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
/>
&nbsp;&nbsp;
<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>

View File

@ -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,

View File

@ -181,7 +181,7 @@ router.post("users.demote", auth(), async (ctx) => {
const actor = ctx.state.user;
ctx.assertPresent(userId, "id is required");
to = to === "Viewer" ? "Viewer" : "Member";
to = to === "viewer" ? "viewer" : "member";
const user = await User.findByPk(userId);
@ -262,7 +262,7 @@ router.post("users.invite", auth(), async (ctx) => {
const { user } = ctx.state;
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 });

View File

@ -159,7 +159,7 @@ describe("#users.invite", () => {
const res = await server.post("/api/users.invite", {
body: {
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();
@ -168,27 +168,74 @@ describe("#users.invite", () => {
});
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", {
body: {
token: user.getJwtToken(),
invites: { email: "test@example.com", name: "Test", guest: false },
token: admin.getJwtToken(),
invites: { email: "test@example.com", name: "Test", role: "member" },
},
});
expect(res.status).toEqual(400);
});
it("should require admin", async () => {
const user = await buildUser();
const admin = await buildUser();
const res = await server.post("/api/users.invite", {
body: {
token: user.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", guest: false }],
token: admin.getJwtToken(),
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
},
});
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 () => {
const res = await server.post("/api/users.invite");
expect(res.status).toEqual(401);
@ -325,7 +372,7 @@ describe("#users.demote", () => {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "Viewer",
to: "viewer",
},
});
const body = await res.json();
@ -342,7 +389,7 @@ describe("#users.demote", () => {
body: {
token: admin.getJwtToken(),
id: user.id,
to: "Member",
to: "member",
},
});
const body = await res.json();

View File

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

View File

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

View File

@ -130,6 +130,10 @@
"View and edit": "View and edit",
"View only": "View only",
"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?",
"Change Language": "Change Language",
"Dismiss": "Dismiss",
@ -306,7 +310,6 @@
"Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in",
"Invited": "Invited",
"Admin": "Admin",
"{{ userName }} was removed from the collection": "{{ userName }} was removed from the collection",
"Could not remove user": "Could not remove user",
"{{ userName }} permissions were updated": "{{ userName }} permissions were updated",
@ -476,8 +479,6 @@
"All collections": "All collections",
"{{userName}} requested": "{{userName}} requested",
"Last active": "Last active",
"Role": "Role",
"Viewer": "Viewer",
"Suspended": "Suspended",
"Shared": "Shared",
"by {{ name }}": "by {{ name }}",

View File

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