Improve user role management on members (#6775)
This commit is contained in:
@@ -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")}…`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`, () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user