From 04b01170ed037691588bd6eb89bdde3f6704b2ef Mon Sep 17 00:00:00 2001
From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com>
Date: Fri, 28 Nov 2025 13:23:51 +0500
Subject: [PATCH] Introduce a workspace member page. (#16031)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
> [!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`.
>
> 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).
---------
Co-authored-by: Félix Malfait
---
.../src/generated-metadata/graphql.ts | 47 ++++
.../twenty-front/src/generated/graphql.ts | 6 +
.../modules/app/components/SettingsRoutes.tsx | 16 +-
.../members/ManageMembersDropdownMenu.tsx | 67 ------
.../members/components/MemberEmailField.tsx | 17 ++
.../members/components/MemberInfosTab.tsx | 110 ++++++++++
.../members/components/MemberNameFields.tsx | 44 ++++
.../uploadWorkspaceMemberProfilePicture.ts | 10 +
.../components/ProfilePictureUploader.tsx | 127 -----------
.../WorkspaceMemberPictureUploader.tsx | 167 +++++++++++++++
.../src/pages/onboarding/CreateProfile.tsx | 8 +-
.../src/pages/settings/SettingsProfile.tsx | 13 +-
.../SettingsWorkspaceMembers.stories.tsx | 2 +-
.../members/SettingsWorkspaceMember.tsx | 201 ++++++++++++++++++
.../SettingsWorkspaceMembers.tsx | 141 ++++--------
.../user-workspace/user-workspace.module.ts | 5 +-
.../user-workspace/user-workspace.resolver.ts | 47 ++++
.../twenty-shared/src/types/SettingsPath.ts | 1 +
18 files changed, 727 insertions(+), 302 deletions(-)
delete mode 100644 packages/twenty-front/src/modules/settings/members/ManageMembersDropdownMenu.tsx
create mode 100644 packages/twenty-front/src/modules/settings/members/components/MemberEmailField.tsx
create mode 100644 packages/twenty-front/src/modules/settings/members/components/MemberInfosTab.tsx
create mode 100644 packages/twenty-front/src/modules/settings/members/components/MemberNameFields.tsx
create mode 100644 packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts
delete mode 100644 packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
create mode 100644 packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx
create mode 100644 packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
rename packages/twenty-front/src/pages/settings/{ => members}/SettingsWorkspaceMembers.tsx (77%)
create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index dd1bec3615..2b581f9c53 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1933,6 +1933,7 @@ export type Mutation = {
uploadImage: SignedFile;
uploadProfilePicture: SignedFile;
uploadWorkspaceLogo: SignedFile;
+ uploadWorkspaceMemberProfilePicture: SignedFile;
upsertFieldPermissions: Array;
upsertObjectPermissions: Array;
upsertPermissionFlags: Array;
@@ -2825,6 +2826,11 @@ export type MutationUploadWorkspaceLogoArgs = {
};
+export type MutationUploadWorkspaceMemberProfilePictureArgs = {
+ file: Scalars['Upload'];
+};
+
+
export type MutationUpsertFieldPermissionsArgs = {
upsertFieldPermissionsInput: UpsertFieldPermissionsInput;
};
@@ -5780,6 +5786,13 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } };
+export type UploadWorkspaceMemberProfilePictureMutationVariables = Exact<{
+ file: Scalars['Upload'];
+}>;
+
+
+export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'SignedFile', path: string, token: string } };
+
export type UpdateUserEmailMutationVariables = Exact<{
newEmail: Scalars['String'];
verifyEmailRedirectPath?: InputMaybe;
@@ -11532,6 +11545,40 @@ export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.Mutat
export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType;
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult;
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions;
+export const UploadWorkspaceMemberProfilePictureDocument = gql`
+ mutation UploadWorkspaceMemberProfilePicture($file: Upload!) {
+ uploadWorkspaceMemberProfilePicture(file: $file) {
+ path
+ token
+ }
+}
+ `;
+export type UploadWorkspaceMemberProfilePictureMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUploadWorkspaceMemberProfilePictureMutation__
+ *
+ * To run a mutation, you first call `useUploadWorkspaceMemberProfilePictureMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUploadWorkspaceMemberProfilePictureMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [uploadWorkspaceMemberProfilePictureMutation, { data, loading, error }] = useUploadWorkspaceMemberProfilePictureMutation({
+ * variables: {
+ * file: // value for 'file'
+ * },
+ * });
+ */
+export function useUploadWorkspaceMemberProfilePictureMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UploadWorkspaceMemberProfilePictureDocument, options);
+ }
+export type UploadWorkspaceMemberProfilePictureMutationHookResult = ReturnType;
+export type UploadWorkspaceMemberProfilePictureMutationResult = Apollo.MutationResult;
+export type UploadWorkspaceMemberProfilePictureMutationOptions = Apollo.BaseMutationOptions;
export const UpdateUserEmailDocument = gql`
mutation UpdateUserEmail($newEmail: String!, $verifyEmailRedirectPath: String) {
updateUserEmail(
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index 66eac9fb87..faeed3cd23 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -1900,6 +1900,7 @@ export type Mutation = {
uploadImage: SignedFile;
uploadProfilePicture: SignedFile;
uploadWorkspaceLogo: SignedFile;
+ uploadWorkspaceMemberProfilePicture: SignedFile;
upsertFieldPermissions: Array;
upsertObjectPermissions: Array;
upsertPermissionFlags: Array;
@@ -2746,6 +2747,11 @@ export type MutationUploadWorkspaceLogoArgs = {
};
+export type MutationUploadWorkspaceMemberProfilePictureArgs = {
+ file: Scalars['Upload'];
+};
+
+
export type MutationUpsertFieldPermissionsArgs = {
upsertFieldPermissionsInput: UpsertFieldPermissionsInput;
};
diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
index f6cdef944c..5725423a87 100644
--- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
+++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx
@@ -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={}
/>
+ }
+ />
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 (
-
- }
- dropdownComponents={
-
-
- {canImpersonate && (
-
-
- }
- />
- );
-};
diff --git a/packages/twenty-front/src/modules/settings/members/components/MemberEmailField.tsx b/packages/twenty-front/src/modules/settings/members/components/MemberEmailField.tsx
new file mode 100644
index 0000000000..e59e9bc667
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/components/MemberEmailField.tsx
@@ -0,0 +1,17 @@
+import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
+
+type MemberEmailFieldProps = {
+ email: string;
+};
+
+export const MemberEmailField = ({ email }: MemberEmailFieldProps) => {
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/members/components/MemberInfosTab.tsx b/packages/twenty-front/src/modules/settings/members/components/MemberInfosTab.tsx
new file mode 100644
index 0000000000..2962285aa0
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/components/MemberInfosTab.tsx
@@ -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(
+ member.avatarUrl || null,
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {
+ if (field === 'firstName') {
+ setFirstName(value);
+ onNameChange(value, lastName);
+ } else {
+ setLastName(value);
+ onNameChange(firstName, value);
+ }
+ }}
+ />
+
+
+
+
+
+
+
+
+ {onImpersonate && (
+
+ )}
+
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/members/components/MemberNameFields.tsx b/packages/twenty-front/src/modules/settings/members/components/MemberNameFields.tsx
new file mode 100644
index 0000000000..7db3cea717
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/components/MemberNameFields.tsx
@@ -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 (
+ <>
+ {
+ onChange('firstName', value);
+ }}
+ fullWidth
+ />
+ {
+ onChange('lastName', value);
+ }}
+ fullWidth
+ />
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts
new file mode 100644
index 0000000000..44f992bc78
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts
@@ -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
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx b/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
deleted file mode 100644
index df5ef7181d..0000000000
--- a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
+++ /dev/null
@@ -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(null);
- const [errorMessage, setErrorMessage] = useState(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 (
-
- );
-};
diff --git a/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx
new file mode 100644
index 0000000000..2543f4df1c
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx
@@ -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(null);
+ const [uploadController, setUploadController] =
+ useState(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 (
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx
index b579697805..df1243f568 100644
--- a/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx
+++ b/packages/twenty-front/src/pages/onboarding/CreateProfile.tsx
@@ -13,7 +13,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
-import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
+import { WorkspaceMemberPictureUploader } from '@/settings/workspace-member/components/WorkspaceMemberPictureUploader';
import { PageFocusId } from '@/types/PageFocusId';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
@@ -176,7 +176,11 @@ export const CreateProfile = () => {
-
+ {currentWorkspaceMember?.id && (
+
+ )}
{
const { t } = useLingui();
+ const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { currentUserWorkspaceTwoFactorAuthenticationMethods } =
useCurrentUserWorkspaceTwoFactorAuthentication();
@@ -27,6 +30,10 @@ export const SettingsProfile = () => {
const { canChangePassword } = useCanChangePassword();
+ if (!currentWorkspaceMember?.id) {
+ return null;
+ }
+
return (
{
= {
title: 'Pages/Settings/SettingsWorkspaceMembers',
diff --git a/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
new file mode 100644
index 0000000000..3540fd4f26
--- /dev/null
+++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMember.tsx
@@ -0,0 +1,201 @@
+import { useParams } from 'react-router-dom';
+import { useDebouncedCallback } from 'use-debounce';
+
+import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
+import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
+import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
+import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
+import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
+import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
+import { useModal } from '@/ui/layout/modal/hooks/useModal';
+import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
+import { t } from '@lingui/core/macro';
+import { useRecoilValue } from 'recoil';
+import { SettingsPath } from 'twenty-shared/types';
+import { getSettingsPath } from 'twenty-shared/utils';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
+
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { isImpersonatingState } from '@/auth/states/isImpersonatingState';
+import { MemberInfosTab } from '@/settings/members/components/MemberInfosTab';
+import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
+import {
+ useDeleteUserWorkspaceMutation,
+ useImpersonateMutation,
+} from '~/generated-metadata/graphql';
+import { PermissionFlagType } from '~/generated/graphql';
+
+// const SETTINGS_WORKSPACE_MEMBER_TABS = {
+// COMPONENT_INSTANCE_ID: 'settings-workspace-member-tabs',
+// TABS_IDS: {
+// INFOS: 'infos',
+// },
+// };
+
+const DELETE_MEMBER_MODAL_ID = 'workspace-member-delete-modal';
+
+export const SettingsWorkspaceMember = () => {
+ const { workspaceMemberId = '' } = useParams();
+ const navigateSettings = useNavigateSettings();
+ const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
+ const { openModal, closeModal } = useModal();
+ const currentWorkspace = useRecoilValue(currentWorkspaceState);
+ const { executeImpersonationAuth } = useImpersonationAuth();
+ const [impersonate] = useImpersonateMutation();
+ const isImpersonating = useRecoilValue(isImpersonatingState);
+ const canImpersonate =
+ useHasPermissionFlag(PermissionFlagType.IMPERSONATE) && !isImpersonating;
+
+ const { record: member, loading } = useFindOneRecord({
+ objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
+ objectRecordId: workspaceMemberId,
+ recordGqlFields: {
+ id: true,
+ userId: true,
+ name: { firstName: true, lastName: true },
+ avatarUrl: true,
+ userEmail: true,
+ },
+ });
+
+ // const tabListComponentId = `${SETTINGS_WORKSPACE_MEMBER_TABS.COMPONENT_INSTANCE_ID}-${workspaceMemberId}`;
+ // const activeTabId = useRecoilComponentValue(
+ // activeTabIdComponentState,
+ // tabListComponentId,
+ // );
+
+ const { updateOneRecord } = useUpdateOneRecord({
+ objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
+ });
+
+ const [deleteUserFromWorkspace, { loading: isDeleting }] =
+ useDeleteUserWorkspaceMutation();
+
+ const debouncedUpdateName = useDebouncedCallback(
+ async (firstName: string, lastName: string) => {
+ if (
+ !member?.id ||
+ firstName.trim().length < 1 ||
+ lastName.trim().length < 1
+ ) {
+ return;
+ }
+ try {
+ await updateOneRecord({
+ idToUpdate: member.id,
+ updateOneRecordInput: {
+ name: { firstName, lastName },
+ },
+ });
+ } catch (error) {
+ enqueueErrorSnackBar({
+ message:
+ error instanceof Error
+ ? error.message
+ : t`Error while saving the name`,
+ });
+ }
+ },
+ 400,
+ );
+
+ const handleDeleteMember = async () => {
+ if (!member?.id) return;
+ try {
+ await deleteUserFromWorkspace({
+ variables: { workspaceMemberIdToDelete: member.id },
+ });
+ enqueueSuccessSnackBar({ message: t`Member removed from workspace` });
+ closeModal(DELETE_MEMBER_MODAL_ID);
+ navigateSettings(SettingsPath.WorkspaceMembersPage);
+ } catch (error) {
+ enqueueErrorSnackBar({
+ message:
+ error instanceof Error
+ ? error.message
+ : t`Unable to delete member right now`,
+ });
+ }
+ };
+
+ const handleImpersonate = async () => {
+ if (!member?.userId || !currentWorkspace?.id) {
+ enqueueErrorSnackBar({
+ message: t`Cannot impersonate selected user`,
+ options: { duration: 2000 },
+ });
+ return;
+ }
+
+ await impersonate({
+ variables: {
+ userId: member.userId,
+ workspaceId: currentWorkspace.id,
+ },
+ onCompleted: async (data) => {
+ const { loginToken } = data.impersonate;
+ await executeImpersonationAuth(loginToken.token);
+ },
+ onError: () => {
+ enqueueErrorSnackBar({
+ message: t`Cannot impersonate selected user`,
+ options: { duration: 2000 },
+ });
+ },
+ });
+ };
+
+ if (loading || !member) return null;
+
+ return (
+
+
+ {/* */}
+
+ {/* {activeTabId === SETTINGS_WORKSPACE_MEMBER_TABS.TABS_IDS.INFOS && ( */}
+ openModal(DELETE_MEMBER_MODAL_ID)}
+ />
+ {/* )} */}
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
similarity index 77%
rename from packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
rename to packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
index b61ed4177f..2954880a9b 100644
--- a/packages/twenty-front/src/pages/settings/SettingsWorkspaceMembers.tsx
+++ b/packages/twenty-front/src/pages/settings/members/SettingsWorkspaceMembers.tsx
@@ -11,13 +11,9 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
-import { useImpersonationAuth } from '@/settings/admin-panel/hooks/useImpersonationAuth';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
-import { ManageMembersDropdownMenu } from '@/settings/members/ManageMembersDropdownMenu';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
-import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
-import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -36,6 +32,7 @@ import {
AppTooltip,
Avatar,
H2Title,
+ IconChevronRight,
IconMail,
IconReload,
IconSearch,
@@ -45,21 +42,15 @@ import {
} from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
-import {
- useDeleteUserWorkspaceMutation,
- useGetWorkspaceInvitationsQuery,
- useImpersonateMutation,
-} from '~/generated-metadata/graphql';
+import { useGetWorkspaceInvitationsQuery } from '~/generated-metadata/graphql';
+import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
-import { TableCell } from '../../modules/ui/layout/table/components/TableCell';
-import { TableRow } from '../../modules/ui/layout/table/components/TableRow';
-import { useDeleteWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
-import { useResendWorkspaceInvitation } from '../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
-import { workspaceInvitationsState } from '../../modules/workspace-invitation/states/workspaceInvitationsStates';
-
-export const WORKSPACE_MEMBER_DELETION_MODAL_ID =
- 'workspace-member-deletion-modal';
+import { TableCell } from '../../../modules/ui/layout/table/components/TableCell';
+import { TableRow } from '../../../modules/ui/layout/table/components/TableRow';
+import { useDeleteWorkspaceInvitation } from '../../../modules/workspace-invitation/hooks/useDeleteWorkspaceInvitation';
+import { useResendWorkspaceInvitation } from '../../../modules/workspace-invitation/hooks/useResendWorkspaceInvitation';
+import { workspaceInvitationsState } from '../../../modules/workspace-invitation/states/workspaceInvitationsStates';
const StyledButtonContainer = styled.div`
align-items: center;
@@ -101,6 +92,18 @@ const StyledTableRows = styled.div`
padding-top: ${({ theme }) => theme.spacing(2)};
`;
+const StyledClickableTableRow = styled(TableRow)`
+ cursor: pointer;
+`;
+
+const StyledChevronWrapper = styled.div`
+ align-items: center;
+ color: ${({ theme }) => theme.font.color.secondary};
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+`;
+
const StyledNoMembers = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
@@ -109,13 +112,10 @@ export const SettingsWorkspaceMembers = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const theme = useTheme();
- const [workspaceMemberToDelete, setWorkspaceMemberToDelete] = useState<
- string | undefined
- >();
+ const navigateSettings = useNavigateSettings();
const [isFetchingMore, setIsFetchingMore] = useState(false);
- const [impersonate] = useImpersonateMutation();
- const { executeImpersonationAuth } = useImpersonationAuth();
const [searchFilter, setSearchFilter] = useState('');
+ const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [debouncedSearchFilter] = useDebounce(searchFilter, 300);
@@ -142,7 +142,6 @@ export const SettingsWorkspaceMembers = () => {
fetchMoreRecords,
hasNextPage,
loading,
- refetch: refetchWorkspaceMembers,
} = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
filter: searchServerFilter,
@@ -150,48 +149,8 @@ export const SettingsWorkspaceMembers = () => {
const { resendInvitation } = useResendWorkspaceInvitation();
const { deleteWorkspaceInvitation } = useDeleteWorkspaceInvitation();
- const [deleteUserFromWorkspace] = useDeleteUserWorkspaceMutation();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
- const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
-
- const handleRemoveWorkspaceMember = async (workspaceMemberId: string) => {
- await deleteUserFromWorkspace?.({
- variables: {
- workspaceMemberIdToDelete: workspaceMemberId,
- },
- });
- setWorkspaceMemberToDelete(undefined);
- refetchWorkspaceMembers();
- };
-
- const handleImpersonate = async (targetWorkspaceMember: WorkspaceMember) => {
- if (!targetWorkspaceMember.userId || !currentWorkspace?.id) {
- enqueueErrorSnackBar({
- message: t`Cannot impersonate selected user`,
- options: { duration: 2000 },
- });
- return;
- }
-
- await impersonate({
- variables: {
- userId: targetWorkspaceMember.userId,
- workspaceId: currentWorkspace.id,
- },
- onCompleted: async (data) => {
- const { loginToken } = data.impersonate;
- await executeImpersonationAuth(loginToken.token);
- return;
- },
- onError: () => {
- enqueueErrorSnackBar({
- message: t`Cannot impersonate selected user`,
- options: { duration: 2000 },
- });
- },
- });
- };
const workspaceInvitations = useRecoilValue(workspaceInvitationsState);
const setWorkspaceInvitations = useSetRecoilState(workspaceInvitationsState);
@@ -279,8 +238,6 @@ export const SettingsWorkspaceMembers = () => {
});
}, [workspaceMembers, searchFilter]);
- const { openModal } = useModal();
-
return (
{
Name
@@ -411,10 +368,18 @@ export const SettingsWorkspaceMembers = () => {
{optimizedWorkspaceMembers.length > 0 ? (
optimizedWorkspaceMembers.map((workspaceMember) => (
- {
+ if (currentWorkspaceMember?.id === workspaceMember.id) {
+ return;
+ }
+ navigateSettings(SettingsPath.WorkspaceMemberPage, {
+ workspaceMemberId: workspaceMember.id,
+ });
+ }}
>
@@ -448,21 +413,13 @@ export const SettingsWorkspaceMembers = () => {
- {currentWorkspaceMember?.id !== workspaceMember.id && (
-
- {
- setWorkspaceMemberToDelete(id);
- openModal(WORKSPACE_MEMBER_DELETION_MODAL_ID);
- }}
- />
-
- )}
+
+ {currentWorkspaceMember?.id !== workspaceMember.id && (
+
+ )}
+
-
+
))
) : (
@@ -487,22 +444,6 @@ export const SettingsWorkspaceMembers = () => {
-
- This action cannot be undone. This will permanently remove this
- member from this workspace and remove them from all their
- assignments.
-
- }
- onConfirmClick={() =>
- workspaceMemberToDelete &&
- handleRemoveWorkspaceMember(workspaceMemberToDelete)
- }
- confirmButtonText={t`Remove member`}
- />
);
};
diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts
index 47aecbc226..1224ae1fa4 100644
--- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts
@@ -11,12 +11,14 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
+import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
+import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@@ -44,12 +46,13 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
FileUploadModule,
FileModule,
TokenModule,
+ PermissionsModule,
OnboardingModule,
],
services: [UserWorkspaceService],
}),
],
exports: [UserWorkspaceService],
- providers: [UserWorkspaceService],
+ providers: [UserWorkspaceService, UserWorkspaceResolver],
})
export class UserWorkspaceModule {}
diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts
new file mode 100644
index 0000000000..7712478feb
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts
@@ -0,0 +1,47 @@
+import { UseGuards } from '@nestjs/common';
+import { Args, Mutation, Resolver } from '@nestjs/graphql';
+
+import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
+
+import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
+
+import type { FileUpload } from 'graphql-upload/processRequest.mjs';
+
+import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto';
+import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
+import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
+import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
+import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
+import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
+import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
+import { streamToBuffer } from 'src/utils/stream-to-buffer';
+
+@Resolver()
+export class UserWorkspaceResolver {
+ constructor(private readonly fileUploadService: FileUploadService) {}
+
+ @Mutation(() => SignedFileDTO)
+ @UseGuards(
+ WorkspaceAuthGuard,
+ SettingsPermissionGuard(PermissionFlagType.WORKSPACE_MEMBERS),
+ )
+ async uploadWorkspaceMemberProfilePicture(
+ @AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
+ @Args({ name: 'file', type: () => GraphQLUpload })
+ { createReadStream, filename, mimetype }: FileUpload,
+ ): Promise {
+ const stream = createReadStream();
+ const buffer = await streamToBuffer(stream);
+ const fileFolder = FileFolder.ProfilePicture;
+
+ const { files } = await this.fileUploadService.uploadImage({
+ file: buffer,
+ filename,
+ mimeType: mimetype,
+ fileFolder,
+ workspaceId,
+ });
+
+ return files[0];
+ }
+}
diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts
index 31bee589e8..fbdc70aba7 100644
--- a/packages/twenty-shared/src/types/SettingsPath.ts
+++ b/packages/twenty-shared/src/types/SettingsPath.ts
@@ -18,6 +18,7 @@ export enum SettingsPath {
ObjectFieldEdit = 'objects/:objectNamePlural/:fieldName',
NewObject = 'objects/new',
WorkspaceMembersPage = 'members',
+ WorkspaceMemberPage = 'members/:workspaceMemberId',
Workspace = 'general',
Domains = 'domains',
Domain = 'domains/domain',