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 && ( - { - onImpersonate(workspaceMember); - closeDropdown(dropdownId); - }} - /> - )} - { - onDelete(workspaceMember.id); - closeDropdown(dropdownId); - }} - /> - - - } - /> - ); -}; 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',