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