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:
parent
00ba65f3ef
commit
e4b7aa6761
|
@ -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`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }}",
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
// @flow
|
||||
export type Rank = "Admin" | "Viewer" | "Member";
|
||||
export type Role = "admin" | "viewer" | "member";
|
||||
|
|
Reference in New Issue