diff --git a/app/components/InputSelect.js b/app/components/InputSelect.js index 840be3953..a673f3c82 100644 --- a/app/components/InputSelect.js +++ b/app/components/InputSelect.js @@ -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` diff --git a/app/components/InputSelectRole.js b/app/components/InputSelectRole.js new file mode 100644 index 000000000..35fe1687a --- /dev/null +++ b/app/components/InputSelectRole.js @@ -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 }>) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +export default InputSelectRole; diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js index c9a0786d5..d2a535c31 100644 --- a/app/menus/UserMenu.js +++ b/app/menus/UserMenu.js @@ -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", diff --git a/app/models/User.js b/app/models/User.js index d870969b9..7fc03c5ba 100644 --- a/app/models/User.js +++ b/app/models/User.js @@ -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"; } } } diff --git a/app/scenes/Invite.js b/app/scenes/Invite.js index 3e899a671..1a53e4231 100644 --- a/app/scenes/Invite.js +++ b/app/scenes/Invite.js @@ -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(false); const [invites, setInvites] = React.useState([ - { 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 (
{team.guestSignin ? ( @@ -160,7 +171,7 @@ function Invite({ onSubmit }: Props) { )} {invites.map((invite, index) => ( - + -    handleChange(ev, index)} value={invite.name} required={!!invite.email} - flex + /> + handleRoleChange(ev, index)} + value={invite.role} + labelHidden={index !== 0} + short /> {index !== 0 && ( diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index 24197f3dd..a1ae7e53f 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -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 { @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 { }; @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 { } @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 { 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, diff --git a/server/api/users.js b/server/api/users.js index d49c95195..435f13eac 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -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 }); diff --git a/server/api/users.test.js b/server/api/users.test.js index 7565fdff9..6734846cf 100644 --- a/server/api/users.test.js +++ b/server/api/users.test.js @@ -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(); diff --git a/server/commands/userInviter.js b/server/commands/userInviter.js index fc2e0db03..520f84d91 100644 --- a/server/commands/userInviter.js +++ b/server/commands/userInviter.js @@ -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, }); diff --git a/server/models/User.js b/server/models/User.js index a07c02cb9..af987c607 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -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 { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 11345a7f8..d1e58004e 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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> ago": "Active <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 }}", diff --git a/shared/types.js b/shared/types.js index 44c68467e..308ce1771 100644 --- a/shared/types.js +++ b/shared/types.js @@ -1,2 +1,2 @@ // @flow -export type Rank = "Admin" | "Viewer" | "Member"; +export type Role = "admin" | "viewer" | "member";