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:
+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