Improve user role management on members (#6775)

This commit is contained in:
Tom Moor
2024-04-09 20:02:40 -06:00
committed by GitHub
parent b458bb3af9
commit 9b45feb9ee
8 changed files with 104 additions and 345 deletions

View File

@@ -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: (
<UserChangeRoleDialog
user={user}
role={role}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
name: ({ t }) => `${t("Delete user")}`,

View File

@@ -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 (
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}`}>
{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,
}
)}
.
</ConfirmationDialog>
);
}
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 (
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}`}>
{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}
</ConfirmationDialog>
);
}
@@ -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 (
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}`}>
{t(
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
{
userName: user.name,
}
)}
</ConfirmationDialog>
);
}
export function UserSuspendDialog({ user, onSubmit }: Props) {
const { t } = useTranslation();
const { users } = useStores();

View File

@@ -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: (
<UserChangeToAdminDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[dialogs, t, user]
);
const handleMember = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change role to editor"),
content: (
<UserChangeToMemberDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[dialogs, t, user]
);
const handleViewer = React.useCallback(
(ev: React.SyntheticEvent) => {
ev.preventDefault();
dialogs.openModal({
title: t("Change role to viewer"),
content: (
<UserChangeToViewerDialog
user={user}
onSubmit={dialogs.closeAllModals}
/>
),
});
},
[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",

View File

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

View File

@@ -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<User> {
actions = [
RPCAction.Info,
@@ -30,16 +21,6 @@ export default class UsersStore extends Store<User> {
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<User> {
}
@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<User> {
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<User> {
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<User> {
}
};
@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<User> {
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}`, () => {

View File

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

View File

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

View File

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