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
@@ -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
/>
</>
);
};
@@ -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
}
}
`;
@@ -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}
/>
);
};
@@ -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}
/>
);
};