Add admin interface to change user avatars (#12405)

This commit is contained in:
Tom Moor
2026-06-03 07:32:11 -04:00
committed by GitHub
parent 8464d99589
commit c419c3ab63
4 changed files with 91 additions and 7 deletions
+46
View File
@@ -1,13 +1,17 @@
import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { UserRole } from "@shared/types"; import { UserRole } from "@shared/types";
import { UserValidation } from "@shared/validations"; import { UserValidation } from "@shared/validations";
import type User from "~/models/User"; import type User from "~/models/User";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog"; import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input"; import Input from "~/components/Input";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import ImageInput from "~/scenes/Settings/components/ImageInput";
import { client } from "~/utils/ApiClient"; import { client } from "~/utils/ApiClient";
import Text from "./Text"; import Text from "./Text";
@@ -142,6 +146,48 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
); );
} }
export const UserChangeAvatarDialog = observer(function UserChangeAvatarDialog({
user,
onSubmit,
}: Props) {
const { t } = useTranslation();
const handleAvatarChange = async (avatarUrl: string | null) => {
try {
await user.save({ avatarUrl });
toast.success(t("Profile picture updated"));
} catch (err) {
toast.error(err.message);
}
};
const handleAvatarError = (error: string | null | undefined) => {
toast.error(error || t("Unable to upload new profile picture"));
};
return (
<Flex column gap={16}>
<Flex justify="center">
<ImageInput
alt={t("Profile picture")}
onSuccess={handleAvatarChange}
onError={handleAvatarError}
model={user}
showRemoveOption={false}
/>
</Flex>
<Flex justify="flex-end" gap={8}>
{user.avatarUrl && (
<Button onClick={() => handleAvatarChange(null)} neutral>
{t("Remove")}
</Button>
)}
<Button onClick={onSubmit}>{t("Done")}</Button>
</Flex>
</Flex>
);
});
export function UserChangeEmailDialog({ user, onSubmit }: Props) { export function UserChangeEmailDialog({ user, onSubmit }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const actor = useCurrentUser(); const actor = useCurrentUser();
+23
View File
@@ -20,6 +20,7 @@ import {
UserSuspendDialog, UserSuspendDialog,
UserChangeNameDialog, UserChangeNameDialog,
UserChangeEmailDialog, UserChangeEmailDialog,
UserChangeAvatarDialog,
} from "~/components/UserDialogs"; } from "~/components/UserDialogs";
/** /**
@@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) {
const { t } = useTranslation(); const { t } = useTranslation();
const can = usePolicy(targetUser ?? ({} as User)); const can = usePolicy(targetUser ?? ({} as User));
const openAvatarDialog = React.useCallback(() => {
if (!targetUser) {
return;
}
dialogs.openModal({
title: t("Change profile picture"),
content: (
<UserChangeAvatarDialog
user={targetUser}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [dialogs, t, targetUser]);
const openNameDialog = React.useCallback(() => { const openNameDialog = React.useCallback(() => {
if (!targetUser) { if (!targetUser) {
return; return;
@@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) {
visible: can.demote || can.promote, visible: can.demote || can.promote,
children: roleChangeActions, children: roleChangeActions,
}), }),
createAction({
name: `${t("Change profile picture")}`,
section: UserSection,
visible: can.update,
perform: openAvatarDialog,
}),
createAction({ createAction({
name: `${t("Change name")}`, name: `${t("Change name")}`,
section: UserSection, section: UserSection,
@@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) {
can.update, can.update,
can.resendInvite, can.resendInvite,
roleChangeActions, roleChangeActions,
openAvatarDialog,
openNameDialog, openNameDialog,
openEmailDialog, openEmailDialog,
resendInvitation, resendInvitation,
+17 -4
View File
@@ -11,11 +11,24 @@ import type { Props as ImageUploadProps } from "./ImageUpload";
import ImageUpload from "./ImageUpload"; import ImageUpload from "./ImageUpload";
type Props = ImageUploadProps & { type Props = ImageUploadProps & {
/** The model whose avatar is displayed and updated by this input. */
model: IAvatar; model: IAvatar;
/** Alt text for the avatar image. */
alt: string; alt: string;
/**
* Whether to render the inline "Remove" button when the model has an
* existing avatar. Defaults to true.
*/
showRemoveOption?: boolean;
}; };
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) { export default function ImageInput({
model,
onSuccess,
alt,
showRemoveOption = true,
...rest
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -29,7 +42,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
<Avatar <Avatar
model={model} model={model}
size={AvatarSize.Upload} size={AvatarSize.Upload}
variant={AvatarVariant.Square} variant={AvatarVariant.Round}
alt={alt} alt={alt}
/> />
<Flex auto align="center" justify="center" className="upload"> <Flex auto align="center" justify="center" className="upload">
@@ -37,7 +50,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
</Flex> </Flex>
</ImageUpload> </ImageUpload>
</ImageBox> </ImageBox>
{model.avatarUrl && ( {model.avatarUrl && showRemoveOption && (
<Button onClick={() => onSuccess(null)} neutral> <Button onClick={() => onSuccess(null)} neutral>
{t("Remove")} {t("Remove")}
</Button> </Button>
@@ -55,7 +68,7 @@ const ImageBox = styled(Flex)`
${avatarStyles}; ${avatarStyles};
position: relative; position: relative;
font-size: 14px; font-size: 14px;
border-radius: 8px; border-radius: 50%;
box-shadow: 0 0 0 1px ${s("backgroundSecondary")}; box-shadow: 0 0 0 1px ${s("backgroundSecondary")};
background: ${s("background")}; background: ${s("background")};
overflow: hidden; overflow: hidden;
+5 -3
View File
@@ -546,6 +546,10 @@
"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.", "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", "New name": "New name",
"Name can't be empty": "Name can't be empty", "Name can't be empty": "Name can't be empty",
"Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",
"Profile picture": "Profile picture",
"Done": "Done",
"Check your email to verify the new address.": "Check your email to verify the new address.", "Check your email to verify the new address.": "Check your email to verify the new address.",
"The email will be changed once verified.": "The email will be changed once verified.", "The email will be changed once verified.": "The email will be changed once verified.",
"You will receive an email to verify your new address. It must be unique in the workspace.": "You will receive an email to verify your new address. It must be unique in the workspace.", "You will receive an email to verify your new address. It must be unique in the workspace.": "You will receive an email to verify your new address. It must be unique in the workspace.",
@@ -575,7 +579,6 @@
"Paste a link": "Paste a link", "Paste a link": "Paste a link",
"Delete embed": "Delete embed", "Delete embed": "Delete embed",
"Delete image": "Delete image", "Delete image": "Delete image",
"Profile picture": "Profile picture",
"Create a new doc": "Create a new doc", "Create a new doc": "Create a new doc",
"Create a nested doc": "Create a nested doc", "Create a nested doc": "Create a nested doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document", "{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
@@ -680,6 +683,7 @@
"Embeds": "Embeds", "Embeds": "Embeds",
"Configure which embed providers are available in the editor.": "Configure which embed providers are available in the editor.", "Configure which embed providers are available in the editor.": "Configure which embed providers are available in the editor.",
"Install": "Install", "Install": "Install",
"Change profile picture": "Change profile picture",
"Change name": "Change name", "Change name": "Change name",
"Change email": "Change email", "Change email": "Change email",
"Suspend user": "Suspend user", "Suspend user": "Suspend user",
@@ -1361,8 +1365,6 @@
"Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.", "Choose how unread notifications are indicated on the app icon.": "Choose how unread notifications are indicated on the app icon.",
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable", "You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
"Profile saved": "Profile saved", "Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",
"Manage how you appear to other members of the workspace.": "Manage how you appear to other members of the workspace.", "Manage how you appear to other members of the workspace.": "Manage how you appear to other members of the workspace.",
"Photo": "Photo", "Photo": "Photo",
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.", "Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",