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 c11b01cbca..c9acec0bad 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 9aa68d15d5..35c0712058 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 b25e257873..e5d51ea1fc 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 90c70373c6..7d1306eb66 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 1503dcf8c5..3e57cadecd 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 bff7821e84..8803bed7b9 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 290820d376..d73a41bf65 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 69b3120fe4..e8d15079c5 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 f51563fd16..da5beaf609 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 d0194d6c7d..86c36483a3 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 60064c24cf..df5ef7181d 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 0000000000..c957de5bcd --- /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 0000000000..02f389ea86 --- /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 0000000000..c2955aec8b --- /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 d0fe8d84fb..b96b2c748e 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 0000000000..d9f1dbf5ce --- /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 0000000000..d1ee185d94 --- /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 12b838e3e5..f7c12e8e29 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 64452d24a0..2f4feb91c2 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 0000000000..82ced1e3ca --- /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 83b6226cf5..a11ee5e212 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 8a0c125860..bd35bd589a 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 ceea764406..bcad13bec7 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 0000000000..6906cd490f --- /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 4aa2e1a404..86456b6fe0 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 d4381c97c4..09694fa660 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 82c8686b74..15a32320cf 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 0000000000..4a4db6a618 --- /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 0000000000..04dccfddbd --- /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 d2bb5326ae..430f3f404b 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 3937c53b55..78128c2f1d 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 1f0d01ea48..d4870b646a 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 39363339c2..f88873020e 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 e5fa0c42db..87ca3b2524 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 e33e789cfc..0bac4d1f24 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 eef8295e9d..3d2d1244cc 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 ac5f3076bb..e84b9402c8 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 d9edd1c705..b06800e950 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 aaa47ae5fe..cc15821028 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 b42ee0252a..4fb9521b5b 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 751cd2ba16..1ddd0d2643 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;