Răsfoiți Sursa

feat(core): Create workflow for updating a Customer email address

Relates to #87
Michael Bromley 6 ani în urmă
părinte
comite
f8065deb09

+ 13 - 3
packages/common/src/generated-shop-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-04-01T11:03:11+02:00
+// Generated in 2019-04-09T14:28:32+02:00
 export type Maybe<T> = T | null;
 
 export interface OrderListOptions {
@@ -317,8 +317,6 @@ export interface UpdateCustomerInput {
 
     phoneNumber?: Maybe<string>;
 
-    emailAddress?: Maybe<string>;
-
     customFields?: Maybe<Json>;
 }
 
@@ -1628,6 +1626,10 @@ export interface Mutation {
     verifyCustomerAccount: LoginResult;
     /** Update the password of the active Customer */
     updateCustomerPassword?: Maybe<boolean>;
+    /** Request to update the emailAddress of the active Customer */
+    requestUpdateCustomerEmailAddress?: Maybe<boolean>;
+    /** Confirm the update of the emailAddress with the provided token */
+    updateCustomerEmailAddress?: Maybe<boolean>;
     /** Requests a password reset email to be sent */
     requestPasswordReset?: Maybe<boolean>;
     /** Resets a Customer's password based on the provided token */
@@ -1886,6 +1888,14 @@ export interface UpdateCustomerPasswordMutationArgs {
 
     newPassword: string;
 }
+export interface RequestUpdateCustomerEmailAddressMutationArgs {
+    password: string;
+
+    newEmailAddress: string;
+}
+export interface UpdateCustomerEmailAddressMutationArgs {
+    token: string;
+}
 export interface RequestPasswordResetMutationArgs {
     emailAddress: string;
 }

+ 262 - 262
packages/common/src/generated-types.ts

@@ -1,5 +1,5 @@
 // tslint:disable
-// Generated in 2019-04-01T11:03:13+02:00
+// Generated in 2019-04-09T14:28:32+02:00
 export type Maybe<T> = T | null;
 
 
@@ -258,6 +258,43 @@ export interface CountryFilterParameter {
   enabled?: Maybe<BooleanOperators>;
 }
 
+export interface FacetListOptions {
+  
+  skip?: Maybe<number>;
+  
+  take?: Maybe<number>;
+  
+  sort?: Maybe<FacetSortParameter>;
+  
+  filter?: Maybe<FacetFilterParameter>;
+}
+
+export interface FacetSortParameter {
+  
+  id?: Maybe<SortOrder>;
+  
+  createdAt?: Maybe<SortOrder>;
+  
+  updatedAt?: Maybe<SortOrder>;
+  
+  name?: Maybe<SortOrder>;
+  
+  code?: Maybe<SortOrder>;
+}
+
+export interface FacetFilterParameter {
+  
+  createdAt?: Maybe<DateOperators>;
+  
+  updatedAt?: Maybe<DateOperators>;
+  
+  languageCode?: Maybe<StringOperators>;
+  
+  name?: Maybe<StringOperators>;
+  
+  code?: Maybe<StringOperators>;
+}
+
 export interface CustomerListOptions {
   
   skip?: Maybe<number>;
@@ -364,18 +401,18 @@ export interface OrderFilterParameter {
   total?: Maybe<NumberOperators>;
 }
 
-export interface FacetListOptions {
+export interface PaymentMethodListOptions {
   
   skip?: Maybe<number>;
   
   take?: Maybe<number>;
   
-  sort?: Maybe<FacetSortParameter>;
+  sort?: Maybe<PaymentMethodSortParameter>;
   
-  filter?: Maybe<FacetFilterParameter>;
+  filter?: Maybe<PaymentMethodFilterParameter>;
 }
 
-export interface FacetSortParameter {
+export interface PaymentMethodSortParameter {
   
   id?: Maybe<SortOrder>;
   
@@ -383,36 +420,32 @@ export interface FacetSortParameter {
   
   updatedAt?: Maybe<SortOrder>;
   
-  name?: Maybe<SortOrder>;
-  
   code?: Maybe<SortOrder>;
 }
 
-export interface FacetFilterParameter {
+export interface PaymentMethodFilterParameter {
   
   createdAt?: Maybe<DateOperators>;
   
   updatedAt?: Maybe<DateOperators>;
   
-  languageCode?: Maybe<StringOperators>;
-  
-  name?: Maybe<StringOperators>;
-  
   code?: Maybe<StringOperators>;
+  
+  enabled?: Maybe<BooleanOperators>;
 }
 
-export interface PaymentMethodListOptions {
+export interface ProductListOptions {
   
   skip?: Maybe<number>;
   
   take?: Maybe<number>;
   
-  sort?: Maybe<PaymentMethodSortParameter>;
+  sort?: Maybe<ProductSortParameter>;
   
-  filter?: Maybe<PaymentMethodFilterParameter>;
+  filter?: Maybe<ProductFilterParameter>;
 }
 
-export interface PaymentMethodSortParameter {
+export interface ProductSortParameter {
   
   id?: Maybe<SortOrder>;
   
@@ -420,18 +453,26 @@ export interface PaymentMethodSortParameter {
   
   updatedAt?: Maybe<SortOrder>;
   
-  code?: Maybe<SortOrder>;
+  name?: Maybe<SortOrder>;
+  
+  slug?: Maybe<SortOrder>;
+  
+  description?: Maybe<SortOrder>;
 }
 
-export interface PaymentMethodFilterParameter {
+export interface ProductFilterParameter {
   
   createdAt?: Maybe<DateOperators>;
   
   updatedAt?: Maybe<DateOperators>;
   
-  code?: Maybe<StringOperators>;
+  languageCode?: Maybe<StringOperators>;
   
-  enabled?: Maybe<BooleanOperators>;
+  name?: Maybe<StringOperators>;
+  
+  slug?: Maybe<StringOperators>;
+  
+  description?: Maybe<StringOperators>;
 }
 
 export interface SearchInput {
@@ -458,47 +499,6 @@ export interface SearchResultSortParameter {
   price?: Maybe<SortOrder>;
 }
 
-export interface ProductListOptions {
-  
-  skip?: Maybe<number>;
-  
-  take?: Maybe<number>;
-  
-  sort?: Maybe<ProductSortParameter>;
-  
-  filter?: Maybe<ProductFilterParameter>;
-}
-
-export interface ProductSortParameter {
-  
-  id?: Maybe<SortOrder>;
-  
-  createdAt?: Maybe<SortOrder>;
-  
-  updatedAt?: Maybe<SortOrder>;
-  
-  name?: Maybe<SortOrder>;
-  
-  slug?: Maybe<SortOrder>;
-  
-  description?: Maybe<SortOrder>;
-}
-
-export interface ProductFilterParameter {
-  
-  createdAt?: Maybe<DateOperators>;
-  
-  updatedAt?: Maybe<DateOperators>;
-  
-  languageCode?: Maybe<StringOperators>;
-  
-  name?: Maybe<StringOperators>;
-  
-  slug?: Maybe<StringOperators>;
-  
-  description?: Maybe<StringOperators>;
-}
-
 export interface PromotionListOptions {
   
   skip?: Maybe<number>;
@@ -821,6 +821,79 @@ export interface UpdateCustomerGroupInput {
   name?: Maybe<string>;
 }
 
+export interface CreateFacetInput {
+  
+  code: string;
+  
+  translations: FacetTranslationInput[];
+  
+  values?: Maybe<CreateFacetValueWithFacetInput[]>;
+  
+  customFields?: Maybe<Json>;
+}
+
+export interface FacetTranslationInput {
+  
+  id?: Maybe<string>;
+  
+  languageCode: LanguageCode;
+  
+  name?: Maybe<string>;
+  
+  customFields?: Maybe<Json>;
+}
+
+export interface CreateFacetValueWithFacetInput {
+  
+  code: string;
+  
+  translations: FacetValueTranslationInput[];
+}
+
+export interface FacetValueTranslationInput {
+  
+  id?: Maybe<string>;
+  
+  languageCode: LanguageCode;
+  
+  name?: Maybe<string>;
+  
+  customFields?: Maybe<Json>;
+}
+
+export interface UpdateFacetInput {
+  
+  id: string;
+  
+  code?: Maybe<string>;
+  
+  translations?: Maybe<FacetTranslationInput[]>;
+  
+  customFields?: Maybe<Json>;
+}
+
+export interface CreateFacetValueInput {
+  
+  facetId: string;
+  
+  code: string;
+  
+  translations: FacetValueTranslationInput[];
+  
+  customFields?: Maybe<Json>;
+}
+
+export interface UpdateFacetValueInput {
+  
+  id: string;
+  
+  code?: Maybe<string>;
+  
+  translations?: Maybe<FacetValueTranslationInput[]>;
+  
+  customFields?: Maybe<Json>;
+}
+
 export interface CreateCustomerInput {
   
   title?: Maybe<string>;
@@ -909,79 +982,6 @@ export interface UpdateAddressInput {
   customFields?: Maybe<Json>;
 }
 
-export interface CreateFacetInput {
-  
-  code: string;
-  
-  translations: FacetTranslationInput[];
-  
-  values?: Maybe<CreateFacetValueWithFacetInput[]>;
-  
-  customFields?: Maybe<Json>;
-}
-
-export interface FacetTranslationInput {
-  
-  id?: Maybe<string>;
-  
-  languageCode: LanguageCode;
-  
-  name?: Maybe<string>;
-  
-  customFields?: Maybe<Json>;
-}
-
-export interface CreateFacetValueWithFacetInput {
-  
-  code: string;
-  
-  translations: FacetValueTranslationInput[];
-}
-
-export interface FacetValueTranslationInput {
-  
-  id?: Maybe<string>;
-  
-  languageCode: LanguageCode;
-  
-  name?: Maybe<string>;
-  
-  customFields?: Maybe<Json>;
-}
-
-export interface UpdateFacetInput {
-  
-  id: string;
-  
-  code?: Maybe<string>;
-  
-  translations?: Maybe<FacetTranslationInput[]>;
-  
-  customFields?: Maybe<Json>;
-}
-
-export interface CreateFacetValueInput {
-  
-  facetId: string;
-  
-  code: string;
-  
-  translations: FacetValueTranslationInput[];
-  
-  customFields?: Maybe<Json>;
-}
-
-export interface UpdateFacetValueInput {
-  
-  id: string;
-  
-  code?: Maybe<string>;
-  
-  translations?: Maybe<FacetValueTranslationInput[]>;
-  
-  customFields?: Maybe<Json>;
-}
-
 export interface UpdateGlobalSettingsInput {
   
   availableLanguages?: Maybe<LanguageCode[]>;
@@ -4546,14 +4546,14 @@ export interface Query {
   
   customerGroup?: Maybe<CustomerGroup>;
   
-  customers: CustomerList;
-  
-  customer?: Maybe<Customer>;
-  
   facets: FacetList;
   
   facet?: Maybe<Facet>;
   
+  customers: CustomerList;
+  
+  customer?: Maybe<Customer>;
+  
   globalSettings: GlobalSettings;
   
   order?: Maybe<Order>;
@@ -4568,12 +4568,12 @@ export interface Query {
   
   productOptionGroup?: Maybe<ProductOptionGroup>;
   
-  search: SearchResponse;
-  
   products: ProductList;
   
   product?: Maybe<Product>;
   
+  search: SearchResponse;
+  
   promotion?: Maybe<Promotion>;
   
   promotions: PromotionList;
@@ -5094,6 +5094,14 @@ export interface CountryList extends PaginatedList {
 }
 
 
+export interface FacetList extends PaginatedList {
+  
+  items: Facet[];
+  
+  totalItems: number;
+}
+
+
 export interface CustomerList extends PaginatedList {
   
   items: Customer[];
@@ -5336,14 +5344,6 @@ export interface ShippingMethod extends Node {
 }
 
 
-export interface FacetList extends PaginatedList {
-  
-  items: Facet[];
-  
-  totalItems: number;
-}
-
-
 export interface GlobalSettings {
   
   id: string;
@@ -5426,6 +5426,66 @@ export interface ProductOptionGroupTranslation {
 }
 
 
+export interface ProductList extends PaginatedList {
+  
+  items: Product[];
+  
+  totalItems: number;
+}
+
+
+export interface Product extends Node {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode: LanguageCode;
+  
+  name: string;
+  
+  slug: string;
+  
+  description: string;
+  
+  featuredAsset?: Maybe<Asset>;
+  
+  assets: Asset[];
+  
+  variants: ProductVariant[];
+  
+  optionGroups: ProductOptionGroup[];
+  
+  facetValues: FacetValue[];
+  
+  translations: ProductTranslation[];
+  
+  collections: Collection[];
+  
+  customFields?: Maybe<Json>;
+}
+
+
+export interface ProductTranslation {
+  
+  id: string;
+  
+  createdAt: DateTime;
+  
+  updatedAt: DateTime;
+  
+  languageCode: LanguageCode;
+  
+  name: string;
+  
+  slug: string;
+  
+  description: string;
+}
+
+
 export interface SearchResponse {
   
   items: SearchResult[];
@@ -5494,66 +5554,6 @@ export interface FacetValueResult {
 }
 
 
-export interface ProductList extends PaginatedList {
-  
-  items: Product[];
-  
-  totalItems: number;
-}
-
-
-export interface Product extends Node {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode: LanguageCode;
-  
-  name: string;
-  
-  slug: string;
-  
-  description: string;
-  
-  featuredAsset?: Maybe<Asset>;
-  
-  assets: Asset[];
-  
-  variants: ProductVariant[];
-  
-  optionGroups: ProductOptionGroup[];
-  
-  facetValues: FacetValue[];
-  
-  translations: ProductTranslation[];
-  
-  collections: Collection[];
-  
-  customFields?: Maybe<Json>;
-}
-
-
-export interface ProductTranslation {
-  
-  id: string;
-  
-  createdAt: DateTime;
-  
-  updatedAt: DateTime;
-  
-  languageCode: LanguageCode;
-  
-  name: string;
-  
-  slug: string;
-  
-  description: string;
-}
-
-
 export interface Promotion extends Node {
   
   id: string;
@@ -5671,18 +5671,6 @@ export interface Mutation {
   addCustomersToGroup: CustomerGroup;
   /** Remove Customers from a CustomerGroup */
   removeCustomersFromGroup: CustomerGroup;
-  /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
-  createCustomer: Customer;
-  /** Update an existing Customer */
-  updateCustomer: Customer;
-  /** Delete a Customer */
-  deleteCustomer: DeletionResponse;
-  /** Create a new Address and associate it with the Customer specified by customerId */
-  createCustomerAddress: Address;
-  /** Update an existing Address */
-  updateCustomerAddress: Address;
-  /** Update an existing Address */
-  deleteCustomerAddress: boolean;
   /** Create a new Facet */
   createFacet: Facet;
   /** Update an existing Facet */
@@ -5695,6 +5683,18 @@ export interface Mutation {
   updateFacetValues: FacetValue[];
   /** Delete one or more FacetValues */
   deleteFacetValues: DeletionResponse[];
+  /** Create a new Customer. If a password is provided, a new User will also be created an linked to the Customer. */
+  createCustomer: Customer;
+  /** Update an existing Customer */
+  updateCustomer: Customer;
+  /** Delete a Customer */
+  deleteCustomer: DeletionResponse;
+  /** Create a new Address and associate it with the Customer specified by customerId */
+  createCustomerAddress: Address;
+  /** Update an existing Address */
+  updateCustomerAddress: Address;
+  /** Update an existing Address */
+  deleteCustomerAddress: boolean;
   
   updateGlobalSettings: GlobalSettings;
   
@@ -5705,8 +5705,6 @@ export interface Mutation {
   createProductOptionGroup: ProductOptionGroup;
   /** Update an existing ProductOptionGroup */
   updateProductOptionGroup: ProductOptionGroup;
-  
-  reindex: SearchReindexResponse;
   /** Create a new Product */
   createProduct: Product;
   /** Update an existing Product */
@@ -5722,6 +5720,8 @@ export interface Mutation {
   /** Update existing ProductVariants */
   updateProductVariants: (Maybe<ProductVariant>)[];
   
+  reindex: SearchReindexResponse;
+  
   createPromotion: Promotion;
   
   updatePromotion: Promotion;
@@ -5859,14 +5859,6 @@ export interface CustomerGroupQueryArgs {
   
   id: string;
 }
-export interface CustomersQueryArgs {
-  
-  options?: Maybe<CustomerListOptions>;
-}
-export interface CustomerQueryArgs {
-  
-  id: string;
-}
 export interface FacetsQueryArgs {
   
   languageCode?: Maybe<LanguageCode>;
@@ -5879,6 +5871,14 @@ export interface FacetQueryArgs {
   
   languageCode?: Maybe<LanguageCode>;
 }
+export interface CustomersQueryArgs {
+  
+  options?: Maybe<CustomerListOptions>;
+}
+export interface CustomerQueryArgs {
+  
+  id: string;
+}
 export interface OrderQueryArgs {
   
   id: string;
@@ -5907,10 +5907,6 @@ export interface ProductOptionGroupQueryArgs {
   
   languageCode?: Maybe<LanguageCode>;
 }
-export interface SearchQueryArgs {
-  
-  input: SearchInput;
-}
 export interface ProductsQueryArgs {
   
   languageCode?: Maybe<LanguageCode>;
@@ -5923,6 +5919,10 @@ export interface ProductQueryArgs {
   
   languageCode?: Maybe<LanguageCode>;
 }
+export interface SearchQueryArgs {
+  
+  input: SearchInput;
+}
 export interface PromotionQueryArgs {
   
   id: string;
@@ -6049,34 +6049,6 @@ export interface RemoveCustomersFromGroupMutationArgs {
   
   customerIds: string[];
 }
-export interface CreateCustomerMutationArgs {
-  
-  input: CreateCustomerInput;
-  
-  password?: Maybe<string>;
-}
-export interface UpdateCustomerMutationArgs {
-  
-  input: UpdateCustomerInput;
-}
-export interface DeleteCustomerMutationArgs {
-  
-  id: string;
-}
-export interface CreateCustomerAddressMutationArgs {
-  
-  customerId: string;
-  
-  input: CreateAddressInput;
-}
-export interface UpdateCustomerAddressMutationArgs {
-  
-  input: UpdateAddressInput;
-}
-export interface DeleteCustomerAddressMutationArgs {
-  
-  id: string;
-}
 export interface CreateFacetMutationArgs {
   
   input: CreateFacetInput;
@@ -6105,6 +6077,34 @@ export interface DeleteFacetValuesMutationArgs {
   
   force?: Maybe<boolean>;
 }
+export interface CreateCustomerMutationArgs {
+  
+  input: CreateCustomerInput;
+  
+  password?: Maybe<string>;
+}
+export interface UpdateCustomerMutationArgs {
+  
+  input: UpdateCustomerInput;
+}
+export interface DeleteCustomerMutationArgs {
+  
+  id: string;
+}
+export interface CreateCustomerAddressMutationArgs {
+  
+  customerId: string;
+  
+  input: CreateAddressInput;
+}
+export interface UpdateCustomerAddressMutationArgs {
+  
+  input: UpdateAddressInput;
+}
+export interface DeleteCustomerAddressMutationArgs {
+  
+  id: string;
+}
 export interface UpdateGlobalSettingsMutationArgs {
   
   input: UpdateGlobalSettingsInput;

+ 206 - 1
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -7,9 +7,12 @@ import path from 'path';
 
 import { CREATE_ADMINISTRATOR, CREATE_ROLE } from '../../../admin-ui/src/app/data/definitions/administrator-definitions';
 import { GET_CUSTOMER } from '../../../admin-ui/src/app/data/definitions/customer-definitions';
+import { pick } from '../../common/lib/pick';
 import { InjectorFn, VendurePlugin } from '../src/config/vendure-plugin/vendure-plugin';
 import { EventBus } from '../src/event-bus/event-bus';
 import { AccountRegistrationEvent } from '../src/event-bus/events/account-registration-event';
+import { IdentifierChangeEvent } from '../src/event-bus/events/identifier-change-event';
+import { IdentifierChangeRequestEvent } from '../src/event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../src/event-bus/events/password-reset-event';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
@@ -28,7 +31,7 @@ describe('Shop auth & accounts', () => {
         const token = await server.init(
             {
                 productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
+                customerCount: 2,
             },
             {
                 plugins: [new TestEmailPlugin()],
@@ -245,6 +248,121 @@ describe('Shop auth & accounts', () => {
         });
     });
 
+    describe('updating emailAddress', () => {
+        let emailUpdateToken: string;
+        let customer: GetCustomer.Customer;
+        const NEW_EMAIL_ADDRESS = 'new@address.com';
+        const PASSWORD = 'newPassword';
+
+        beforeAll(async () => {
+            const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, { id: 'T_1' });
+            customer = result.customer!;
+        });
+
+        beforeEach(() => {
+            sendEmailFn = jest.fn();
+        });
+
+        it('throws if not logged in', async () => {
+            try {
+                await shopClient.asAnonymousUser();
+                await shopClient.query(REQUEST_UPDATE_EMAIL_ADDRESS, {
+                    password: PASSWORD,
+                    newEmailAddress: NEW_EMAIL_ADDRESS,
+                });
+                fail('should have thrown');
+            } catch (err) {
+                expect(getErrorCode(err)).toBe('FORBIDDEN');
+            }
+        });
+
+        it('throws if password is incorrect', async () => {
+            try {
+                await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
+                await shopClient.query(REQUEST_UPDATE_EMAIL_ADDRESS, {
+                    password: 'bad password',
+                    newEmailAddress: NEW_EMAIL_ADDRESS,
+                });
+                fail('should have thrown');
+            } catch (err) {
+                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
+            }
+        });
+
+        it('throws if email address already in use', assertThrowsWithMessage(
+            async () => {
+                await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
+                const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, { id: 'T_2' });
+                const otherCustomer = result.customer!;
+
+                await shopClient.query(REQUEST_UPDATE_EMAIL_ADDRESS, {
+                    password: PASSWORD,
+                    newEmailAddress: otherCustomer.emailAddress,
+                });
+            },
+            'This email address is not available',
+            ),
+        );
+
+        it('triggers event with token', async () => {
+            await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
+            const emailUpdateTokenPromise = getEmailUpdateTokenPromise();
+
+            await shopClient.query(REQUEST_UPDATE_EMAIL_ADDRESS, {
+                password: PASSWORD,
+                newEmailAddress: NEW_EMAIL_ADDRESS,
+            });
+
+            const { identifierChangeToken, pendingIdentifier } = await emailUpdateTokenPromise;
+            emailUpdateToken = identifierChangeToken!;
+
+            expect(pendingIdentifier).toBe(NEW_EMAIL_ADDRESS);
+            expect(emailUpdateToken).toBeTruthy();
+        });
+
+        it('cannot login with new email address before verification', async () => {
+            try {
+                await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
+                fail('should have thrown');
+            } catch (err) {
+                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
+            }
+        });
+
+        it('throws with bad token', assertThrowsWithMessage(
+            async () => {
+                await shopClient.query(UPDATE_EMAIL_ADDRESS, { token: 'bad token' });
+            },
+            'Identifier change token not recognized',
+            ),
+        );
+
+        it('verify the new email address', async () => {
+            const result = await shopClient.query(UPDATE_EMAIL_ADDRESS, { token: emailUpdateToken });
+            expect(result.updateCustomerEmailAddress).toBe(true);
+
+            expect(sendEmailFn).toHaveBeenCalled();
+            expect(sendEmailFn.mock.calls[0][0] instanceof IdentifierChangeEvent).toBe(true);
+        });
+
+        it('can login with new email address after verification', async () => {
+            await shopClient.asUserWithCredentials(NEW_EMAIL_ADDRESS, PASSWORD);
+            const { activeCustomer } = await shopClient.query(GET_ACTIVE_CUSTOMER);
+            expect(activeCustomer.id).toBe(customer.id);
+            expect(activeCustomer.emailAddress).toBe(NEW_EMAIL_ADDRESS);
+        });
+
+        it('cannot login with old email address after verification', async () => {
+            try {
+                await shopClient.asUserWithCredentials(customer.emailAddress, PASSWORD);
+                fail('should have thrown');
+            } catch (err) {
+                expect(getErrorCode(err)).toBe('UNAUTHORIZED');
+            }
+        });
+
+    });
+
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
         try {
             const status = await shopClient.queryStatus(operation, variables);
@@ -474,6 +592,58 @@ describe('Registration without email verification', () => {
     });
 });
 
+describe('Updating email address without email verification', () => {
+    const shopClient = new TestShopClient();
+    const adminClient = new TestAdminClient();
+    const server = new TestServer();
+    let customer: GetCustomer.Customer;
+    const NEW_EMAIL_ADDRESS = 'new@address.com';
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
+            },
+            {
+                plugins: [new TestEmailPlugin()],
+                authOptions: {
+                    requireVerification: false,
+                },
+            },
+        );
+        await shopClient.init();
+        await adminClient.init();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    beforeAll(async () => {
+        const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, { id: 'T_1' });
+        customer = result.customer!;
+    });
+
+    beforeEach(() => {
+        sendEmailFn = jest.fn();
+    });
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('updates email address', async () => {
+        await shopClient.asUserWithCredentials(customer.emailAddress, 'test');
+        const { requestUpdateCustomerEmailAddress } = await shopClient.query(REQUEST_UPDATE_EMAIL_ADDRESS, {
+            password: 'test',
+            newEmailAddress: NEW_EMAIL_ADDRESS,
+        });
+
+        expect(requestUpdateCustomerEmailAddress).toBe(true);
+        expect(sendEmailFn).toHaveBeenCalledTimes(1);
+        expect(sendEmailFn.mock.calls[0][0] instanceof IdentifierChangeEvent).toBe(true);
+
+        const { activeCustomer } = await shopClient.query(GET_ACTIVE_CUSTOMER);
+        expect(activeCustomer.emailAddress).toBe(NEW_EMAIL_ADDRESS);
+    });
+});
 
 /**
  * This mock plugin simulates an EmailPlugin which would send emails
@@ -488,6 +658,12 @@ class TestEmailPlugin implements VendurePlugin {
         eventBus.subscribe(PasswordResetEvent, event => {
             sendEmailFn(event);
         });
+        eventBus.subscribe(IdentifierChangeRequestEvent, event => {
+            sendEmailFn(event);
+        });
+        eventBus.subscribe(IdentifierChangeEvent, event => {
+            sendEmailFn(event);
+        });
     }
 }
 
@@ -507,6 +683,14 @@ function getPasswordResetTokenPromise(): Promise<string> {
     });
 }
 
+function getEmailUpdateTokenPromise(): Promise<{ identifierChangeToken: string | null; pendingIdentifier: string | null; }> {
+    return new Promise(resolve => {
+        sendEmailFn.mockImplementation((event: IdentifierChangeRequestEvent) => {
+            resolve(pick(event.user, ['identifierChangeToken', 'pendingIdentifier']));
+        });
+    });
+}
+
 const REGISTER_ACCOUNT = gql`
     mutation Register($input: RegisterCustomerInput!) {
         registerCustomerAccount(input: $input)
@@ -546,3 +730,24 @@ const RESET_PASSWORD = gql`
         }
     }
 `;
+
+const REQUEST_UPDATE_EMAIL_ADDRESS = gql`
+    mutation RequestUpdateEmailAddress($password: String! $newEmailAddress: String!) {
+        requestUpdateCustomerEmailAddress(password: $password, newEmailAddress: $newEmailAddress)
+    }
+`;
+
+const UPDATE_EMAIL_ADDRESS = gql`
+    mutation UpdateEmailAddress($token: String!) {
+        updateCustomerEmailAddress(token: $token)
+    }
+`;
+
+const GET_ACTIVE_CUSTOMER = gql`
+    query {
+        activeCustomer {
+            id
+            emailAddress
+        }
+    }
+`;

+ 1 - 5
packages/core/src/api/resolvers/base/base-auth.resolver.ts

@@ -89,11 +89,7 @@ export class BaseAuthResolver {
         if (!activeUserId) {
             throw new InternalServerError(`error.no-active-user-id`);
         }
-        const user = await this.userService.getUserById(activeUserId);
-        if (!user) {
-            throw new InternalServerError(`error.no-active-user-id`);
-        }
-        return this.userService.updatePassword(user, currentPassword, newPassword);
+        return this.userService.updatePassword(activeUserId, currentPassword, newPassword);
     }
 
     /**

+ 27 - 1
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -12,7 +12,11 @@ import {
 } from '@vendure/common/lib/generated-shop-types';
 import { Request, Response } from 'express';
 
-import { PasswordResetTokenError, VerificationTokenError } from '../../../common/error/errors';
+import {
+    RequestUpdateCustomerEmailAddressMutationArgs,
+    UpdateCustomerEmailAddressMutationArgs,
+} from '../../../../../common/src/generated-shop-types';
+import { ForbiddenError, PasswordResetTokenError, VerificationTokenError } from '../../../common/error/errors';
 import { ConfigService } from '../../../config/config.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
@@ -139,4 +143,26 @@ export class ShopAuthResolver extends BaseAuthResolver {
     ): Promise<boolean> {
         return super.updatePassword(ctx, args.currentPassword, args.newPassword);
     }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async requestUpdateCustomerEmailAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: RequestUpdateCustomerEmailAddressMutationArgs,
+    ): Promise<boolean> {
+        if (!ctx.activeUserId) {
+            throw new ForbiddenError();
+        }
+        await this.authService.verifyUserPassword(ctx.activeUserId, args.password);
+        return this.customerService.requestUpdateEmailAddress(ctx, ctx.activeUserId, args.newEmailAddress);
+    }
+
+    @Mutation()
+    @Allow(Permission.Owner)
+    async updateCustomerEmailAddress(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdateCustomerEmailAddressMutationArgs,
+    ): Promise<boolean> {
+        return this.customerService.updateEmailAddress(ctx, args.token);
+    }
 }

+ 12 - 1
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -42,6 +42,18 @@ type Mutation {
     verifyCustomerAccount(token: String!, password: String!): LoginResult!
     "Update the password of the active Customer"
     updateCustomerPassword(currentPassword: String!, newPassword: String!): Boolean
+    """
+    Request to update the emailAddress of the active Customer. If `authOptions.requireVerification` is enabled
+    (as is the default), then the `identifierChangeToken` will be assigned to the current User and
+    a IdentifierChangeRequestEvent will be raised. This can then be used e.g. by the EmailPlugin to email
+    that verification token to the Customer, which is then used to verify the change of email address.
+    """
+    requestUpdateCustomerEmailAddress(password: String!, newEmailAddress: String!): Boolean
+    """
+    Confirm the update of the emailAddress with the provided token, which has been generated by the
+    `requestUpdateCustomerEmailAddress` mutation.
+    """
+    updateCustomerEmailAddress(token: String!): Boolean
     "Requests a password reset email to be sent"
     requestPasswordReset(emailAddress: String!): Boolean
     "Resets a Customer's password based on the provided token"
@@ -61,7 +73,6 @@ input UpdateCustomerInput {
     firstName: String
     lastName: String
     phoneNumber: String
-    emailAddress: String
 }
 
 input PaymentInput {

+ 12 - 0
packages/core/src/common/error/errors.ts

@@ -75,6 +75,18 @@ export class PasswordResetTokenExpiredError extends I18nError {
     }
 }
 
+export class IdentifierChangeTokenError extends I18nError {
+    constructor() {
+        super('error.identifier-change-token-not-recognized', {}, 'EXPIRED_IDENTIFIER_CHANGE_TOKEN');
+    }
+}
+
+export class IdentifierChangeTokenExpiredError extends I18nError {
+    constructor() {
+        super('error.identifier-change-token-has-expired', {}, 'EXPIRED_IDENTIFIER_CHANGE_TOKEN');
+    }
+}
+
 export class NotVerifiedError extends I18nError {
     constructor() {
         super('error.email-address-not-verified', {}, 'NOT_VERIFIED');

+ 18 - 1
packages/core/src/entity/user/user.entity.ts

@@ -21,7 +21,7 @@ export class User extends VendureEntity implements HasCustomFields {
     @Column({ unique: true })
     identifier: string;
 
-    @Column() passwordHash: string;
+    @Column({ select: false }) passwordHash: string;
 
     @Column({ default: false })
     verified: boolean;
@@ -32,6 +32,23 @@ export class User extends VendureEntity implements HasCustomFields {
     @Column({ type: 'varchar', nullable: true })
     passwordResetToken: string | null;
 
+    /**
+     * @description
+     * A token issued when a User requests to change their identifier (typically
+     * an email address)
+     */
+    @Column({ type: 'varchar', nullable: true })
+    identifierChangeToken: string | null;
+
+    /**
+     * @description
+     * When a request has been made to change the User's identifier, the new identifier
+     * will be stored here until it has been verfified, after which it will
+     * replace the current value of the `identifier` field.
+     */
+    @Column({ type: 'varchar', nullable: true })
+    pendingIdentifier: string | null;
+
     @ManyToMany(type => Role)
     @JoinTable()
     roles: Role[];

+ 16 - 0
packages/core/src/event-bus/events/identifier-change-event.ts

@@ -0,0 +1,16 @@
+import { RequestContext } from '../../api/common/request-context';
+import { User } from '../../entity/user/user.entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired when a registered user successfully changes the identifier (ie email address)
+ * associated with their account.
+ *
+ * @docsCategory events
+ */
+export class IdentifierChangeEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public user: User, public oldIdentifier: string) {
+        super();
+    }
+}

+ 16 - 0
packages/core/src/event-bus/events/identifier-change-request-event.ts

@@ -0,0 +1,16 @@
+import { RequestContext } from '../../api/common/request-context';
+import { User } from '../../entity/user/user.entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired when a registered user requests to update the identifier (ie email address)
+ * associated with the account.
+ *
+ * @docsCategory events
+ */
+export class IdentifierChangeRequestEvent extends VendureEvent {
+    constructor(public ctx: RequestContext, public user: User) {
+        super();
+    }
+}

+ 2 - 0
packages/core/src/event-bus/index.ts

@@ -3,6 +3,8 @@ export * from './vendure-event';
 export * from './events/account-registration-event';
 export * from './events/catalog-modification-event';
 export * from './events/collection-modification-event';
+export * from './events/identifier-change-event';
+export * from './events/identifier-change-request-event';
 export * from './events/order-state-transition-event';
 export * from './events/password-reset-event';
 export * from './events/tax-rate-modification-event';

+ 3 - 0
packages/core/src/i18n/messages/en.json

@@ -7,10 +7,13 @@
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "channel-not-found":  "No channel with the token \"{ token }\" exists",
     "country-code-not-valid":  "The countryCode \"{ countryCode }\" was not recognized",
+    "email-address-not-available": "This email address is not available",
     "email-address-not-verified": "Please verify this email address before logging in",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
     "forbidden": "You are not currently authorized to perform this action",
+    "identifier-change-token-not-recognized": "Identifier change token not recognized",
+    "identifier-change-token-has-expired": "Identifier change token has expired",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
     "missing-password-on-registration": "A password must be provided when `authOptions.requireVerification` is set to \"false\"",
     "no-search-plugin-configured": "No search plugin has been configured",

+ 17 - 4
packages/core/src/service/services/auth.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
+import { ID } from '@vendure/common/lib/shared-types';
 import crypto from 'crypto';
 import ms from 'ms';
 import { Connection } from 'typeorm';
@@ -41,10 +42,7 @@ export class AuthService {
         password: string,
     ): Promise<AuthenticatedSession> {
         const user = await this.getUserFromIdentifier(identifier);
-        const passwordMatches = await this.passwordCipher.check(password, user.passwordHash);
-        if (!passwordMatches) {
-            throw new UnauthorizedError();
-        }
+        await this.verifyUserPassword(user.id, password);
         if (this.configService.authOptions.requireVerification && !user.verified) {
             throw new NotVerifiedError();
         }
@@ -62,6 +60,21 @@ export class AuthService {
         return newSession;
     }
 
+    /**
+     * Verify the provided password against the one we have for the given user.
+     */
+    async verifyUserPassword(userId: ID, password: string): Promise<boolean> {
+        const user = await this.connection.getRepository(User).findOne(userId, { select: ['passwordHash'] });
+        if (!user) {
+            throw new UnauthorizedError();
+        }
+        const passwordMatches = await this.passwordCipher.check(password, user.passwordHash);
+        if (!passwordMatches) {
+            throw new UnauthorizedError();
+        }
+        return true;
+    }
+
     /**
      * Create an anonymous session.
      */

+ 47 - 0
packages/core/src/service/services/customer.service.ts

@@ -19,8 +19,11 @@ import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/ut
 import { ConfigService } from '../../config/config.service';
 import { Address } from '../../entity/address/address.entity';
 import { Customer } from '../../entity/customer/customer.entity';
+import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { AccountRegistrationEvent } from '../../event-bus/events/account-registration-event';
+import { IdentifierChangeEvent } from '../../event-bus/events/identifier-change-event';
+import { IdentifierChangeRequestEvent } from '../../event-bus/events/identifier-change-request-event';
 import { PasswordResetEvent } from '../../event-bus/events/password-reset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -165,6 +168,50 @@ export class CustomerService {
         }
     }
 
+    async requestUpdateEmailAddress(ctx: RequestContext, userId: ID, newEmailAddress: string): Promise<boolean> {
+        const userWithConflictingIdentifier = await this.userService.getUserByEmailAddress(newEmailAddress);
+        if (userWithConflictingIdentifier) {
+            throw new UserInputError('error.email-address-not-available');
+        }
+        const user = await this.userService.getUserById(userId);
+        if (!user) {
+            return false;
+        }
+        if (this.configService.authOptions.requireVerification) {
+            user.pendingIdentifier = newEmailAddress;
+            await this.userService.setIdentifierChangeToken(user);
+            this.eventBus.publish(new IdentifierChangeRequestEvent(ctx, user));
+            return true;
+        } else {
+            const customer = await this.findOneByUserId(user.id);
+            if (!customer) {
+                return false;
+            }
+            const oldIdentifier = user.identifier;
+            user.identifier = newEmailAddress;
+            customer.emailAddress = newEmailAddress;
+            await this.connection.getRepository(User).save(user);
+            await this.connection.getRepository(Customer).save(customer);
+            this.eventBus.publish(new IdentifierChangeEvent(ctx, user, oldIdentifier));
+            return true;
+        }
+    }
+
+    async updateEmailAddress(ctx: RequestContext, token: string): Promise<boolean> {
+        const { user, oldIdentifier } = await this.userService.changeIdentifierByToken(token);
+        if (!user) {
+            return false;
+        }
+        const customer = await this.findOneByUserId(user.id);
+        if (!customer) {
+            return false;
+        }
+        this.eventBus.publish(new IdentifierChangeEvent(ctx, user, oldIdentifier));
+        customer.emailAddress = user.identifier;
+        await this.connection.getRepository(Customer).save(customer);
+        return true;
+    }
+
     async update(input: UpdateCustomerInput): Promise<Customer> {
         const customer = await getEntityOrThrow(this.connection, Customer, input.id);
         const updatedCustomer = patchEntity(customer, input);

+ 35 - 1
packages/core/src/service/services/user.service.ts

@@ -4,6 +4,9 @@ import { ID } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
 import {
+    IdentifierChangeTokenError,
+    IdentifierChangeTokenExpiredError,
+    InternalServerError,
     PasswordResetTokenExpiredError,
     UnauthorizedError,
     VerificationTokenExpiredError,
@@ -114,7 +117,33 @@ export class UserService {
         }
     }
 
-    async updatePassword(user: User, currentPassword: string, newPassword: string): Promise<boolean> {
+    async changeIdentifierByToken(token: string): Promise<{ user: User; oldIdentifier: string; }> {
+        const user = await this.connection.getRepository(User).findOne({
+            where: { identifierChangeToken: token },
+        });
+        if (!user) {
+            throw new IdentifierChangeTokenError();
+        }
+        if (!this.verificationTokenGenerator.verifyVerificationToken(token)) {
+            throw new IdentifierChangeTokenExpiredError();
+        }
+        const pendingIdentifier = user.pendingIdentifier;
+        if (!pendingIdentifier) {
+            throw new InternalServerError('error.pending-identifier-missing');
+        }
+        const oldIdentifier = user.identifier;
+        user.identifier = pendingIdentifier;
+        user.identifierChangeToken = null;
+        user.pendingIdentifier = null;
+        await this.connection.getRepository(User).save(user);
+        return { user, oldIdentifier };
+    }
+
+    async updatePassword(userId: ID, currentPassword: string, newPassword: string): Promise<boolean> {
+        const user = await this.connection.getRepository(User).findOne(userId, { select: ['id', 'passwordHash'] });
+        if (!user) {
+            throw new InternalServerError(`error.no-active-user-id`);
+        }
         const matches = await this.passwordCipher.check(currentPassword, user.passwordHash);
         if (!matches) {
             throw new UnauthorizedError();
@@ -123,4 +152,9 @@ export class UserService {
         await this.connection.getRepository(User).save(user);
         return true;
     }
+
+    async setIdentifierChangeToken(user: User): Promise<User> {
+        user.identifierChangeToken = this.verificationTokenGenerator.generateVerificationToken();
+        return this.connection.manager.save(user);
+    }
 }

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-shop.json


Fișier diff suprimat deoarece este prea mare
+ 424 - 415
schema.json


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff