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:
Abdullah.
2025-11-28 13:23:51 +05:00
committed by GitHub
parent e53e0d266d
commit 04b01170ed
18 changed files with 727 additions and 302 deletions
@@ -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}
/>
);
};