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