diff --git a/app/actions/definitions/users.tsx b/app/actions/definitions/users.tsx index 95c4f301f..a90c46159 100644 --- a/app/actions/definitions/users.tsx +++ b/app/actions/definitions/users.tsx @@ -1,8 +1,13 @@ import { PlusIcon } from "outline-icons"; import * as React from "react"; +import { UserRole } from "@shared/types"; +import { UserRoleHelper } from "@shared/utils/UserRoleHelper"; import stores from "~/stores"; import Invite from "~/scenes/Invite"; -import { UserDeleteDialog } from "~/components/UserDialogs"; +import { + UserChangeRoleDialog, + UserDeleteDialog, +} from "~/components/UserDialogs"; import { createAction } from "~/actions"; import { UserSection } from "~/actions/sections"; @@ -22,6 +27,48 @@ export const inviteUser = createAction({ }, }); +export const updateUserRoleActionFactory = (userId: string, role: UserRole) => + createAction({ + name: ({ t }) => { + const user = stores.users.get(userId); + + return UserRoleHelper.isRoleHigher(role, user!.role) + ? `${t("Promote to {{ role }}", { role })}…` + : `${t("Demote to {{ role }}", { role })}…`; + }, + analyticsName: "Update user role", + section: UserSection, + visible: ({ stores }) => { + const can = stores.policies.abilities(userId); + const user = stores.users.get(userId); + if (!user) { + return false; + } + return UserRoleHelper.isRoleHigher(role, user.role) + ? can.promote + : UserRoleHelper.isRoleLower(role, user.role) + ? can.demote + : false; + }, + perform: ({ t }) => { + const user = stores.users.get(userId); + if (!user) { + return; + } + + stores.dialogs.openModal({ + title: t("Update role"), + content: ( + + ), + }); + }, + }); + export const deleteUserActionFactory = (userId: string) => createAction({ name: ({ t }) => `${t("Delete user")}…`, diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx index e7d6fff2f..74aac725e 100644 --- a/app/components/UserDialogs.tsx +++ b/app/components/UserDialogs.tsx @@ -11,42 +11,41 @@ type Props = { onSubmit: () => void; }; -export function UserChangeToViewerDialog({ user, onSubmit }: Props) { +export function UserChangeRoleDialog({ + user, + role, + onSubmit, +}: Props & { + role: UserRole; +}) { const { t } = useTranslation(); const { users } = useStores(); const handleSubmit = async () => { - await users.demote(user, UserRole.Viewer); + await users.updateRole(user, role); onSubmit(); }; - return ( - - {t( - "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content", - { - userName: user.name, - } - )} - . - - ); -} - -export function UserChangeToMemberDialog({ user, onSubmit }: Props) { - const { t } = useTranslation(); - const { users } = useStores(); - - const handleSubmit = async () => { - await users.demote(user, UserRole.Member); - onSubmit(); - }; + let accessNote; + switch (role) { + case UserRole.Admin: + accessNote = t("Admins can manage the workspace and access billing."); + break; + case UserRole.Member: + accessNote = t("Editors can create, edit, and comment on documents."); + break; + case UserRole.Viewer: + accessNote = t("Viewers can only view and comment on documents."); + break; + } return ( - {t("Are you sure you want to make {{ userName }} a member?", { + {t("Are you sure you want to make {{ userName }} a {{ role }}?", { + role, userName: user.name, - })} + })}{" "} + {accessNote} ); } @@ -76,27 +75,6 @@ export function UserDeleteDialog({ user, onSubmit }: Props) { ); } -export function UserChangeToAdminDialog({ user, onSubmit }: Props) { - const { t } = useTranslation(); - const { users } = useStores(); - - const handleSubmit = async () => { - await users.promote(user); - onSubmit(); - }; - - return ( - - {t( - "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", - { - userName: user.name, - } - )} - - ); -} - export function UserSuspendDialog({ user, onSubmit }: Props) { const { t } = useTranslation(); const { users } = useStores(); diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index 717c5c795..a331ea74c 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -3,19 +3,20 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { useMenuState } from "reakit/Menu"; import { toast } from "sonner"; +import { UserRole } from "@shared/types"; import User from "~/models/User"; import ContextMenu from "~/components/ContextMenu"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import Template from "~/components/ContextMenu/Template"; import { - UserChangeToAdminDialog, - UserChangeToMemberDialog, - UserChangeToViewerDialog, UserSuspendDialog, UserChangeNameDialog, } from "~/components/UserDialogs"; import { actionToMenuItem } from "~/actions"; -import { deleteUserActionFactory } from "~/actions/definitions/users"; +import { + deleteUserActionFactory, + updateUserRoleActionFactory, +} from "~/actions/definitions/users"; import useActionContext from "~/hooks/useActionContext"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; @@ -35,54 +36,6 @@ function UserMenu({ user }: Props) { isContextMenu: true, }); - const handlePromote = React.useCallback( - (ev: React.SyntheticEvent) => { - ev.preventDefault(); - dialogs.openModal({ - title: t("Change role to admin"), - content: ( - - ), - }); - }, - [dialogs, t, user] - ); - - const handleMember = React.useCallback( - (ev: React.SyntheticEvent) => { - ev.preventDefault(); - dialogs.openModal({ - title: t("Change role to editor"), - content: ( - - ), - }); - }, - [dialogs, t, user] - ); - - const handleViewer = React.useCallback( - (ev: React.SyntheticEvent) => { - ev.preventDefault(); - dialogs.openModal({ - title: t("Change role to viewer"), - content: ( - - ), - }); - }, - [dialogs, t, user] - ); - const handleChangeName = React.useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); @@ -149,22 +102,16 @@ function UserMenu({ user }: Props) { {...menu} items={[ { - type: "button", - title: `${t("Change role to editor")}…`, - onClick: handleMember, - visible: can.demote && user.role !== "member", - }, - { - type: "button", - title: `${t("Change role to viewer")}…`, - onClick: handleViewer, - visible: can.demote && user.role !== "viewer", - }, - { - type: "button", - title: `${t("Change role to admin")}…`, - onClick: handlePromote, - visible: can.promote && user.role !== "admin", + type: "submenu", + title: t("Change role"), + visible: can.demote || can.promote, + items: [UserRole.Admin, UserRole.Member, UserRole.Viewer].map( + (role) => + actionToMenuItem( + updateUserRoleActionFactory(user.id, role), + context + ) + ), }, { type: "button", diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 01ac6845f..c6d3f3f01 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -70,7 +70,7 @@ function Members() { }; void fetchData(); - }, [query, sort, filter, role, page, direction, users, users.counts.all]); + }, [query, sort, filter, role, page, direction, users]); React.useEffect(() => { let filtered = users.orderedData; diff --git a/app/stores/UsersStore.ts b/app/stores/UsersStore.ts index 13a31843e..497ec3805 100644 --- a/app/stores/UsersStore.ts +++ b/app/stores/UsersStore.ts @@ -4,22 +4,13 @@ import deburr from "lodash/deburr"; import differenceWith from "lodash/differenceWith"; import filter from "lodash/filter"; import orderBy from "lodash/orderBy"; -import { observable, computed, action, runInAction } from "mobx"; -import { type JSONObject, UserRole } from "@shared/types"; +import { computed, action, runInAction } from "mobx"; +import { UserRole } from "@shared/types"; import User from "~/models/User"; import { client } from "~/utils/ApiClient"; import RootStore from "./RootStore"; import Store, { RPCAction } from "./base/Store"; -type UserCounts = { - active: number; - admins: number; - all: number; - invited: number; - suspended: number; - viewers: number; -}; - export default class UsersStore extends Store { actions = [ RPCAction.Info, @@ -30,16 +21,6 @@ export default class UsersStore extends Store { RPCAction.Count, ]; - @observable - counts: UserCounts = { - active: 0, - admins: 0, - all: 0, - invited: 0, - suspended: 0, - viewers: 0, - }; - constructor(rootStore: RootStore) { super(rootStore, User); } @@ -98,47 +79,18 @@ export default class UsersStore extends Store { } @action - promote = async (user: User) => { - try { - this.updateCounts(UserRole.Admin, user.role); - await this.actionOnUser("promote", user); - } catch (_e) { - this.updateCounts(user.role, UserRole.Admin); - } - }; - - @action - demote = async (user: User, to: UserRole) => { - try { - this.updateCounts(to, user.role); - await this.actionOnUser("demote", user, to); - } catch (_e) { - this.updateCounts(user.role, to); - } + updateRole = async (user: User, role: UserRole) => { + await this.actionOnUser("update_role", user, role); }; @action suspend = async (user: User) => { - try { - this.counts.suspended += 1; - this.counts.active -= 1; - await this.actionOnUser("suspend", user); - } catch (_e) { - this.counts.suspended -= 1; - this.counts.active += 1; - } + await this.actionOnUser("suspend", user); }; @action activate = async (user: User) => { - try { - this.counts.suspended -= 1; - this.counts.active += 1; - await this.actionOnUser("activate", user); - } catch (_e) { - this.counts.suspended += 1; - this.counts.active -= 1; - } + await this.actionOnUser("activate", user); }; @action @@ -155,8 +107,6 @@ export default class UsersStore extends Store { invariant(res?.data, "Data should be available"); runInAction(`invite`, () => { res.data.users.forEach(this.add); - this.counts.invited += res.data.sent.length; - this.counts.all += res.data.sent.length; }); return res.data; }; @@ -167,20 +117,6 @@ export default class UsersStore extends Store { id: user.id, }); - @action - fetchCounts = async ( - teamId: string - ): Promise<{ - counts: UserCounts; - }> => { - const res = await client.post(`/≈`, { - teamId, - }); - invariant(res?.data, "Data should be available"); - this.counts = res.data.counts; - return res.data; - }; - @action fetchDocumentUsers = async (params: { id: string; @@ -200,62 +136,6 @@ export default class UsersStore extends Store { } }; - @action - async delete(user: User, options: JSONObject = {}) { - await super.delete(user, options); - - if (!user.isSuspended && user.lastActiveAt) { - this.counts.active -= 1; - } - - if (user.isInvited) { - this.counts.invited -= 1; - } - - if (user.isAdmin) { - this.counts.admins -= 1; - } - - if (user.isSuspended) { - this.counts.suspended -= 1; - } - - if (user.isViewer) { - this.counts.viewers -= 1; - } - - this.counts.all -= 1; - } - - @action - updateCounts = (to: UserRole, from: UserRole) => { - if (to === UserRole.Admin) { - this.counts.admins += 1; - - if (from === UserRole.Viewer) { - this.counts.viewers -= 1; - } - } - - if (to === UserRole.Viewer) { - this.counts.viewers += 1; - - if (from === UserRole.Admin) { - this.counts.admins -= 1; - } - } - - if (to === UserRole.Member) { - if (from === UserRole.Viewer) { - this.counts.viewers -= 1; - } - - if (from === UserRole.Admin) { - this.counts.admins -= 1; - } - } - }; - notInDocument = (documentId: string, query = "") => { const document = this.rootStore.documents.get(documentId); const teamMembers = this.activeOrInvited; @@ -318,10 +198,10 @@ export default class UsersStore extends Store { return queriedUsers(users, query); }; - actionOnUser = async (action: string, user: User, to?: UserRole) => { + actionOnUser = async (action: string, user: User, role?: UserRole) => { const res = await client.post(`/users.${action}`, { id: user.id, - to, + role, }); invariant(res?.data, "Data should be available"); runInAction(`UsersStore#${action}`, () => { diff --git a/server/routes/api/users/users.test.ts b/server/routes/api/users/users.test.ts index f7f211782..ac36f4fa8 100644 --- a/server/routes/api/users/users.test.ts +++ b/server/routes/api/users/users.test.ts @@ -781,87 +781,3 @@ describe("#users.activate", () => { expect(body).toMatchSnapshot(); }); }); - -describe("#users.count", () => { - it("should count active users", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - const res = await server.post("/api/users.count", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.counts.all).toEqual(1); - expect(body.data.counts.admins).toEqual(0); - expect(body.data.counts.invited).toEqual(0); - expect(body.data.counts.suspended).toEqual(0); - expect(body.data.counts.active).toEqual(1); - }); - - it("should count admin users", async () => { - const team = await buildTeam(); - const user = await buildAdmin({ - teamId: team.id, - }); - const res = await server.post("/api/users.count", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.counts.all).toEqual(1); - expect(body.data.counts.admins).toEqual(1); - expect(body.data.counts.invited).toEqual(0); - expect(body.data.counts.suspended).toEqual(0); - expect(body.data.counts.active).toEqual(1); - }); - - it("should count suspended users", async () => { - const team = await buildTeam(); - const user = await buildUser({ teamId: team.id }); - await buildUser({ - teamId: team.id, - suspendedAt: new Date(), - }); - const res = await server.post("/api/users.count", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.counts.all).toEqual(2); - expect(body.data.counts.admins).toEqual(0); - expect(body.data.counts.invited).toEqual(0); - expect(body.data.counts.suspended).toEqual(1); - expect(body.data.counts.active).toEqual(1); - }); - - it("should count invited users", async () => { - const team = await buildTeam(); - const user = await buildUser({ - teamId: team.id, - lastActiveAt: null, - }); - const res = await server.post("/api/users.count", { - body: { - token: user.getJwtToken(), - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.counts.all).toEqual(1); - expect(body.data.counts.admins).toEqual(0); - expect(body.data.counts.invited).toEqual(1); - expect(body.data.counts.suspended).toEqual(0); - expect(body.data.counts.active).toEqual(0); - }); - - it("should require authentication", async () => { - const res = await server.post("/api/users.count"); - expect(res.status).toEqual(401); - }); -}); diff --git a/server/routes/api/users/users.ts b/server/routes/api/users/users.ts index cccb85d7d..dbff1ce3c 100644 --- a/server/routes/api/users/users.ts +++ b/server/routes/api/users/users.ts @@ -168,17 +168,6 @@ router.post( } ); -router.post("users.count", auth(), async (ctx: APIContext) => { - const { user } = ctx.state.auth; - const counts = await User.getCounts(user.teamId); - - ctx.body = { - data: { - counts, - }, - }; -}); - router.post( "users.info", auth(), diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b7d87d5ee..cd0fd8e8c 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -103,6 +103,9 @@ "Create a workspace": "Create a workspace", "Invite people": "Invite people", "Invite to workspace": "Invite to workspace", + "Promote to {{ role }}": "Promote to {{ role }}", + "Demote to {{ role }}": "Demote to {{ role }}", + "Update role": "Update role", "Delete user": "Delete user", "Collection": "Collection", "Debug": "Debug", @@ -312,11 +315,12 @@ "No results": "No results", "Previous page": "Previous page", "Next page": "Next page", - "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content": "Are you sure you want to make {{ userName }} a read-only viewer? They will not be able to edit any content", - "Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?", + "Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.", + "Editors can create, edit, and comment on documents.": "Editors can create, edit, and comment on documents.", + "Viewers can only view and comment on documents.": "Viewers can only view and comment on documents.", + "Are you sure you want to make {{ userName }} a {{ role }}?": "Are you sure you want to make {{ userName }} a {{ role }}?", "I understand, delete": "I understand, delete", "Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.": "Are you sure you want to permanently delete {{ userName }}? This operation is unrecoverable, consider suspending the user instead.", - "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.", "New name": "New name", "Name can't be empty": "Name can't be empty", @@ -453,13 +457,11 @@ "Contents": "Contents", "Headings you add to the document will appear here": "Headings you add to the document will appear here", "Table of contents": "Table of contents", - "Change role to admin": "Change role to admin", - "Change role to editor": "Change role to editor", - "Change role to viewer": "Change role to viewer", "Change name": "Change name", "Suspend user": "Suspend user", "An error occurred while sending the invite": "An error occurred while sending the invite", "User options": "User options", + "Change role": "Change role", "Resend invite": "Resend invite", "Revoke invite": "Revoke invite", "Activate user": "Activate user",