From 0cab2b49fcae9975591450c43185b9ce222b6828 Mon Sep 17 00:00:00 2001 From: "Abdullah." <125115953+mabdullahabaid@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:00:02 +0500 Subject: [PATCH] (breaking change) Allow users with a single workspace to update their email. (#15736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Users with a single workspace are allowed to update their email across `core.user` and `workspace_xyz.workspaceMember`. - The latter happens asynchronously (built it like this for non-blocking with multiple workspaces), but since we restrict the email update functionality to a single user, we can also update the email in workspaceMember synchronously - I left asynchronous there to receive feedback on whether we should move to synchronous or not. - Merged main and resolved conflicts to ensure we use the `SettingsPermissionGuard` and the updated `workspace.service.ts` code. One edge-case that I was trying to communicate on Discord: Say that an admin is a member of multiple workspaces. Therefore, they can allow roles with PROFILE_INFORMATION permission to update their email.

image

However, since the admin is part of multiple workspaces, he/she cannot even update own email - the field stays disabled, leading to some confusion.

image

However, the workspace can have another member with admin role or some other role that has PROFILE_INFORMATION permission flag. That user will be and should be allowed to update email, so we cannot hide `email` from dropdown options.

image

The behavior is fine imo, just a little confusing for members with more than one workspace. I have also tested the flow by signing up to YC workspace with my org google account (twenty.com), then changing email to my personal address. - After changing, I need to login using Google with my personal account to access YC workspace again. - If I login using Google with org google account (twenty.com), a new user account is created. This behavior is consistent with Notion and Linear. Finally, as for the verification of email, the user is asked to verify email while they're logged in, but just in case they logout without verifying, the next login would force them to verify their email in the email/password flow. However, for Social/SSO, they must verify before they logout or else they'd have to contact support for assistance. I have not looked into how to show verification screen while logging in via Social/SSO yet, but if that's something critical for completeness here, I shall revisit it. --------- Co-authored-by: Félix Malfait --- .../send-email-verification-link.email.tsx | 18 +- .../src/generated-metadata/graphql.ts | 326 ++++++++++-------- .../twenty-front/src/generated/graphql.ts | 56 +-- .../auth/components/VerifyEmailEffect.tsx | 15 +- ...oken.ts => verifyEmailAndGetLoginToken.ts} | 4 +- ...erifyEmailAndGetWorkspaceAgnosticToken.ts} | 4 +- .../src/modules/auth/hooks/useAuth.ts | 56 ++- .../auth/states/currentWorkspaceState.ts | 1 + .../profile/components/EmailField.tsx | 127 ++++++- .../profile/components/NameFields.tsx | 18 +- .../components/ProfilePictureUploader.tsx | 15 +- .../graphql/mutations/updateUserEmail.ts | 13 + .../profile/hooks/useCanEditProfileField.ts | 40 +++ .../settings/profile/hooks/useUpdateEmail.ts | 41 +++ .../SettingsRolePermissionsToolSection.tsx | 8 + .../SettingsSecurityEditableProfileFields.tsx | 147 ++++++++ .../EditableProfileFields.constants.ts | 2 + .../graphql/fragments/userQueryFragment.ts | 1 + .../settings/security/SettingsSecurity.tsx | 10 + .../1762884796640-editableProfileFields.ts | 17 + .../engine/core-modules/auth/auth.resolver.ts | 75 ++-- ...erify-email-and-get-login-token.output.ts} | 4 +- .../services/workspace-domains.service.ts | 2 + .../email-verification.constants.ts | 4 + .../email-verification.module.ts | 2 - .../services/email-verification.service.ts | 54 ++- .../core-modules/message-queue/jobs.module.ts | 2 + .../user/dtos/update-user-email.input.ts | 16 + .../jobs/update-workspace-member-email.job.ts | 44 +++ .../user/services/user.service.spec.ts | 18 + .../user/services/user.service.ts | 107 ++++++ .../engine/core-modules/user/user.entity.ts | 6 +- .../core-modules/user/user.exception.ts | 3 + .../engine/core-modules/user/user.module.ts | 4 + .../engine/core-modules/user/user.resolver.ts | 33 +- .../workspace/dtos/update-workspace-input.ts | 7 + .../workspace/services/workspace.service.ts | 1 + .../workspace/workspace.entity.ts | 9 + .../permission-flag-type.constants.ts | 1 + .../constants/tool-permission-flags.ts | 1 + .../permissions/permissions.service.ts | 1 + 41 files changed, 1041 insertions(+), 272 deletions(-) rename packages/twenty-front/src/modules/auth/graphql/mutations/{getLoginTokenFromEmailVerificationToken.ts => verifyEmailAndGetLoginToken.ts} (83%) rename packages/twenty-front/src/modules/auth/graphql/mutations/{getWorkspaceAgnosticTokenFromEmailVerificationToken.ts => verifyEmailAndGetWorkspaceAgnosticToken.ts} (79%) create mode 100644 packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts create mode 100644 packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts create mode 100644 packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx create mode 100644 packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts rename packages/twenty-server/src/engine/core-modules/auth/dto/{get-login-token-from-email-verification-token.output.ts => verify-email-and-get-login-token.output.ts} (72%) create mode 100644 packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts diff --git a/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx index c11b01cbca6..c9acec0bad3 100644 --- a/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx +++ b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx @@ -9,22 +9,33 @@ import { type APP_LOCALES } from 'twenty-shared/translations'; type SendEmailVerificationLinkEmailProps = { link: string; locale: keyof typeof APP_LOCALES; + isEmailUpdate?: boolean; }; export const SendEmailVerificationLinkEmail = ({ link, locale, + isEmailUpdate = false, }: SendEmailVerificationLinkEmailProps) => { const i18n = createI18nInstance(locale); + const title = isEmailUpdate + ? i18n._('Confirm your new email address') + : i18n._('Confirm your email address'); + const bodyId = isEmailUpdate + ? 'We received a request to change the email address associated with your Twenty account. Click below to confirm this change.' + : 'Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address.'; + const ctaLabel = isEmailUpdate + ? i18n._('Confirm new email') + : i18n._('Verify Email'); return ( - + <Title value={title} /> <MainText> - <Trans id="Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address." /> + <Trans id={bodyId} /> </MainText> <br /> - <CallToAction href={link} value={i18n._('Verify Email')} /> + <CallToAction href={link} value={ctaLabel} /> <br /> <br /> </BaseEmail> @@ -34,6 +45,7 @@ export const SendEmailVerificationLinkEmail = ({ SendEmailVerificationLinkEmail.PreviewProps = { link: 'https://app.twenty.com/verify-email/123', locale: 'en', + isEmailUpdate: false, }; export default SendEmailVerificationLinkEmail; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9aa68d15d55..35c0712058d 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1460,12 +1460,6 @@ export type GetAuthorizationUrlForSsoOutput = { type: Scalars['String']; }; -export type GetLoginTokenFromEmailVerificationTokenOutput = { - __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput'; - loginToken: AuthToken; - workspaceUrls: WorkspaceUrls; -}; - export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']; @@ -1853,8 +1847,6 @@ export type Mutation = { getAuthTokensFromOTP: AuthTokens; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput; getLoginTokenFromCredentials: LoginTokenOutput; - getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; - getWorkspaceAgnosticTokenFromEmailVerificationToken: AvailableWorkspacesAndAccessTokensOutput; impersonate: ImpersonateOutput; initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; @@ -1914,6 +1906,7 @@ export type Mutation = { updatePageLayoutWidget: PageLayoutWidget; updatePageLayoutWithTabsAndWidgets: PageLayout; updatePasswordViaResetToken: InvalidatePasswordOutput; + updateUserEmail: Scalars['Boolean']; updateWebhook?: Maybe<Webhook>; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionPositions: Scalars['Boolean']; @@ -1930,6 +1923,8 @@ export type Mutation = { upsertPermissionFlags: Array<PermissionFlag>; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; + verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput; + verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokensOutput; verifyEmailingDomain: EmailingDomain; verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput; }; @@ -2440,21 +2435,6 @@ export type MutationGetLoginTokenFromCredentialsArgs = { }; -export type MutationGetLoginTokenFromEmailVerificationTokenArgs = { - captchaToken?: InputMaybe<Scalars['String']>; - email: Scalars['String']; - emailVerificationToken: Scalars['String']; - origin: Scalars['String']; -}; - - -export type MutationGetWorkspaceAgnosticTokenFromEmailVerificationTokenArgs = { - captchaToken?: InputMaybe<Scalars['String']>; - email: Scalars['String']; - emailVerificationToken: Scalars['String']; -}; - - export type MutationImpersonateArgs = { userId: Scalars['UUID']; workspaceId: Scalars['UUID']; @@ -2758,6 +2738,12 @@ export type MutationUpdatePasswordViaResetTokenArgs = { }; +export type MutationUpdateUserEmailArgs = { + newEmail: Scalars['String']; + verifyEmailRedirectPath?: InputMaybe<Scalars['String']>; +}; + + export type MutationUpdateWebhookArgs = { input: UpdateWebhookInput; }; @@ -2843,6 +2829,21 @@ export type MutationValidateApprovedAccessDomainArgs = { }; +export type MutationVerifyEmailAndGetLoginTokenArgs = { + captchaToken?: InputMaybe<Scalars['String']>; + email: Scalars['String']; + emailVerificationToken: Scalars['String']; + origin: Scalars['String']; +}; + + +export type MutationVerifyEmailAndGetWorkspaceAgnosticTokenArgs = { + captchaToken?: InputMaybe<Scalars['String']>; + email: Scalars['String']; + emailVerificationToken: Scalars['String']; +}; + + export type MutationVerifyEmailingDomainArgs = { id: Scalars['String']; }; @@ -3101,6 +3102,7 @@ export enum PermissionFlagType { IMPERSONATE = 'IMPERSONATE', IMPORT_CSV = 'IMPORT_CSV', LAYOUTS = 'LAYOUTS', + PROFILE_INFORMATION = 'PROFILE_INFORMATION', ROLES = 'ROLES', SECURITY = 'SECURITY', SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', @@ -4451,6 +4453,7 @@ export type UpdateWorkspaceInput = { customDomain?: InputMaybe<Scalars['String']>; defaultRoleId?: InputMaybe<Scalars['UUID']>; displayName?: InputMaybe<Scalars['String']>; + editableProfileFields?: InputMaybe<Array<Scalars['String']>>; inviteHash?: InputMaybe<Scalars['String']>; isGoogleAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>; isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>; @@ -4585,6 +4588,12 @@ export type VerificationRecord = { value: Scalars['String']; }; +export type VerifyEmailAndGetLoginTokenOutput = { + __typename?: 'VerifyEmailAndGetLoginTokenOutput'; + loginToken: AuthToken; + workspaceUrls: WorkspaceUrls; +}; + export type VerifyTwoFactorAuthenticationMethodOutput = { __typename?: 'VerifyTwoFactorAuthenticationMethodOutput'; success: Scalars['Boolean']; @@ -4791,6 +4800,7 @@ export type Workspace = { defaultRole?: Maybe<Role>; deletedAt?: Maybe<Scalars['DateTime']>; displayName?: Maybe<Scalars['String']>; + editableProfileFields?: Maybe<Array<Scalars['String']>>; featureFlags?: Maybe<Array<FeatureFlagDto>>; hasValidEnterpriseKey: Scalars['Boolean']; id: Scalars['UUID']; @@ -5140,25 +5150,6 @@ export type GetLoginTokenFromCredentialsMutationVariables = Exact<{ export type GetLoginTokenFromCredentialsMutation = { __typename?: 'Mutation', getLoginTokenFromCredentials: { __typename?: 'LoginTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } }; -export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{ - emailVerificationToken: Scalars['String']; - email: Scalars['String']; - captchaToken?: InputMaybe<Scalars['String']>; - origin: Scalars['String']; -}>; - - -export type GetLoginTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getLoginTokenFromEmailVerificationToken: { __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } }; - -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables = Exact<{ - emailVerificationToken: Scalars['String']; - email: Scalars['String']; - captchaToken?: InputMaybe<Scalars['String']>; -}>; - - -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getWorkspaceAgnosticTokenFromEmailVerificationToken: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; - export type ImpersonateMutationVariables = Exact<{ userId: Scalars['UUID']; workspaceId: Scalars['UUID']; @@ -5249,6 +5240,25 @@ export type UpdatePasswordViaResetTokenMutationVariables = Exact<{ export type UpdatePasswordViaResetTokenMutation = { __typename?: 'Mutation', updatePasswordViaResetToken: { __typename?: 'InvalidatePasswordOutput', success: boolean } }; +export type VerifyEmailAndGetLoginTokenMutationVariables = Exact<{ + emailVerificationToken: Scalars['String']; + email: Scalars['String']; + captchaToken?: InputMaybe<Scalars['String']>; + origin: Scalars['String']; +}>; + + +export type VerifyEmailAndGetLoginTokenMutation = { __typename?: 'Mutation', verifyEmailAndGetLoginToken: { __typename?: 'VerifyEmailAndGetLoginTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } }; + +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables = Exact<{ + emailVerificationToken: Scalars['String']; + email: Scalars['String']; + captchaToken?: InputMaybe<Scalars['String']>; +}>; + + +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutation = { __typename?: 'Mutation', verifyEmailAndGetWorkspaceAgnosticToken: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; captchaToken?: InputMaybe<Scalars['String']>; @@ -5749,6 +5759,14 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } }; +export type UpdateUserEmailMutationVariables = Exact<{ + newEmail: Scalars['String']; + verifyEmailRedirectPath?: InputMaybe<Scalars['String']>; +}>; + + +export type UpdateUserEmailMutation = { __typename?: 'Mutation', updateUserEmail: boolean }; + export type ApiKeyForRoleFragmentFragment = { __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }; export type FieldPermissionFragmentFragment = { __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }; @@ -5947,7 +5965,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array<PermissionFlagType> | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array<PermissionFlagType> | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array<string> | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -5973,7 +5991,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array<PermissionFlagType> | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array<PermissionFlagType> | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array<string> | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array<string> | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -6983,6 +7001,7 @@ export const UserQueryFragmentFragmentDoc = gql` routerModel isTwoFactorAuthenticationEnforced trashRetentionDays + editableProfileFields } availableWorkspaces { ...AvailableWorkspacesFragment @@ -8137,98 +8156,6 @@ export function useGetLoginTokenFromCredentialsMutation(baseOptions?: Apollo.Mut export type GetLoginTokenFromCredentialsMutationHookResult = ReturnType<typeof useGetLoginTokenFromCredentialsMutation>; export type GetLoginTokenFromCredentialsMutationResult = Apollo.MutationResult<GetLoginTokenFromCredentialsMutation>; export type GetLoginTokenFromCredentialsMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromCredentialsMutation, GetLoginTokenFromCredentialsMutationVariables>; -export const GetLoginTokenFromEmailVerificationTokenDocument = gql` - mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) { - getLoginTokenFromEmailVerificationToken( - emailVerificationToken: $emailVerificationToken - email: $email - captchaToken: $captchaToken - origin: $origin - ) { - loginToken { - ...AuthTokenFragment - } - workspaceUrls { - ...WorkspaceUrlsFragment - } - } -} - ${AuthTokenFragmentFragmentDoc} -${WorkspaceUrlsFragmentFragmentDoc}`; -export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>; - -/** - * __useGetLoginTokenFromEmailVerificationTokenMutation__ - * - * To run a mutation, you first call `useGetLoginTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useGetLoginTokenFromEmailVerificationTokenMutation` 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 [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({ - * variables: { - * emailVerificationToken: // value for 'emailVerificationToken' - * email: // value for 'email' - * captchaToken: // value for 'captchaToken' - * origin: // value for 'origin' - * }, - * }); - */ -export function useGetLoginTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>(GetLoginTokenFromEmailVerificationTokenDocument, options); - } -export type GetLoginTokenFromEmailVerificationTokenMutationHookResult = ReturnType<typeof useGetLoginTokenFromEmailVerificationTokenMutation>; -export type GetLoginTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult<GetLoginTokenFromEmailVerificationTokenMutation>; -export type GetLoginTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<GetLoginTokenFromEmailVerificationTokenMutation, GetLoginTokenFromEmailVerificationTokenMutationVariables>; -export const GetWorkspaceAgnosticTokenFromEmailVerificationTokenDocument = gql` - mutation GetWorkspaceAgnosticTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String) { - getWorkspaceAgnosticTokenFromEmailVerificationToken( - emailVerificationToken: $emailVerificationToken - email: $email - captchaToken: $captchaToken - ) { - availableWorkspaces { - ...AvailableWorkspacesFragment - } - tokens { - ...AuthTokenPairFragment - } - } -} - ${AvailableWorkspacesFragmentFragmentDoc} -${AuthTokenPairFragmentFragmentDoc}`; -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction<GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables>; - -/** - * __useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation__ - * - * To run a mutation, you first call `useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation` 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 [getWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation({ - * variables: { - * emailVerificationToken: // value for 'emailVerificationToken' - * email: // value for 'email' - * captchaToken: // value for 'captchaToken' - * }, - * }); - */ -export function useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions<GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables>) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation<GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables>(GetWorkspaceAgnosticTokenFromEmailVerificationTokenDocument, options); - } -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationHookResult = ReturnType<typeof useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation>; -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult<GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation>; -export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions<GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables>; export const ImpersonateDocument = gql` mutation Impersonate($userId: UUID!, $workspaceId: UUID!) { impersonate(userId: $userId, workspaceId: $workspaceId) { @@ -8668,6 +8595,98 @@ export function useUpdatePasswordViaResetTokenMutation(baseOptions?: Apollo.Muta export type UpdatePasswordViaResetTokenMutationHookResult = ReturnType<typeof useUpdatePasswordViaResetTokenMutation>; export type UpdatePasswordViaResetTokenMutationResult = Apollo.MutationResult<UpdatePasswordViaResetTokenMutation>; export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOptions<UpdatePasswordViaResetTokenMutation, UpdatePasswordViaResetTokenMutationVariables>; +export const VerifyEmailAndGetLoginTokenDocument = gql` + mutation VerifyEmailAndGetLoginToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) { + verifyEmailAndGetLoginToken( + emailVerificationToken: $emailVerificationToken + email: $email + captchaToken: $captchaToken + origin: $origin + ) { + loginToken { + ...AuthTokenFragment + } + workspaceUrls { + ...WorkspaceUrlsFragment + } + } +} + ${AuthTokenFragmentFragmentDoc} +${WorkspaceUrlsFragmentFragmentDoc}`; +export type VerifyEmailAndGetLoginTokenMutationFn = Apollo.MutationFunction<VerifyEmailAndGetLoginTokenMutation, VerifyEmailAndGetLoginTokenMutationVariables>; + +/** + * __useVerifyEmailAndGetLoginTokenMutation__ + * + * To run a mutation, you first call `useVerifyEmailAndGetLoginTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useVerifyEmailAndGetLoginTokenMutation` 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 [verifyEmailAndGetLoginTokenMutation, { data, loading, error }] = useVerifyEmailAndGetLoginTokenMutation({ + * variables: { + * emailVerificationToken: // value for 'emailVerificationToken' + * email: // value for 'email' + * captchaToken: // value for 'captchaToken' + * origin: // value for 'origin' + * }, + * }); + */ +export function useVerifyEmailAndGetLoginTokenMutation(baseOptions?: Apollo.MutationHookOptions<VerifyEmailAndGetLoginTokenMutation, VerifyEmailAndGetLoginTokenMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<VerifyEmailAndGetLoginTokenMutation, VerifyEmailAndGetLoginTokenMutationVariables>(VerifyEmailAndGetLoginTokenDocument, options); + } +export type VerifyEmailAndGetLoginTokenMutationHookResult = ReturnType<typeof useVerifyEmailAndGetLoginTokenMutation>; +export type VerifyEmailAndGetLoginTokenMutationResult = Apollo.MutationResult<VerifyEmailAndGetLoginTokenMutation>; +export type VerifyEmailAndGetLoginTokenMutationOptions = Apollo.BaseMutationOptions<VerifyEmailAndGetLoginTokenMutation, VerifyEmailAndGetLoginTokenMutationVariables>; +export const VerifyEmailAndGetWorkspaceAgnosticTokenDocument = gql` + mutation VerifyEmailAndGetWorkspaceAgnosticToken($emailVerificationToken: String!, $email: String!, $captchaToken: String) { + verifyEmailAndGetWorkspaceAgnosticToken( + emailVerificationToken: $emailVerificationToken + email: $email + captchaToken: $captchaToken + ) { + availableWorkspaces { + ...AvailableWorkspacesFragment + } + tokens { + ...AuthTokenPairFragment + } + } +} + ${AvailableWorkspacesFragmentFragmentDoc} +${AuthTokenPairFragmentFragmentDoc}`; +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationFn = Apollo.MutationFunction<VerifyEmailAndGetWorkspaceAgnosticTokenMutation, VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables>; + +/** + * __useVerifyEmailAndGetWorkspaceAgnosticTokenMutation__ + * + * To run a mutation, you first call `useVerifyEmailAndGetWorkspaceAgnosticTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useVerifyEmailAndGetWorkspaceAgnosticTokenMutation` 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 [verifyEmailAndGetWorkspaceAgnosticTokenMutation, { data, loading, error }] = useVerifyEmailAndGetWorkspaceAgnosticTokenMutation({ + * variables: { + * emailVerificationToken: // value for 'emailVerificationToken' + * email: // value for 'email' + * captchaToken: // value for 'captchaToken' + * }, + * }); + */ +export function useVerifyEmailAndGetWorkspaceAgnosticTokenMutation(baseOptions?: Apollo.MutationHookOptions<VerifyEmailAndGetWorkspaceAgnosticTokenMutation, VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<VerifyEmailAndGetWorkspaceAgnosticTokenMutation, VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables>(VerifyEmailAndGetWorkspaceAgnosticTokenDocument, options); + } +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationHookResult = ReturnType<typeof useVerifyEmailAndGetWorkspaceAgnosticTokenMutation>; +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationResult = Apollo.MutationResult<VerifyEmailAndGetWorkspaceAgnosticTokenMutation>; +export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationOptions = Apollo.BaseMutationOptions<VerifyEmailAndGetWorkspaceAgnosticTokenMutation, VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables>; export const CheckUserExistsDocument = gql` query CheckUserExists($email: String!, $captchaToken: String) { checkUserExists(email: $email, captchaToken: $captchaToken) { @@ -11455,6 +11474,41 @@ export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.Mutat export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType<typeof useUpdateLabPublicFeatureFlagMutation>; export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult<UpdateLabPublicFeatureFlagMutation>; export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>; +export const UpdateUserEmailDocument = gql` + mutation UpdateUserEmail($newEmail: String!, $verifyEmailRedirectPath: String) { + updateUserEmail( + newEmail: $newEmail + verifyEmailRedirectPath: $verifyEmailRedirectPath + ) +} + `; +export type UpdateUserEmailMutationFn = Apollo.MutationFunction<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>; + +/** + * __useUpdateUserEmailMutation__ + * + * To run a mutation, you first call `useUpdateUserEmailMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateUserEmailMutation` 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 [updateUserEmailMutation, { data, loading, error }] = useUpdateUserEmailMutation({ + * variables: { + * newEmail: // value for 'newEmail' + * verifyEmailRedirectPath: // value for 'verifyEmailRedirectPath' + * }, + * }); + */ +export function useUpdateUserEmailMutation(baseOptions?: Apollo.MutationHookOptions<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>(UpdateUserEmailDocument, options); + } +export type UpdateUserEmailMutationHookResult = ReturnType<typeof useUpdateUserEmailMutation>; +export type UpdateUserEmailMutationResult = Apollo.MutationResult<UpdateUserEmailMutation>; +export type UpdateUserEmailMutationOptions = Apollo.BaseMutationOptions<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>; export const CreateOneRoleDocument = gql` mutation CreateOneRole($createRoleInput: CreateRoleInput!) { createOneRole(createRoleInput: $createRoleInput) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index b25e2578739..e5d51ea1fc5 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1394,12 +1394,6 @@ export type GetAuthorizationUrlForSsoOutput = { type: Scalars['String']; }; -export type GetLoginTokenFromEmailVerificationTokenOutput = { - __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput'; - loginToken: AuthToken; - workspaceUrls: WorkspaceUrls; -}; - export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']; @@ -1781,8 +1775,6 @@ export type Mutation = { getAuthTokensFromOTP: AuthTokens; getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput; getLoginTokenFromCredentials: LoginTokenOutput; - getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; - getWorkspaceAgnosticTokenFromEmailVerificationToken: AvailableWorkspacesAndAccessTokensOutput; impersonate: ImpersonateOutput; initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput; initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput; @@ -1836,6 +1828,7 @@ export type Mutation = { updatePageLayoutWidget: PageLayoutWidget; updatePageLayoutWithTabsAndWidgets: PageLayout; updatePasswordViaResetToken: InvalidatePasswordOutput; + updateUserEmail: Scalars['Boolean']; updateWebhook?: Maybe<Webhook>; updateWorkflowRunStep: WorkflowAction; updateWorkflowVersionPositions: Scalars['Boolean']; @@ -1852,6 +1845,8 @@ export type Mutation = { upsertPermissionFlags: Array<PermissionFlag>; userLookupAdminPanel: UserLookup; validateApprovedAccessDomain: ApprovedAccessDomain; + verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput; + verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokensOutput; verifyEmailingDomain: EmailingDomain; verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput; }; @@ -2327,21 +2322,6 @@ export type MutationGetLoginTokenFromCredentialsArgs = { }; -export type MutationGetLoginTokenFromEmailVerificationTokenArgs = { - captchaToken?: InputMaybe<Scalars['String']>; - email: Scalars['String']; - emailVerificationToken: Scalars['String']; - origin: Scalars['String']; -}; - - -export type MutationGetWorkspaceAgnosticTokenFromEmailVerificationTokenArgs = { - captchaToken?: InputMaybe<Scalars['String']>; - email: Scalars['String']; - emailVerificationToken: Scalars['String']; -}; - - export type MutationImpersonateArgs = { userId: Scalars['UUID']; workspaceId: Scalars['UUID']; @@ -2615,6 +2595,12 @@ export type MutationUpdatePasswordViaResetTokenArgs = { }; +export type MutationUpdateUserEmailArgs = { + newEmail: Scalars['String']; + verifyEmailRedirectPath?: InputMaybe<Scalars['String']>; +}; + + export type MutationUpdateWebhookArgs = { input: UpdateWebhookInput; }; @@ -2700,6 +2686,21 @@ export type MutationValidateApprovedAccessDomainArgs = { }; +export type MutationVerifyEmailAndGetLoginTokenArgs = { + captchaToken?: InputMaybe<Scalars['String']>; + email: Scalars['String']; + emailVerificationToken: Scalars['String']; + origin: Scalars['String']; +}; + + +export type MutationVerifyEmailAndGetWorkspaceAgnosticTokenArgs = { + captchaToken?: InputMaybe<Scalars['String']>; + email: Scalars['String']; + emailVerificationToken: Scalars['String']; +}; + + export type MutationVerifyEmailingDomainArgs = { id: Scalars['String']; }; @@ -2958,6 +2959,7 @@ export enum PermissionFlagType { IMPERSONATE = 'IMPERSONATE', IMPORT_CSV = 'IMPORT_CSV', LAYOUTS = 'LAYOUTS', + PROFILE_INFORMATION = 'PROFILE_INFORMATION', ROLES = 'ROLES', SECURITY = 'SECURITY', SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL', @@ -4185,6 +4187,7 @@ export type UpdateWorkspaceInput = { customDomain?: InputMaybe<Scalars['String']>; defaultRoleId?: InputMaybe<Scalars['UUID']>; displayName?: InputMaybe<Scalars['String']>; + editableProfileFields?: InputMaybe<Array<Scalars['String']>>; inviteHash?: InputMaybe<Scalars['String']>; isGoogleAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>; isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>; @@ -4309,6 +4312,12 @@ export type VerificationRecord = { value: Scalars['String']; }; +export type VerifyEmailAndGetLoginTokenOutput = { + __typename?: 'VerifyEmailAndGetLoginTokenOutput'; + loginToken: AuthToken; + workspaceUrls: WorkspaceUrls; +}; + export type VerifyTwoFactorAuthenticationMethodOutput = { __typename?: 'VerifyTwoFactorAuthenticationMethodOutput'; success: Scalars['Boolean']; @@ -4515,6 +4524,7 @@ export type Workspace = { defaultRole?: Maybe<Role>; deletedAt?: Maybe<Scalars['DateTime']>; displayName?: Maybe<Scalars['String']>; + editableProfileFields?: Maybe<Array<Scalars['String']>>; featureFlags?: Maybe<Array<FeatureFlagDto>>; hasValidEnterpriseKey: Scalars['Boolean']; id: Scalars['UUID']; diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx index 90c70373c6e..7d1306eb660 100644 --- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx @@ -20,8 +20,8 @@ import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificatio export const VerifyEmailEffect = () => { const { - getLoginTokenFromEmailVerificationToken, - getWorkspaceAgnosticTokenFromEmailVerificationToken, + verifyEmailAndGetLoginToken, + verifyEmailAndGetWorkspaceAgnosticToken, } = useAuth(); const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); @@ -65,7 +65,7 @@ export const VerifyEmailEffect = () => { try { if (!isOnAWorkspace) { - await getWorkspaceAgnosticTokenFromEmailVerificationToken( + await verifyEmailAndGetWorkspaceAgnosticToken( emailVerificationToken, email, ); @@ -73,11 +73,10 @@ export const VerifyEmailEffect = () => { return enqueueSuccessSnackBar(successSnackbarParams); } - const { loginToken, workspaceUrls } = - await getLoginTokenFromEmailVerificationToken( - emailVerificationToken, - email, - ); + const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken( + emailVerificationToken, + email, + ); enqueueSuccessSnackBar(successSnackbarParams); diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts similarity index 83% rename from packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts rename to packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts index 1503dcf8c52..3e57cadecda 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts @@ -1,13 +1,13 @@ import { gql } from '@apollo/client'; export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql` - mutation GetLoginTokenFromEmailVerificationToken( + mutation VerifyEmailAndGetLoginToken( $emailVerificationToken: String! $email: String! $captchaToken: String $origin: String! ) { - getLoginTokenFromEmailVerificationToken( + verifyEmailAndGetLoginToken( emailVerificationToken: $emailVerificationToken email: $email captchaToken: $captchaToken diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts similarity index 79% rename from packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts rename to packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts index bff7821e84d..8803bed7b9b 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts @@ -1,12 +1,12 @@ import { gql } from '@apollo/client'; export const GET_WORKSPACE_AGNOSTIC_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql` - mutation GetWorkspaceAgnosticTokenFromEmailVerificationToken( + mutation VerifyEmailAndGetWorkspaceAgnosticToken( $emailVerificationToken: String! $email: String! $captchaToken: String ) { - getWorkspaceAgnosticTokenFromEmailVerificationToken( + verifyEmailAndGetWorkspaceAgnosticToken( emailVerificationToken: $emailVerificationToken email: $email captchaToken: $captchaToken diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 290820d3767..d73a41bf650 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -18,17 +18,18 @@ import { useGetAuthTokensFromLoginTokenMutation, useGetAuthTokensFromOtpMutation, useGetLoginTokenFromCredentialsMutation, - useGetLoginTokenFromEmailVerificationTokenMutation, - useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, useSignInMutation, useSignUpInWorkspaceMutation, useSignUpMutation, + useVerifyEmailAndGetLoginTokenMutation, + useVerifyEmailAndGetWorkspaceAgnosticTokenMutation, type AuthTokenPair, } from '~/generated-metadata/graphql'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { tokenPairState } from '../states/tokenPairState'; +import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState'; import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; import { @@ -66,7 +67,6 @@ import { type AuthToken } from '~/generated/graphql'; import { cookieStorage } from '~/utils/cookie-storage'; import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl'; import { loginTokenState } from '../states/loginTokenState'; -import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState'; export const useAuth = () => { const setTokenPair = useSetRecoilState(tokenPairState); @@ -98,10 +98,10 @@ export const useAuth = () => { const [signUpInWorkspace] = useSignUpInWorkspaceMutation(); const [getAuthTokensFromLoginToken] = useGetAuthTokensFromLoginTokenMutation(); - const [getLoginTokenFromEmailVerificationToken] = - useGetLoginTokenFromEmailVerificationTokenMutation(); - const [getWorkspaceAgnosticTokenFromEmailVerificationToken] = - useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation(); + const [verifyEmailAndGetLoginToken] = + useVerifyEmailAndGetLoginTokenMutation(); + const [verifyEmailAndGetWorkspaceAgnosticToken] = + useVerifyEmailAndGetWorkspaceAgnosticTokenMutation(); const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation(); const workspacePublicData = useRecoilValue(workspacePublicDataState); @@ -238,13 +238,13 @@ export const useAuth = () => { [getLoginTokenFromCredentials, setSearchParams, setSignInUpStep, origin], ); - const handleGetLoginTokenFromEmailVerificationToken = useCallback( + const handleverifyEmailAndGetLoginToken = useCallback( async ( emailVerificationToken: string, email: string, captchaToken?: string, ) => { - const loginTokenResult = await getLoginTokenFromEmailVerificationToken({ + const loginTokenResult = await verifyEmailAndGetLoginToken({ variables: { email, emailVerificationToken, @@ -257,41 +257,38 @@ export const useAuth = () => { throw loginTokenResult.errors; } - if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) { + if (!loginTokenResult.data?.verifyEmailAndGetLoginToken) { throw new Error('No login token'); } - return loginTokenResult.data.getLoginTokenFromEmailVerificationToken; + return loginTokenResult.data.verifyEmailAndGetLoginToken; }, - [getLoginTokenFromEmailVerificationToken, origin], + [verifyEmailAndGetLoginToken, origin], ); - const handleGetWorkspaceAgnosticTokenFromEmailVerificationToken = useCallback( + const handleverifyEmailAndGetWorkspaceAgnosticToken = useCallback( async ( emailVerificationToken: string, email: string, captchaToken?: string, ) => { - const { data, errors } = - await getWorkspaceAgnosticTokenFromEmailVerificationToken({ - variables: { - email, - emailVerificationToken, - captchaToken, - }, - }); + const { data, errors } = await verifyEmailAndGetWorkspaceAgnosticToken({ + variables: { + email, + emailVerificationToken, + captchaToken, + }, + }); if (isDefined(errors)) { throw errors; } - if (!data?.getWorkspaceAgnosticTokenFromEmailVerificationToken) { + if (!data?.verifyEmailAndGetWorkspaceAgnosticToken) { throw new Error('No workspace agnostic token in result'); } - handleSetAuthTokens( - data.getWorkspaceAgnosticTokenFromEmailVerificationToken.tokens, - ); + handleSetAuthTokens(data.verifyEmailAndGetWorkspaceAgnosticToken.tokens); const { user } = await loadCurrentUser(); @@ -303,7 +300,7 @@ export const useAuth = () => { }, [ createWorkspace, - getWorkspaceAgnosticTokenFromEmailVerificationToken, + verifyEmailAndGetWorkspaceAgnosticToken, handleSetAuthTokens, loadCurrentUser, setSignInUpStep, @@ -681,10 +678,9 @@ export const useAuth = () => { return { getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials, - getWorkspaceAgnosticTokenFromEmailVerificationToken: - handleGetWorkspaceAgnosticTokenFromEmailVerificationToken, - getLoginTokenFromEmailVerificationToken: - handleGetLoginTokenFromEmailVerificationToken, + verifyEmailAndGetWorkspaceAgnosticToken: + handleverifyEmailAndGetWorkspaceAgnosticToken, + verifyEmailAndGetLoginToken: handleverifyEmailAndGetLoginToken, getAuthTokensFromLoginToken: handleGetAuthTokensFromLoginToken, checkUserExists: { checkUserExistsData, checkUserExistsQuery }, clearSession, diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 69b3120fe46..e8d15079c50 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -29,6 +29,7 @@ export type CurrentWorkspace = Pick< | 'isTwoFactorAuthenticationEnforced' | 'trashRetentionDays' | 'routerModel' + | 'editableProfileFields' > & { defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null; }; diff --git a/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx b/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx index f51563fd166..da5beaf6096 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx @@ -1,18 +1,131 @@ +import styled from '@emotion/styled'; +import { useState } from 'react'; import { useRecoilValue } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; +import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField'; +import { useUpdateEmail } from '@/settings/profile/hooks/useUpdateEmail'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { IconCheck, IconPencil, IconX } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledFieldRow = styled.div` + display: flex; + align-items: stretch; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledActionWrapper = styled.div` + display: flex; + align-items: stretch; + + & > button + button { + border-left: none; + } +`; + +const StyledActionButton = styled(Button)` + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; +`; export const EmailField = () => { const currentUser = useRecoilValue(currentUserState); + const { canEdit } = useCanEditProfileField('email'); + const { updateEmail } = useUpdateEmail(); + + const [draftEmail, setDraftEmail] = useState(''); + const [isEditing, setIsEditing] = useState(false); + + const currentEmail = currentUser?.email ?? ''; + + const normalizedDraftEmail = draftEmail.trim().toLowerCase(); + + const isEmailChanged = + normalizedDraftEmail.length > 0 && normalizedDraftEmail !== currentEmail; + const isEmailFormatValid = + normalizedDraftEmail.includes('@') && !normalizedDraftEmail.endsWith('@'); + + const isSaveDisabled = + !canEdit || !isEditing || !isEmailChanged || !isEmailFormatValid; + + const handleStartEditing = () => { + if (!canEdit) { + return; + } + + setDraftEmail(currentEmail); + setIsEditing(true); + }; + + const handleCancelEditing = () => { + setIsEditing(false); + }; + + const handleSave = async () => { + if (isSaveDisabled) { + return; + } + + setIsEditing(false); + await updateEmail(normalizedDraftEmail); + }; + + const currentUserId = currentUser?.id; return ( - <SettingsTextInput - instanceId={`user-email-${currentUser?.id}`} - value={currentUser?.email} - disabled - fullWidth - key={'email-' + currentUser?.id} - /> + <StyledContainer> + <StyledFieldRow> + <SettingsTextInput + instanceId={`user-email-${currentUserId}`} + value={isEditing ? draftEmail : currentEmail} + onChange={setDraftEmail} + disabled={!canEdit || !isEditing} + fullWidth + type="email" + onInputEnter={handleSave} + /> + {isEditing ? ( + <StyledActionWrapper key="editing"> + <StyledActionButton + Icon={IconCheck} + variant="secondary" + position="left" + size="small" + onClick={handleSave} + disabled={isSaveDisabled} + type="button" + /> + <StyledActionButton + Icon={IconX} + variant="secondary" + position="right" + size="small" + onClick={handleCancelEditing} + type="button" + /> + </StyledActionWrapper> + ) : ( + <StyledActionWrapper key="view"> + <StyledActionButton + Icon={IconPencil} + variant="secondary" + size="small" + onClick={handleStartEditing} + disabled={!canEdit} + type="button" + /> + </StyledActionWrapper> + )} + </StyledFieldRow> + </StyledContainer> ); }; diff --git a/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx b/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx index d0194d6c7dc..86c36483a32 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx @@ -8,6 +8,7 @@ import { currentUserState } from '@/auth/states/currentUserState'; 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 { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { logError } from '~/utils/logError'; @@ -21,20 +22,16 @@ const StyledComboInputContainer = styled.div` type NameFieldsProps = { autoSave?: boolean; - onFirstNameUpdate?: (firstName: string) => void; - onLastNameUpdate?: (lastName: string) => void; }; -export const NameFields = ({ - autoSave = true, - onFirstNameUpdate, - onLastNameUpdate, -}: NameFieldsProps) => { +export const NameFields = ({ autoSave = true }: NameFieldsProps) => { const { t } = useLingui(); const currentUser = useRecoilValue(currentUserState); const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( currentWorkspaceMemberState, ); + const { canEdit: canEditFirstName } = useCanEditProfileField('firstName'); + const { canEdit: canEditLastName } = useCanEditProfileField('lastName'); const [firstName, setFirstName] = useState( currentWorkspaceMember?.name?.firstName ?? '', @@ -49,9 +46,6 @@ export const NameFields = ({ // TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com) const debouncedUpdate = useDebouncedCallback(async () => { - onFirstNameUpdate?.(firstName); - onLastNameUpdate?.(lastName); - try { if (!currentWorkspaceMember?.id) { throw new Error('User is not logged in'); @@ -107,6 +101,8 @@ export const NameFields = ({ debouncedUpdate, autoSave, currentWorkspaceMember, + canEditFirstName, + canEditLastName, ]); const firstNameTextInputId = `${currentWorkspaceMember?.id}-first-name`; @@ -121,6 +117,7 @@ export const NameFields = ({ onChange={setFirstName} placeholder="Tim" fullWidth + disabled={!canEditFirstName} /> <SettingsTextInput instanceId={lastNameTextInputId} @@ -129,6 +126,7 @@ export const NameFields = ({ onChange={setLastName} placeholder="Cook" fullWidth + disabled={!canEditLastName} /> </StyledComboInputContainer> ); diff --git a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx b/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx index 60064c24cf9..df5ef7181da 100644 --- a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx +++ b/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx @@ -4,6 +4,7 @@ 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'; @@ -25,8 +26,11 @@ export const ProfilePictureUploader = () => { objectNameSingular: CoreObjectNameSingular.WorkspaceMember, }); + const { canEdit: canEditProfilePicture } = + useCanEditProfileField('profilePicture'); + const handleUpload = async (file: File) => { - if (isUndefinedOrNull(file)) { + if (isUndefinedOrNull(file) || !canEditProfilePicture) { return; } @@ -76,6 +80,10 @@ export const ProfilePictureUploader = () => { }; const handleAbort = async () => { + if (!canEditProfilePicture) { + return; + } + if (isDefined(uploadController)) { uploadController.abort(); setUploadController(null); @@ -83,6 +91,10 @@ export const ProfilePictureUploader = () => { }; const handleRemove = async () => { + if (!canEditProfilePicture) { + return; + } + try { if (!currentWorkspaceMember?.id) { throw new Error('User is not logged in'); @@ -109,6 +121,7 @@ export const ProfilePictureUploader = () => { onAbort={handleAbort} isUploading={isUploading} errorMessage={errorMessage} + disabled={!canEditProfilePicture} /> ); }; diff --git a/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts new file mode 100644 index 00000000000..c957de5bcd7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_USER_EMAIL = gql` + mutation UpdateUserEmail( + $newEmail: String! + $verifyEmailRedirectPath: String + ) { + updateUserEmail( + newEmail: $newEmail + verifyEmailRedirectPath: $verifyEmailRedirectPath + ) + } +`; diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts new file mode 100644 index 00000000000..02f389ea86f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts @@ -0,0 +1,40 @@ +import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils'; +import { useRecoilValue } from 'recoil'; +import { PermissionFlagType } from '~/generated-metadata/graphql'; + +export type EditableProfileField = + | 'email' + | 'firstName' + | 'lastName' + | 'profilePicture'; + +export const useCanEditProfileField = (field: EditableProfileField) => { + const currentWorkspace = useRecoilValue(currentWorkspaceState); + const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState); + const availableWorkspaces = useRecoilValue(availableWorkspacesState); + + if (!currentWorkspace || !currentUserWorkspace) { + return { canEdit: false }; + } + + const editableFields = currentWorkspace.editableProfileFields ?? []; + const workspaceAllowsField = editableFields.includes(field); + + const permissionFlags = currentUserWorkspace.permissionFlags ?? []; + const hasProfilePermission = permissionFlags.includes( + PermissionFlagType.PROFILE_INFORMATION, + ); + + const requiresSingleWorkspace = field === 'email'; + const isSingleWorkspaceUser = + countAvailableWorkspaces(availableWorkspaces) <= 1; + const meetsWorkspaceLimit = !requiresSingleWorkspace || isSingleWorkspaceUser; + + return { + canEdit: + workspaceAllowsField && hasProfilePermission && meetsWorkspaceLimit, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts new file mode 100644 index 00000000000..c2955aec8bf --- /dev/null +++ b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts @@ -0,0 +1,41 @@ +import { ApolloError } from '@apollo/client'; + +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useUpdateUserEmailMutation } from '~/generated-metadata/graphql'; + +export const useUpdateEmail = () => { + const { enqueueErrorSnackBar, enqueueInfoSnackBar } = useSnackBar(); + + const currentUser = useRecoilValue(currentUserState); + + const [updateUserEmail] = useUpdateUserEmailMutation(); + + const handleUpdate = async (email: string) => { + if (!currentUser) { + return; + } + + try { + await updateUserEmail({ + variables: { + newEmail: email, + }, + }); + + enqueueInfoSnackBar({ + message: 'Check your inbox to verify your new email address.', + }); + } catch (error) { + if (error instanceof ApolloError) { + enqueueErrorSnackBar({ apolloError: error }); + } + } + }; + + return { + updateEmail: handleUpdate, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx index d0fe8d84fb1..b96b2c748e4 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx @@ -19,6 +19,7 @@ import { IconSparkles, IconTable, IconTool, + IconUser, } from 'twenty-ui/display'; import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout'; import { @@ -104,6 +105,13 @@ export const SettingsRolePermissionsToolSection = ({ Icon: IconAt, isToolPermission: true, }, + { + key: PermissionFlagType.PROFILE_INFORMATION, + name: t`Edit Profile`, + description: t`Edit own profile information`, + Icon: IconUser, + isToolPermission: true, + }, { key: PermissionFlagType.VIEWS, name: t`Manage Views`, diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx new file mode 100644 index 00000000000..d9f1dbf5cea --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx @@ -0,0 +1,147 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { EDITABLE_PROFILE_FIELDS_DROPDOWN_ID } from '@/settings/security/constants/EditableProfileFields.constants'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +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 { ApolloError } from '@apollo/client'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useRecoilState } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconMail, + IconPhoto, + IconUser, + IconUserCircle, + type IconComponent, +} from 'twenty-ui/display'; +import { type SelectOption } from 'twenty-ui/input'; +import { MenuItemMultiSelect } from 'twenty-ui/navigation'; +import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql'; + +const StyledDropdownContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; +`; + +type ProfileFieldOption = { + value: string; + label: string; + Icon: IconComponent; +}; + +export const SettingsSecurityEditableProfileFields = () => { + const { t } = useLingui(); + const { enqueueErrorSnackBar } = useSnackBar(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const profileFieldOptions: ProfileFieldOption[] = [ + { value: 'email', label: t`Email`, Icon: IconMail }, + { value: 'firstName', label: t`First Name`, Icon: IconUserCircle }, + { value: 'lastName', label: t`Last Name`, Icon: IconUser }, + { value: 'profilePicture', label: t`Profile Picture`, Icon: IconPhoto }, + ]; + + const selectedFields = + currentWorkspace?.editableProfileFields?.filter(isDefined) ?? []; + + const optionByValue = new Map( + profileFieldOptions.map((option) => [option.value, option]), + ); + + const selectedLabelList = selectedFields + .map((value) => optionByValue.get(value)?.label ?? value) + .filter(isDefined); + + const selectedDisplayLabel = + selectedLabelList.length > 0 + ? selectedLabelList.join(', ') + : t`No fields selected`; + + const firstSelectedIcon = + selectedFields.length === 1 + ? optionByValue.get(selectedFields[0])?.Icon + : undefined; + + const selectedOption: SelectOption<string> = { + value: selectedDisplayLabel, + label: selectedDisplayLabel, + Icon: firstSelectedIcon, + }; + + const toggleField = (field: string) => { + if (!currentWorkspace?.id) { + enqueueErrorSnackBar({ message: t`User is not logged in` }); + return; + } + + const previousFields = currentWorkspace.editableProfileFields ?? []; + + const nextFields = previousFields.includes(field) + ? previousFields.filter((value) => value !== field) + : [...previousFields, field]; + + const normalizedFields = profileFieldOptions + .map((option) => option.value) + .filter((value) => nextFields.includes(value)); + + setCurrentWorkspace((prev) => + prev ? { ...prev, editableProfileFields: normalizedFields } : prev, + ); + + updateWorkspace({ + variables: { + input: { + editableProfileFields: normalizedFields, + }, + }, + }).catch((err) => { + setCurrentWorkspace((prev) => + prev ? { ...prev, editableProfileFields: previousFields } : prev, + ); + enqueueErrorSnackBar({ + apolloError: err instanceof ApolloError ? err : undefined, + }); + }); + }; + + return ( + <StyledDropdownContainer> + <Dropdown + dropdownId={EDITABLE_PROFILE_FIELDS_DROPDOWN_ID} + dropdownPlacement="bottom-start" + dropdownOffset={{ y: 8 }} + clickableComponent={ + <SelectControl + selectedOption={selectedOption} + isDisabled={!currentWorkspace} + hasRightElement={false} + /> + } + dropdownComponents={ + <DropdownContent> + <DropdownMenuItemsContainer> + {profileFieldOptions.map((option) => ( + <MenuItemMultiSelect + key={option.value} + text={option.label} + LeftIcon={option.Icon} + selected={selectedFields.includes(option.value)} + className="settings-security-editable-profile-fields-menu-item" + onSelectChange={() => toggleField(option.value)} + /> + ))} + </DropdownMenuItemsContainer> + </DropdownContent> + } + /> + </StyledDropdownContainer> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts b/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts new file mode 100644 index 00000000000..d1ee185d948 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts @@ -0,0 +1,2 @@ +export const EDITABLE_PROFILE_FIELDS_DROPDOWN_ID = + 'editable-profile-fields-dropdown'; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 12b838e3e51..f7c12e8e29a 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -83,6 +83,7 @@ export const USER_QUERY_FRAGMENT = gql` routerModel isTwoFactorAuthenticationEnforced trashRetentionDays + editableProfileFields } availableWorkspaces { ...AvailableWorkspacesFragment diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx index 64452d24a09..2f4feb91c28 100644 --- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx +++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx @@ -10,6 +10,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard'; import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList'; import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList'; +import { SettingsSecurityEditableProfileFields } from '@/settings/security/components/SettingsSecurityEditableProfileFields'; import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState'; import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -139,6 +140,15 @@ export const SettingsSecurity = () => { <SettingsSecurityAuthProvidersOptionsList /> </StyledContainer> </Section> + <Section> + <StyledContainer> + <H2Title + title={t`Editable Profile Fields`} + description={t`Choose which profile fields users with the Edit Profile permission can modify`} + /> + <SettingsSecurityEditableProfileFields /> + </StyledContainer> + </Section> {shouldShowBypassSection && ( <Section> <StyledContainer> diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts new file mode 100644 index 00000000000..82ced1e3ca0 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts @@ -0,0 +1,17 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class EditableProfileFields1762884796640 implements MigrationInterface { + name = 'EditableProfileFields1762884796640'; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "editableProfileFields" character varying array DEFAULT '{email,profilePicture,firstName,lastName}'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "editableProfileFields"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 83b6226cf57..a11ee5e2124 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -33,7 +33,6 @@ import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-module import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input'; import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input'; import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output'; -import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output'; import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output'; import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; @@ -51,6 +50,7 @@ import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util'; +import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants'; import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -76,6 +76,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { VerifyEmailAndGetLoginTokenOutput } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output'; import { ApiKeyToken } from './dto/api-key-token.dto'; import { AuthTokens } from './dto/auth-tokens.dto'; @@ -239,9 +240,9 @@ export class AuthResolver { }; } - @Mutation(() => GetLoginTokenFromEmailVerificationTokenOutput) + @Mutation(() => VerifyEmailAndGetLoginTokenOutput) @UseGuards(PublicEndpointGuard, NoPermissionGuard) - async getLoginTokenFromEmailVerificationToken( + async verifyEmailAndGetLoginToken( @Args() getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput, @Args('origin') origin: string, @@ -252,19 +253,25 @@ export class AuthResolver { getAuthTokenFromEmailVerificationTokenInput, ); + if (appToken.context && appToken.context.email !== appToken.user.email) { + await this.userService.updateEmailFromVerificationToken( + appToken.user.id, + appToken.context.email, + ); + } + + const user = await this.userService.markEmailAsVerified(appToken.user.id); + + await this.appTokenRepository.remove(appToken); + const workspace = (await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace( origin, )) ?? - (await this.userWorkspaceService.findFirstWorkspaceByUserId( - appToken.user.id, - )); - - await this.userService.markEmailAsVerified(appToken.user.id); - await this.appTokenRepository.remove(appToken); + (await this.userWorkspaceService.findFirstWorkspaceByUserId(user.id)); const loginToken = await this.loginTokenService.generateLoginToken( - appToken.user.email, + user.email, workspace.id, authProvider, ); @@ -277,7 +284,7 @@ export class AuthResolver { @Mutation(() => AvailableWorkspacesAndAccessTokensOutput) @UseGuards(PublicEndpointGuard, NoPermissionGuard) - async getWorkspaceAgnosticTokenFromEmailVerificationToken( + async verifyEmailAndGetWorkspaceAgnosticToken( @Args() getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput, @AuthProvider() authProvider: AuthProviderEnum, @@ -287,31 +294,39 @@ export class AuthResolver { getAuthTokenFromEmailVerificationTokenInput, ); - await this.userService.markEmailAsVerified(appToken.user.id); + if (appToken.context && appToken.context.email !== appToken.user.email) { + await this.userService.updateEmailFromVerificationToken( + appToken.user.id, + appToken.context.email, + ); + } + + const user = await this.userService.markEmailAsVerified(appToken.user.id); + await this.appTokenRepository.remove(appToken); const availableWorkspaces = await this.userWorkspaceService.findAvailableWorkspacesByEmail( - appToken.user.email, + user.email, ); return { availableWorkspaces: await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch( availableWorkspaces, - appToken.user, + user, authProvider, ), tokens: { accessOrWorkspaceAgnosticToken: await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( { - userId: appToken.user.id, + userId: user.id, authProvider: AuthProviderEnum.Password, }, ), refreshToken: await this.refreshTokenService.generateRefreshToken({ - userId: appToken.user.id, + userId: user.id, authProvider: AuthProviderEnum.Password, targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, }), @@ -377,13 +392,14 @@ export class AuthResolver { user.email, ); - await this.emailVerificationService.sendVerificationEmail( - user.id, - user.email, - undefined, - signUpInput.locale ?? SOURCE_LOCALE, - signUpInput.verifyEmailRedirectPath, - ); + await this.emailVerificationService.sendVerificationEmail({ + userId: user.id, + email: user.email, + workspace: undefined, + locale: signUpInput.locale ?? SOURCE_LOCALE, + verifyEmailRedirectPath: signUpInput.verifyEmailRedirectPath, + verificationTrigger: EmailVerificationTrigger.SIGN_UP, + }); return { availableWorkspaces: @@ -459,13 +475,14 @@ export class AuthResolver { }, }); - await this.emailVerificationService.sendVerificationEmail( - user.id, - user.email, + await this.emailVerificationService.sendVerificationEmail({ + userId: user.id, + email: user.email, workspace, - signUpInput.locale ?? SOURCE_LOCALE, - signUpInput.verifyEmailRedirectPath, - ); + locale: signUpInput.locale ?? SOURCE_LOCALE, + verifyEmailRedirectPath: signUpInput.verifyEmailRedirectPath, + verificationTrigger: EmailVerificationTrigger.SIGN_UP, + }); const loginToken = await this.loginTokenService.generateLoginToken( user.email, diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts similarity index 72% rename from packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts rename to packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts index 8a0c1258602..bd35bd589a0 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts @@ -4,8 +4,8 @@ import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspa import { AuthToken } from './auth-token.dto'; -@ObjectType('GetLoginTokenFromEmailVerificationTokenOutput') -export class GetLoginTokenFromEmailVerificationTokenOutput { +@ObjectType('VerifyEmailAndGetLoginTokenOutput') +export class VerifyEmailAndGetLoginTokenOutput { @Field(() => AuthToken) loginToken: AuthToken; diff --git a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts index ceea764406a..bcad13bec79 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts @@ -142,6 +142,7 @@ export class WorkspaceDomainsService { return { subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), customDomain: null, + isCustomDomainEnabled: false, }; } @@ -149,6 +150,7 @@ export class WorkspaceDomainsService { return { subdomain: workspace.subdomain, customDomain: null, + isCustomDomainEnabled: false, }; } diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts new file mode 100644 index 00000000000..6906cd490f1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts @@ -0,0 +1,4 @@ +export enum EmailVerificationTrigger { + SIGN_UP = 'SIGN_UP', + EMAIL_UPDATE = 'EMAIL_UPDATE', +} diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts index 4aa2e1a404c..86456b6fe0c 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts @@ -11,14 +11,12 @@ import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; -import { UserModule } from 'src/engine/core-modules/user/user.module'; @Module({ imports: [ TypeOrmModule.forFeature([AppTokenEntity, UserEntity]), EmailModule, TwentyConfigModule, - UserModule, UserWorkspaceModule, WorkspaceDomainsModule, DomainServerConfigModule, diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts index d4381c97c43..09694fa6602 100644 --- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts +++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts @@ -8,7 +8,7 @@ import ms from 'ms'; import { SendEmailVerificationLinkEmail } from 'twenty-emails'; import { type APP_LOCALES } from 'twenty-shared/translations'; import { AppPath } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { @@ -19,6 +19,7 @@ import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/toke import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { WorkspaceDomainConfig } from 'src/engine/core-modules/domain/workspace-domains/types/workspace-domain-config.type'; +import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants'; import { EmailVerificationException, EmailVerificationExceptionCode, @@ -26,29 +27,38 @@ import { import { EmailService } from 'src/engine/core-modules/email/email.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { UserService } from 'src/engine/core-modules/user/services/user.service'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @Injectable() export class EmailVerificationService { constructor( @InjectRepository(AppTokenEntity) private readonly appTokenRepository: Repository<AppTokenEntity>, + @InjectRepository(UserEntity) + private readonly userRepository: Repository<UserEntity>, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly domainsServerConfigService: DomainServerConfigService, private readonly emailService: EmailService, private readonly twentyConfigService: TwentyConfigService, - private readonly userService: UserService, private readonly emailVerificationTokenService: EmailVerificationTokenService, private readonly i18nService: I18nService, ) {} - async sendVerificationEmail( - userId: string, - email: string, - workspace: WorkspaceDomainConfig | undefined, - locale: keyof typeof APP_LOCALES, - verifyEmailRedirectPath?: string, - ) { + async sendVerificationEmail({ + userId, + email, + workspace, + locale, + verifyEmailRedirectPath, + verificationTrigger = EmailVerificationTrigger.SIGN_UP, + }: { + userId: string; + email: string; + workspace: WorkspaceDomainConfig | undefined; + locale: keyof typeof APP_LOCALES; + verifyEmailRedirectPath?: string; + verificationTrigger?: EmailVerificationTrigger; + }) { if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) { return { success: false }; } @@ -78,6 +88,8 @@ export class EmailVerificationService { const emailData = { link: verificationLink.toString(), locale, + isEmailUpdate: + verificationTrigger === EmailVerificationTrigger.EMAIL_UPDATE, }; const emailTemplate = SendEmailVerificationLinkEmail(emailData); @@ -87,7 +99,10 @@ export class EmailVerificationService { plainText: true, }); - const emailVerificationMsg = msg`Welcome to Twenty: Please Confirm Your Email`; + const emailVerificationMsg = + verificationTrigger === EmailVerificationTrigger.EMAIL_UPDATE + ? msg`Please confirm your updated email` + : msg`Welcome to Twenty: Please Confirm Your Email`; const i18n = this.i18nService.getI18nInstance(locale); const subject = i18n._(emailVerificationMsg); @@ -116,7 +131,14 @@ export class EmailVerificationService { ); } - const user = await this.userService.findUserByEmailOrThrow(email); + // TODO: Remove the dependency on querying user altogether when the endpoint is authenticated. + const user = await this.userRepository.findOne({ + where: { + email, + }, + }); + + assertIsDefinedOrThrow(user); if (user.isEmailVerified) { throw new EmailVerificationException( @@ -149,7 +171,13 @@ export class EmailVerificationService { await this.appTokenRepository.delete(existingToken.id); } - await this.sendVerificationEmail(user.id, email, workspace, locale); + await this.sendVerificationEmail({ + userId: user.id, + email, + workspace, + locale, + verificationTrigger: EmailVerificationTrigger.SIGN_UP, + }); return { success: true }; } diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts index 82c8686b744..15a32320cf7 100644 --- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts +++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts @@ -12,6 +12,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job'; import { EmailModule } from 'src/engine/core-modules/email/email.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { UpdateWorkspaceMemberEmailJob } from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; import { UserModule } from 'src/engine/core-modules/user/user.module'; import { WebhookJobModule } from 'src/engine/core-modules/webhook/jobs/webhook-job.module'; @@ -74,6 +75,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; UpdateSubscriptionQuantityJob, HandleWorkspaceMemberDeletedJob, CleanWorkspaceDeletionWarningUserVarsJob, + UpdateWorkspaceMemberEmailJob, ], }) export class JobsModule { diff --git a/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts b/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts new file mode 100644 index 00000000000..4a4db6a618d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts @@ -0,0 +1,16 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +@ArgsType() +export class UpdateUserEmailInput { + @Field(() => String) + @IsNotEmpty() + @IsEmail() + newEmail: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + verifyEmailRedirectPath?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts b/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts new file mode 100644 index 00000000000..04dccfddbd0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts @@ -0,0 +1,44 @@ +import { Logger, Scope } from '@nestjs/common'; + +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +export type UpdateWorkspaceMemberEmailJobData = { + userId: string; + email: string; +}; + +@Processor({ + queueName: MessageQueue.workspaceQueue, + scope: Scope.REQUEST, +}) +export class UpdateWorkspaceMemberEmailJob { + private readonly logger = new Logger(UpdateWorkspaceMemberEmailJob.name); + + constructor( + private readonly userWorkspaceService: UserWorkspaceService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + ) {} + + @Process(UpdateWorkspaceMemberEmailJob.name) + async handle({ + userId, + email, + }: UpdateWorkspaceMemberEmailJobData): Promise<void> { + const workspace = + await this.userWorkspaceService.findFirstWorkspaceByUserId(userId); + + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>( + workspace.id, + 'workspaceMember', + { shouldBypassPermissionChecks: true }, + ); + + await workspaceMemberRepository.update({ userId }, { userEmail: email }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts index d2bb5326ae8..430f3f404b9 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts @@ -6,6 +6,9 @@ import { type Repository, type UpdateResult } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; @@ -51,6 +54,21 @@ describe('UserService', () => { provide: WorkspaceService, useValue: { deleteWorkspace: jest.fn() }, }, + { + provide: WorkspaceDomainsService, + useValue: { + getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain: + jest.fn(), + }, + }, + { + provide: EmailVerificationService, + useValue: { sendVerificationEmail: jest.fn() }, + }, + { + provide: `MESSAGE_QUEUE_${MessageQueue.workspaceQueue}`, + useValue: { add: jest.fn() }, + }, { provide: TwentyORMGlobalManager, useValue: { diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 3937c53b55b..78128c2f1d4 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -4,6 +4,7 @@ import assert from 'assert'; import { msg } from '@lingui/core/macro'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; import { type QueryRunner, IsNull, Not, Repository } from 'typeorm'; @@ -12,9 +13,21 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; +import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants'; +import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { + UpdateWorkspaceMemberEmailJob, + UpdateWorkspaceMemberEmailJobData, +} from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { UserExceptionCode } from 'src/engine/core-modules/user/user.exception'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -32,10 +45,14 @@ export class UserService extends TypeOrmQueryService<UserEntity> { constructor( @InjectRepository(UserEntity) private readonly userRepository: Repository<UserEntity>, + private readonly workspaceDomainsService: WorkspaceDomainsService, + private readonly emailVerificationService: EmailVerificationService, private readonly workspaceService: WorkspaceService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly userRoleService: UserRoleService, private readonly userWorkspaceService: UserWorkspaceService, + @InjectMessageQueue(MessageQueue.workspaceQueue) + private readonly workspaceQueueService: MessageQueueService, ) { super(userRepository); } @@ -284,4 +301,94 @@ export class UserService extends TypeOrmQueryService<UserEntity> { ? await queryRunner.manager.save(UserEntity, user) : await this.userRepository.save(user); } + + async updateEmailFromVerificationToken(userId: string, email: string) { + const user = await this.findUserByIdOrThrow(userId); + + user.email = email; + + const updatedUser = await this.userRepository.save(user); + + await this.enqueueWorkspaceMemberEmailUpdate({ + userId: user.id, + email, + }); + + return updatedUser; + } + + async updateUserEmail({ + user, + workspace, + newEmail, + verifyEmailRedirectPath, + }: { + user: UserEntity; + workspace: WorkspaceEntity; + newEmail: string; + verifyEmailRedirectPath?: string; + }): Promise<void> { + const normalizedEmail = newEmail.trim().toLowerCase(); + + if (normalizedEmail === user.email) { + throw new UserInputError( + 'New email must be different from current email', + { + subCode: UserExceptionCode.EMAIL_UNCHANGED, + userFriendlyMessage: msg`New email must be different from current email`, + }, + ); + } + + const userWorkspaceCount = + await this.userWorkspaceService.countUserWorkspaces(user.id); + + if (userWorkspaceCount > 1) { + throw new UserInputError( + 'Email updates are available only for users with a single workspace', + { + subCode: + UserExceptionCode.EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE, + userFriendlyMessage: msg`Email can only be updated when you belong to a single workspace.`, + }, + ); + } + + const existingUser = await this.userRepository.findOne({ + where: { email: normalizedEmail }, + }); + + if (existingUser && existingUser.id !== user.id) { + throw new UserInputError('Email already in use', { + subCode: UserExceptionCode.EMAIL_ALREADY_IN_USE, + userFriendlyMessage: msg`Email already in use`, + }); + } + + const workspaceDomainConfig = + this.workspaceDomainsService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain( + workspace, + ); + + await this.emailVerificationService.sendVerificationEmail({ + userId: user.id, + email: normalizedEmail, + workspace: workspaceDomainConfig, + locale: user.locale || SOURCE_LOCALE, + verifyEmailRedirectPath, + verificationTrigger: EmailVerificationTrigger.EMAIL_UPDATE, + }); + } + + async enqueueWorkspaceMemberEmailUpdate( + data: UpdateWorkspaceMemberEmailJobData, + ) { + await this.workspaceQueueService.add<UpdateWorkspaceMemberEmailJobData>( + UpdateWorkspaceMemberEmailJob.name, + data, + { + retryLimit: 2, + }, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts index 1f0d01ea48a..d4870b646ad 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts @@ -1,7 +1,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { IDField } from '@ptc-org/nestjs-query-graphql'; -import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { BeforeInsert, BeforeUpdate, @@ -95,8 +95,8 @@ export class UserEntity { deletedAt: Date; @Field(() => String, { nullable: false }) - @Column({ nullable: false, default: SOURCE_LOCALE }) - locale: string; + @Column({ nullable: false, default: SOURCE_LOCALE, type: 'varchar' }) + locale: keyof typeof APP_LOCALES; @OneToMany(() => AppTokenEntity, (appToken) => appToken.user, { cascade: true, diff --git a/packages/twenty-server/src/engine/core-modules/user/user.exception.ts b/packages/twenty-server/src/engine/core-modules/user/user.exception.ts index 39363339c29..f88873020ee 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.exception.ts @@ -4,4 +4,7 @@ export class UserException extends CustomException<UserExceptionCode> {} export enum UserExceptionCode { USER_NOT_FOUND = 'USER_NOT_FOUND', + EMAIL_ALREADY_IN_USE = 'EMAIL_ALREADY_IN_USE', + EMAIL_UNCHANGED = 'EMAIL_UNCHANGED', + EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE = 'EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE', } diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index e5fa0c42db0..87ca3b25247 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -22,6 +22,8 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -49,6 +51,8 @@ import { UserService } from './services/user.service'; UserRoleModule, FeatureFlagModule, PermissionsModule, + EmailVerificationModule, + WorkspaceDomainsModule, ], exports: [UserService, WorkspaceMemberTranspiler], providers: [UserService, UserResolver, WorkspaceMemberTranspiler], diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts index e33e789cfcc..0bac4d1f24a 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts @@ -38,6 +38,7 @@ import { buildTwoFactorAuthenticationMethodSummary } from 'src/engine/core-modul import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { DeletedWorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto'; +import { UpdateUserEmailInput } from 'src/engine/core-modules/user/dtos/update-user-email.input'; import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { @@ -56,6 +57,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; @@ -97,7 +99,6 @@ export class UserResolver { private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>, private readonly userRoleService: UserRoleService, private readonly permissionsService: PermissionsService, - private readonly workspaceMemberTranspiler: WorkspaceMemberTranspiler, private readonly userWorkspaceService: UserWorkspaceService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, @@ -512,4 +513,34 @@ export class UserResolver { authProvider, ); } + + @Mutation(() => Boolean) + @UseGuards( + UserAuthGuard, + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.PROFILE_INFORMATION), + ) + async updateUserEmail( + @Args() { newEmail, verifyEmailRedirectPath }: UpdateUserEmailInput, + @AuthUser() user: UserEntity, + @AuthWorkspace() workspace: WorkspaceEntity, + ) { + const editableFields = workspace.editableProfileFields || []; + + if (!editableFields.includes('email')) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + + await this.userService.updateUserEmail({ + user, + workspace, + newEmail, + verifyEmailRedirectPath, + }); + + return true; + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index eef8295e9d5..3d2d1244cc7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -1,6 +1,7 @@ import { Field, InputType } from '@nestjs/graphql'; import { + IsArray, IsBoolean, IsInt, IsOptional, @@ -102,4 +103,10 @@ export class UpdateWorkspaceInput { @IsString() @IsOptional() routerModel?: string; + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + editableProfileFields?: string[]; } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index ac5f3076bbc..e84b9402c80 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -69,6 +69,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> { isGoogleAuthEnabled: PermissionFlagType.SECURITY, isMicrosoftAuthEnabled: PermissionFlagType.SECURITY, isPasswordAuthEnabled: PermissionFlagType.SECURITY, + editableProfileFields: PermissionFlagType.SECURITY, isTwoFactorAuthenticationEnforced: PermissionFlagType.SECURITY, defaultRoleId: PermissionFlagType.ROLES, routerModel: PermissionFlagType.WORKSPACE, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index d9edd1c7055..b06800e9501 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -262,6 +262,15 @@ export class WorkspaceEntity { @Column({ default: false }) isCustomDomainEnabled: boolean; + @Field(() => [String], { nullable: true }) + @Column({ + type: 'varchar', + array: true, + nullable: true, + default: '{email,profilePicture,firstName,lastName}', + }) + editableProfileFields: string[] | null; + // TODO: set as non nullable @Column({ nullable: true, type: 'uuid' }) defaultRoleId: string | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts index aaa47ae5feb..cc15821028c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts @@ -23,4 +23,5 @@ export enum PermissionFlagType { IMPORT_CSV = 'IMPORT_CSV', EXPORT_CSV = 'EXPORT_CSV', CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS', + PROFILE_INFORMATION = 'PROFILE_INFORMATION', } diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts index b42ee0252a7..4fb9521b5b4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts @@ -7,4 +7,5 @@ export const TOOL_PERMISSION_FLAGS = [ 'IMPORT_CSV', 'EXPORT_CSV', 'CONNECTED_ACCOUNTS', + 'PROFILE_INFORMATION', ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index 751cd2ba16d..1ddd0d26436 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -114,6 +114,7 @@ export class PermissionsService { [PermissionFlagType.CONNECTED_ACCOUNTS]: false, [PermissionFlagType.IMPERSONATE]: false, [PermissionFlagType.SSO_BYPASS]: false, + [PermissionFlagType.PROFILE_INFORMATION]: false, }, objectsPermissions: {}, }) as const satisfies UserWorkspacePermissions;