Allow admin to change member's name (#5233)
* feat: allow admins to change user names * fix: review
This commit is contained in:
@@ -15,6 +15,8 @@ type Props = {
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
/** Keep the submit button disabled */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
@@ -23,6 +25,7 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
@@ -50,7 +53,12 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">{children}</Text>
|
||||
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving || disabled}
|
||||
danger={danger}
|
||||
autoFocus
|
||||
>
|
||||
{isSaving && savingText ? savingText : submitText}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
@@ -106,3 +107,37 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string>(user.name);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await user.save({ name });
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Save")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
disabled={!name}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
name="name"
|
||||
label={t("New name")}
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserChangeToMemberDialog,
|
||||
UserChangeToViewerDialog,
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
} from "~/components/UserRoleDialogs";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -80,6 +81,20 @@ function UserMenu({ user }: Props) {
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleChangeName = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change name"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<UserChangeNameDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -154,6 +169,12 @@ function UserMenu({ user }: Props) {
|
||||
onClick: handlePromote,
|
||||
visible: can.promote && user.role !== "admin",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change name")}…`,
|
||||
onClick: handleChangeName,
|
||||
visible: can.update && user.role !== "admin",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Resend invite"),
|
||||
|
||||
@@ -28,6 +28,10 @@ allow(User, "update", User, (actor, user) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actor.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ exports[`#users.activate should activate a suspended user 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
@@ -87,7 +87,7 @@ exports[`#users.demote should demote an admin 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
@@ -126,7 +126,7 @@ exports[`#users.demote should demote an admin to member 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
@@ -165,7 +165,7 @@ exports[`#users.demote should demote an admin to viewer 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
@@ -222,7 +222,7 @@ exports[`#users.promote should promote a new admin 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
@@ -288,7 +288,7 @@ exports[`#users.suspend should suspend an user 1`] = `
|
||||
"readDetails": true,
|
||||
"resendInvite": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
"update": true,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { NotificationEventType, UserPreference } from "@shared/types";
|
||||
import BaseSchema from "../BaseSchema";
|
||||
|
||||
export const UsersNotificationsSubscribeSchema = z.object({
|
||||
body: z.object({
|
||||
@@ -20,3 +21,15 @@ export const UsersNotificationsUnsubscribeSchema = z.object({
|
||||
export type UsersNotificationsUnsubscribeReq = z.infer<
|
||||
typeof UsersNotificationsUnsubscribeSchema
|
||||
>;
|
||||
|
||||
export const UsersUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
preferences: z.record(z.nativeEnum(UserPreference), z.boolean()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UsersUpdateReq = z.infer<typeof UsersUpdateSchema>;
|
||||
|
||||
@@ -414,6 +414,39 @@ describe("#users.update", () => {
|
||||
expect(body.data.name).toEqual("New name");
|
||||
});
|
||||
|
||||
it("should allow admin to update other user's profile info", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
const res = await server.post("/api/users.update", {
|
||||
body: {
|
||||
id: user.id,
|
||||
token: admin.getJwtToken(),
|
||||
name: "New name",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("New name");
|
||||
expect(body.data.avatarUrl).toBe(user.avatarUrl);
|
||||
});
|
||||
|
||||
it("should disallow non-admin to update other user's profile info", async () => {
|
||||
const actor = await buildUser();
|
||||
const user = await buildUser({
|
||||
teamId: actor.teamId,
|
||||
});
|
||||
const res = await server.post("/api/users.update", {
|
||||
body: {
|
||||
id: user.id,
|
||||
token: actor.getJwtToken(),
|
||||
name: "New name",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should fail upon sending invalid user preference", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.update", {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import Router from "koa-router";
|
||||
import { has } from "lodash";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
@@ -30,8 +29,6 @@ import {
|
||||
assertPresent,
|
||||
assertArray,
|
||||
assertUuid,
|
||||
assertKeysIn,
|
||||
assertBoolean,
|
||||
} from "@server/validation";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
@@ -197,47 +194,56 @@ router.post("users.info", auth(), async (ctx: APIContext) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.update", auth(), transaction(), async (ctx: APIContext) => {
|
||||
const { auth, transaction } = ctx.state;
|
||||
const { user } = auth;
|
||||
const { name, avatarUrl, language, preferences } = ctx.request.body;
|
||||
if (name) {
|
||||
user.name = name;
|
||||
}
|
||||
if (avatarUrl) {
|
||||
user.avatarUrl = avatarUrl;
|
||||
}
|
||||
if (language) {
|
||||
user.language = language;
|
||||
}
|
||||
if (preferences) {
|
||||
assertKeysIn(preferences, UserPreference);
|
||||
router.post(
|
||||
"users.update",
|
||||
auth(),
|
||||
transaction(),
|
||||
validate(T.UsersUpdateSchema),
|
||||
async (ctx: APIContext<T.UsersUpdateReq>) => {
|
||||
const { auth, transaction } = ctx.state;
|
||||
const actor = auth.user;
|
||||
const { id, name, avatarUrl, language, preferences } = ctx.input.body;
|
||||
|
||||
for (const value of Object.values(UserPreference)) {
|
||||
if (has(preferences, value)) {
|
||||
assertBoolean(preferences[value]);
|
||||
user.setPreference(value, preferences[value]);
|
||||
let user: User | null = actor;
|
||||
if (id) {
|
||||
user = await User.findByPk(id);
|
||||
}
|
||||
authorize(actor, "update", user);
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
if (name) {
|
||||
user.name = name;
|
||||
}
|
||||
if (avatarUrl) {
|
||||
user.avatarUrl = avatarUrl;
|
||||
}
|
||||
if (language) {
|
||||
user.language = language;
|
||||
}
|
||||
if (preferences) {
|
||||
for (const key of Object.keys(preferences) as Array<UserPreference>) {
|
||||
user.setPreference(key, preferences[key] as boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
await user.save({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.update",
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
ip: ctx.request.ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await user.save({ transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.update",
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
ip: ctx.request.ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, {
|
||||
includeDetails: true,
|
||||
}),
|
||||
};
|
||||
});
|
||||
ctx.body = {
|
||||
data: presentUser(user, {
|
||||
includeDetails,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Admin specific
|
||||
router.post("users.promote", auth(), async (ctx: APIContext) => {
|
||||
|
||||
@@ -229,6 +229,9 @@
|
||||
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
|
||||
"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.",
|
||||
"Save": "Save",
|
||||
"New name": "New name",
|
||||
"Name can't be empty": "Name can't be empty",
|
||||
"Profile picture": "Profile picture",
|
||||
"Insert column after": "Insert column after",
|
||||
"Insert column before": "Insert column before",
|
||||
@@ -354,6 +357,7 @@
|
||||
"Change role to admin": "Change role to admin",
|
||||
"Change role to member": "Change role to member",
|
||||
"Change role to viewer": "Change role to viewer",
|
||||
"Change name": "Change name",
|
||||
"Suspend account": "Suspend account",
|
||||
"An error occurred while sending the invite": "An error occurred while sending the invite",
|
||||
"User options": "User options",
|
||||
@@ -388,7 +392,6 @@
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
|
||||
"Name": "Name",
|
||||
"Sort": "Sort",
|
||||
"Save": "Save",
|
||||
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
|
||||
"This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.",
|
||||
"Public document sharing": "Public document sharing",
|
||||
|
||||
Reference in New Issue
Block a user