From c419c3ab63b55bc5c41fb31135ad2b704a82fe36 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 3 Jun 2026 07:32:11 -0400 Subject: [PATCH] Add admin interface to change user avatars (#12405) --- app/components/UserDialogs.tsx | 46 +++++++++++++++++++ app/hooks/useUserMenuActions.tsx | 23 ++++++++++ app/scenes/Settings/components/ImageInput.tsx | 21 +++++++-- shared/i18n/locales/en_US/translation.json | 8 ++-- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx index db6ec7fd07..a6ce379f31 100644 --- a/app/components/UserDialogs.tsx +++ b/app/components/UserDialogs.tsx @@ -1,13 +1,17 @@ +import { observer } from "mobx-react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import { UserRole } from "@shared/types"; import { UserValidation } from "@shared/validations"; import type User from "~/models/User"; +import Button from "~/components/Button"; import ConfirmationDialog from "~/components/ConfirmationDialog"; +import Flex from "~/components/Flex"; import Input from "~/components/Input"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; +import ImageInput from "~/scenes/Settings/components/ImageInput"; import { client } from "~/utils/ApiClient"; 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 ( + + + + + + {user.avatarUrl && ( + + )} + + + + ); +}); + export function UserChangeEmailDialog({ user, onSubmit }: Props) { const { t } = useTranslation(); const actor = useCurrentUser(); diff --git a/app/hooks/useUserMenuActions.tsx b/app/hooks/useUserMenuActions.tsx index bd1b633ffe..a976c6c21e 100644 --- a/app/hooks/useUserMenuActions.tsx +++ b/app/hooks/useUserMenuActions.tsx @@ -20,6 +20,7 @@ import { UserSuspendDialog, UserChangeNameDialog, UserChangeEmailDialog, + UserChangeAvatarDialog, } from "~/components/UserDialogs"; /** @@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) { const { t } = useTranslation(); const can = usePolicy(targetUser ?? ({} as User)); + const openAvatarDialog = React.useCallback(() => { + if (!targetUser) { + return; + } + dialogs.openModal({ + title: t("Change profile picture"), + content: ( + + ), + }); + }, [dialogs, t, targetUser]); + const openNameDialog = React.useCallback(() => { if (!targetUser) { return; @@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) { visible: can.demote || can.promote, children: roleChangeActions, }), + createAction({ + name: `${t("Change profile picture")}…`, + section: UserSection, + visible: can.update, + perform: openAvatarDialog, + }), createAction({ name: `${t("Change name")}…`, section: UserSection, @@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) { can.update, can.resendInvite, roleChangeActions, + openAvatarDialog, openNameDialog, openEmailDialog, resendInvitation, diff --git a/app/scenes/Settings/components/ImageInput.tsx b/app/scenes/Settings/components/ImageInput.tsx index e3bfa5b9f3..4d8d5d96c5 100644 --- a/app/scenes/Settings/components/ImageInput.tsx +++ b/app/scenes/Settings/components/ImageInput.tsx @@ -11,11 +11,24 @@ import type { Props as ImageUploadProps } from "./ImageUpload"; import ImageUpload from "./ImageUpload"; type Props = ImageUploadProps & { + /** The model whose avatar is displayed and updated by this input. */ model: IAvatar; + /** Alt text for the avatar image. */ 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(); return ( @@ -29,7 +42,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) { @@ -37,7 +50,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) { - {model.avatarUrl && ( + {model.avatarUrl && showRemoveOption && ( @@ -55,7 +68,7 @@ const ImageBox = styled(Flex)` ${avatarStyles}; position: relative; font-size: 14px; - border-radius: 8px; + border-radius: 50%; box-shadow: 0 0 0 1px ${s("backgroundSecondary")}; background: ${s("background")}; overflow: hidden; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index cc5585f8e1..891a2aa297 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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.", "New name": "New name", "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.", "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.", @@ -575,7 +579,6 @@ "Paste a link": "Paste a link", "Delete embed": "Delete embed", "Delete image": "Delete image", - "Profile picture": "Profile picture", "Create a new doc": "Create a new 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", @@ -680,6 +683,7 @@ "Embeds": "Embeds", "Configure which embed providers are available in the editor.": "Configure which embed providers are available in the editor.", "Install": "Install", + "Change profile picture": "Change profile picture", "Change name": "Change name", "Change email": "Change email", "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.", "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 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.", "Photo": "Photo", "Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",