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