Introduce a workspace member page. (#16031)
- Refactored workspace member details into a focused Infos-only page. - Aligned the flow with SettingsProfile, including controlled name inputs, debounced saves, and stable instance IDs. - Added a dedicated member-picture upload flow. Introduced the MemberPictureUploader, connected to the uploadWorkspaceMemberProfilePicture mutation. - Backend now includes a workspace-member resolver/module for profile-picture uploads. The endpoint is permission-guarded, streams files through FileUploadService, and returns the signed file without modifying the member entity. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a workspace member detail page with picture/name management, integrates a new avatar upload mutation, and updates list routing; replaces the old profile picture uploader across profile and onboarding. > > - **Frontend** > - **Settings Members**: > - Add `pages/settings/members/SettingsWorkspaceMember` with `MemberInfosTab`, `MemberNameFields`, and `MemberEmailField` for viewing/editing member info. > - Update routes in `SettingsRoutes` and add `SettingsPath.WorkspaceMemberPage`. > - Update `SettingsWorkspaceMembers` to navigate to member detail on row click and simplify row actions (remove dropdown menu). > - **Avatar Upload**: > - Introduce `WorkspaceMemberPictureUploader` using `uploadWorkspaceMemberProfilePicture` mutation. > - Replace `ProfilePictureUploader` in `SettingsProfile` and `onboarding/CreateProfile`. > - **GraphQL (client)**: > - Add `uploadWorkspaceMemberProfilePicture` mutation types/hooks in `generated(-metadata)/graphql.ts`. > - **Backend** > - Add `UserWorkspaceResolver` with `uploadWorkspaceMemberProfilePicture` mutation guarded by `WorkspaceAuthGuard` and `SettingsPermissionGuard` (WORKSPACE_MEMBERS), using `FileUploadService`. > - Register resolver and `PermissionsModule` in `UserWorkspaceModule`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 359652f8c94d093a69469969874131e525d3dc6f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -172,8 +172,16 @@ const SettingsAgentTurnDetail = lazy(() =>
|
||||
);
|
||||
|
||||
const SettingsWorkspaceMembers = lazy(() =>
|
||||
import('~/pages/settings/SettingsWorkspaceMembers').then((module) => ({
|
||||
default: module.SettingsWorkspaceMembers,
|
||||
import('~/pages/settings/members/SettingsWorkspaceMembers').then(
|
||||
(module) => ({
|
||||
default: module.SettingsWorkspaceMembers,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const SettingsWorkspaceMember = lazy(() =>
|
||||
import('~/pages/settings/members/SettingsWorkspaceMember').then((module) => ({
|
||||
default: module.SettingsWorkspaceMember,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -458,6 +466,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
path={SettingsPath.WorkspaceMembersPage}
|
||||
element={<SettingsWorkspaceMembers />}
|
||||
/>
|
||||
<Route
|
||||
path={SettingsPath.WorkspaceMemberPage}
|
||||
element={<SettingsWorkspaceMember />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconDotsVertical, IconSpy, IconTrash } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { PermissionFlagType } from '~/generated/graphql';
|
||||
|
||||
type ManageMembersDropdownMenuProps = {
|
||||
dropdownId: string;
|
||||
workspaceMember: WorkspaceMember;
|
||||
onDelete: (workspaceMemberId: string) => void;
|
||||
onImpersonate: (workspaceMember: WorkspaceMember) => void;
|
||||
};
|
||||
|
||||
export const ManageMembersDropdownMenu = ({
|
||||
dropdownId,
|
||||
workspaceMember,
|
||||
onDelete,
|
||||
onImpersonate,
|
||||
}: ManageMembersDropdownMenuProps) => {
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
const isImpersonating = useRecoilValue(isImpersonatingState);
|
||||
const canImpersonate =
|
||||
useHasPermissionFlag(PermissionFlagType.IMPERSONATE) && !isImpersonating;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="right-start"
|
||||
clickableComponent={
|
||||
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
{canImpersonate && (
|
||||
<MenuItem
|
||||
LeftIcon={IconSpy}
|
||||
text={t`Impersonate`}
|
||||
onClick={() => {
|
||||
onImpersonate(workspaceMember);
|
||||
closeDropdown(dropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
accent="danger"
|
||||
LeftIcon={IconTrash}
|
||||
text={t`Delete`}
|
||||
onClick={() => {
|
||||
onDelete(workspaceMember.id);
|
||||
closeDropdown(dropdownId);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
|
||||
type MemberEmailFieldProps = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const MemberEmailField = ({ email }: MemberEmailFieldProps) => {
|
||||
return (
|
||||
<SettingsTextInput
|
||||
instanceId="workspace-member-email"
|
||||
value={email}
|
||||
disabled
|
||||
fullWidth
|
||||
type="email"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { MemberEmailField } from '@/settings/members/components/MemberEmailField';
|
||||
import { MemberNameFields } from '@/settings/members/components/MemberNameFields';
|
||||
import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/components/WorkspaceMemberPictureUploader';
|
||||
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
|
||||
type MemberInfosTabProps = {
|
||||
member: WorkspaceMember;
|
||||
onNameChange: (firstName: string, lastName: string) => void;
|
||||
onImpersonate?: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const StyledNameRow = styled.div`
|
||||
display: grid;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
`;
|
||||
|
||||
const StyledActionRow = styled.div`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
export const MemberInfosTab = ({
|
||||
member,
|
||||
onNameChange,
|
||||
onImpersonate,
|
||||
onDelete,
|
||||
}: MemberInfosTabProps) => {
|
||||
const [firstName, setFirstName] = useState(member.name.firstName);
|
||||
const [lastName, setLastName] = useState(member.name.lastName);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(
|
||||
member.avatarUrl || null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<H2Title title={t`Picture`} />
|
||||
<WorkspaceMemberPictureUploader
|
||||
workspaceMemberId={member.id}
|
||||
avatarUrl={avatarUrl}
|
||||
onAvatarUpdated={setAvatarUrl}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Name`}
|
||||
description={t`As it will be displayed in the workspace`}
|
||||
/>
|
||||
<StyledNameRow>
|
||||
<MemberNameFields
|
||||
memberId={member.id}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
onChange={(field, value) => {
|
||||
if (field === 'firstName') {
|
||||
setFirstName(value);
|
||||
onNameChange(value, lastName);
|
||||
} else {
|
||||
setLastName(value);
|
||||
onNameChange(firstName, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</StyledNameRow>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Email`}
|
||||
description={t`The email associated to this account`}
|
||||
/>
|
||||
<MemberEmailField email={member.userEmail} />
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Admin`}
|
||||
description={t`Perform administrative actions or permanently delete this user`}
|
||||
/>
|
||||
<StyledActionRow>
|
||||
{onImpersonate && (
|
||||
<Button
|
||||
title={t`Impersonate`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onImpersonate}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
accent="danger"
|
||||
title={t`Delete account`}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</StyledActionRow>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
type MemberNameFieldsProps = {
|
||||
memberId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
onChange: (field: 'firstName' | 'lastName', value: string) => void;
|
||||
};
|
||||
|
||||
export const MemberNameFields = ({
|
||||
memberId,
|
||||
firstName,
|
||||
lastName,
|
||||
onChange,
|
||||
}: MemberNameFieldsProps) => {
|
||||
const firstNameInstanceId = `${memberId}-first-name`;
|
||||
const lastNameInstanceId = `${memberId}-last-name`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTextInput
|
||||
instanceId={firstNameInstanceId}
|
||||
label={t`First Name`}
|
||||
value={firstName}
|
||||
placeholder="Tim"
|
||||
onChange={(value) => {
|
||||
onChange('firstName', value);
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={lastNameInstanceId}
|
||||
label={t`Last name`}
|
||||
value={lastName}
|
||||
placeholder="Cook"
|
||||
onChange={(value) => {
|
||||
onChange('lastName', value);
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE = gql`
|
||||
mutation UploadWorkspaceMemberProfilePicture($file: Upload!) {
|
||||
uploadWorkspaceMemberProfilePicture(file: $file) {
|
||||
path
|
||||
token
|
||||
}
|
||||
}
|
||||
`;
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
|
||||
import { ImageInput } from '@/ui/input/components/ImageInput';
|
||||
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
|
||||
import { useUploadProfilePictureMutation } from '~/generated-metadata/graphql';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
export const ProfilePictureUploader = () => {
|
||||
const [uploadPicture, { loading: isUploading }] =
|
||||
useUploadProfilePictureMutation();
|
||||
|
||||
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
|
||||
const [uploadController, setUploadController] =
|
||||
useState<AbortController | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
});
|
||||
|
||||
const { canEdit: canEditProfilePicture } =
|
||||
useCanEditProfileField('profilePicture');
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (isUndefinedOrNull(file) || !canEditProfilePicture) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setUploadController(controller);
|
||||
|
||||
try {
|
||||
if (!currentWorkspaceMember?.id) {
|
||||
throw new Error('User is not logged in');
|
||||
}
|
||||
const result = await uploadPicture({
|
||||
variables: {
|
||||
file,
|
||||
},
|
||||
context: {
|
||||
fetchOptions: {
|
||||
signal: controller.signal,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setUploadController(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
const signedFile = result?.data?.uploadProfilePicture;
|
||||
|
||||
if (!isDefined(signedFile)) {
|
||||
throw new Error('Avatar URL not found');
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: currentWorkspaceMember?.id,
|
||||
updateOneRecordInput: {
|
||||
avatarUrl: signedFile.path,
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentWorkspaceMember({
|
||||
...currentWorkspaceMember,
|
||||
avatarUrl: buildSignedPath(signedFile),
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
setErrorMessage('An error occurred while uploading the picture.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
if (!canEditProfilePicture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(uploadController)) {
|
||||
uploadController.abort();
|
||||
setUploadController(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!canEditProfilePicture) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currentWorkspaceMember?.id) {
|
||||
throw new Error('User is not logged in');
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: currentWorkspaceMember?.id,
|
||||
updateOneRecordInput: {
|
||||
avatarUrl: '',
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentWorkspaceMember({ ...currentWorkspaceMember, avatarUrl: null });
|
||||
} catch {
|
||||
setErrorMessage('An error occurred while removing the picture.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageInput
|
||||
picture={currentWorkspaceMember?.avatarUrl}
|
||||
onUpload={handleUpload}
|
||||
onRemove={handleRemove}
|
||||
onAbort={handleAbort}
|
||||
isUploading={isUploading}
|
||||
errorMessage={errorMessage}
|
||||
disabled={!canEditProfilePicture}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE } from '@/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture';
|
||||
import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ImageInput } from '@/ui/input/components/ImageInput';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
type WorkspaceMemberPictureUploaderProps = {
|
||||
workspaceMemberId: string;
|
||||
avatarUrl?: string | null;
|
||||
onAvatarUpdated?: (url: string | null) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const WorkspaceMemberPictureUploader = ({
|
||||
workspaceMemberId,
|
||||
avatarUrl,
|
||||
onAvatarUpdated,
|
||||
disabled = false,
|
||||
}: WorkspaceMemberPictureUploaderProps) => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [uploadController, setUploadController] =
|
||||
useState<AbortController | null>(null);
|
||||
|
||||
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
|
||||
const [uploadPicture] = useMutation(UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE);
|
||||
|
||||
const { updateOneRecord } = useUpdateOneRecord({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
});
|
||||
|
||||
const { canEdit: canEditProfilePicture } =
|
||||
useCanEditProfileField('profilePicture');
|
||||
|
||||
const isEditingSelf = currentWorkspaceMember?.id === workspaceMemberId;
|
||||
const canEdit = isEditingSelf ? canEditProfilePicture : !disabled;
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (isUndefinedOrNull(file) || !canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setUploadController(controller);
|
||||
setIsUploading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const { data } = await uploadPicture({
|
||||
variables: { file },
|
||||
context: {
|
||||
fetchOptions: {
|
||||
signal: controller.signal,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const signedFile = data?.uploadWorkspaceMemberProfilePicture;
|
||||
if (!isDefined(signedFile)) {
|
||||
throw new Error('Avatar upload failed');
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: workspaceMemberId,
|
||||
updateOneRecordInput: { avatarUrl: signedFile.path },
|
||||
});
|
||||
|
||||
const newAvatarUrl = buildSignedPath(signedFile);
|
||||
|
||||
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
|
||||
setCurrentWorkspaceMember({
|
||||
...currentWorkspaceMember,
|
||||
avatarUrl: newAvatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(onAvatarUpdated)) {
|
||||
onAvatarUpdated(newAvatarUrl);
|
||||
}
|
||||
|
||||
setUploadController(null);
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to upload picture';
|
||||
setErrorMessage('An error occurred while uploading the picture.');
|
||||
enqueueErrorSnackBar({ message });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await updateOneRecord({
|
||||
idToUpdate: workspaceMemberId,
|
||||
updateOneRecordInput: { avatarUrl: '' },
|
||||
});
|
||||
|
||||
if (isEditingSelf && isDefined(currentWorkspaceMember)) {
|
||||
setCurrentWorkspaceMember({
|
||||
...currentWorkspaceMember,
|
||||
avatarUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(onAvatarUpdated)) {
|
||||
onAvatarUpdated(null);
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to remove picture';
|
||||
setErrorMessage('An error occurred while removing the picture.');
|
||||
enqueueErrorSnackBar({ message });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
if (!canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(uploadController)) {
|
||||
uploadController.abort();
|
||||
setUploadController(null);
|
||||
}
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
const displayAvatarUrl =
|
||||
avatarUrl ?? (isEditingSelf ? currentWorkspaceMember?.avatarUrl : null);
|
||||
|
||||
return (
|
||||
<ImageInput
|
||||
picture={displayAvatarUrl}
|
||||
onUpload={handleUpload}
|
||||
onRemove={handleRemove}
|
||||
onAbort={handleAbort}
|
||||
isUploading={isUploading}
|
||||
errorMessage={errorMessage}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user