Skip to content

Commit

Permalink
feat: add customer approval handling (#1284)
Browse files Browse the repository at this point in the history
* information page for redirect after registration that requires confirmation
* specific login error for denied login because of missing approval

REQUIRED ICM VERSION: 7.10.38.15-LTS

Co-authored-by: Silke Grüber <S.Grueber@intershop.de>
Co-authored-by: Stefan Hauke <s.hauke@intershop.de>
  • Loading branch information
3 people authored Oct 17, 2022
1 parent b8ba675 commit 2c6ac07
Show file tree
Hide file tree
Showing 17 changed files with 235 additions and 32 deletions.
2 changes: 2 additions & 0 deletions src/app/core/facades/account.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import {
createUser,
deleteUserPaymentInstrument,
getCustomerApprovalEmail,
getLoggedInCustomer,
getLoggedInUser,
getPasswordReminderError,
Expand Down Expand Up @@ -124,6 +125,7 @@ export class AccountFacade {

customer$ = this.store.pipe(select(getLoggedInCustomer));
isBusinessCustomer$ = this.store.pipe(select(isBusinessCustomer));
getCustomerApprovalEmail$ = this.store.pipe(select(getCustomerApprovalEmail));
userPriceDisplayType$ = this.store.pipe(select(getPriceDisplayType));

updateCustomerProfile(customer: Customer, message?: MessagesPayloadType) {
Expand Down
8 changes: 0 additions & 8 deletions src/app/core/services/user/user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,6 @@ describe('User Service', () => {

it("should create a new individual user when 'createUser' is called", done => {
when(apiServiceMock.post(anyString(), anything(), anything())).thenReturn(of({}));
when(apiServiceMock.get(anything(), anything())).thenReturn(
of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData)
);
when(apiServiceMock.get(anything())).thenReturn(
of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData)
);

const payload = {
customer: { customerNo: '4711', isBusinessCustomer: false } as Customer,
Expand All @@ -176,8 +170,6 @@ describe('User Service', () => {

userService.createUser(payload).subscribe(() => {
verify(apiServiceMock.post('privatecustomers', anything(), anything())).once();
verify(apiServiceMock.get('customers/-', anything())).once();
verify(apiServiceMock.get('privatecustomers/-')).once();
done();
});
});
Expand Down
8 changes: 4 additions & 4 deletions src/app/core/services/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ export class UserService {
}

/**
* Create a new user for the given data.
* Creates a new user for the given data.
*
* @param body The user data (customer, user, credentials, address) to create a new user.
* @param body The user data (customer, user, credentials, address) to create a new user. The new user is not logged in after creation.
*/
createUser(body: CustomerRegistrationType): Observable<CustomerLoginType> {
createUser(body: CustomerRegistrationType): Observable<CustomerUserType> {
if (!body || !body.customer || (!body.user && !body.userId) || !body.address) {
return throwError(() => new Error('createUser() called without required body data'));
}
Expand Down Expand Up @@ -164,7 +164,7 @@ export class UserService {
.post<void>(AppFacade.getCustomerRestResource(body.customer.isBusinessCustomer, isAppTypeRest), newCustomer, {
captcha: pick(body, ['captcha', 'captchaAction']),
})
.pipe(concatMap(() => this.fetchCustomer()))
.pipe(map(() => ({ customer: body.customer, user: body.user })))
)
);
}
Expand Down
7 changes: 7 additions & 0 deletions src/app/core/store/customer/user/user.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@ export const logoutUser = createAction('[User] Logout User');

export const createUser = createAction('[User] Create User', payload<CustomerRegistrationType>());

export const createUserSuccess = createAction('[User API] Create User Success', payload<{ email: string }>());

export const createUserFail = createAction('[User API] Create User Failed', httpError());

export const createUserApprovalRequired = createAction(
'[User Internal] Create User Approval Required',
payload<{ email: string }>()
);

export const updateUser = createAction(
'[User] Update User',
payload<{ user: User; credentials?: Credentials; successMessage?: MessagesPayloadType }>()
Expand Down
80 changes: 75 additions & 5 deletions src/app/core/store/customer/user/user.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ import { PaymentService } from 'ish-core/services/payment/payment.service';
import { UserService } from 'ish-core/services/user/user.service';
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { displaySuccessMessage } from 'ish-core/store/core/messages';
import { loadServerConfigSuccess } from 'ish-core/store/core/server-config';
import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module';
import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';
import { routerTestNavigatedAction } from 'ish-core/utils/dev/routing';

import {
createUser,
createUserApprovalRequired,
createUserFail,
createUserSuccess,
deleteUserPaymentInstrument,
deleteUserPaymentInstrumentFail,
deleteUserPaymentInstrumentSuccess,
Expand Down Expand Up @@ -101,7 +104,7 @@ describe('User Effects', () => {

TestBed.configureTestingModule({
imports: [
CoreStoreModule.forTesting(['router']),
CoreStoreModule.forTesting(['router', 'serverConfig']),
CustomerStoreModule.forTesting('user'),
RouterTestingModule.withRoutes([{ path: '**', children: [] }]),
],
Expand Down Expand Up @@ -246,14 +249,37 @@ describe('User Effects', () => {
});

describe('createUser$', () => {
beforeEach(() => {
store.dispatch(
loadServerConfigSuccess({
config: { general: { customerTypeForLoginApproval: ['PRIVATE'] } },
})
);
});

const customerLoginType = {
customer: {
isBusinessCustomer: true,
customerNo: 'PC',
},
user: {
email: 'test@intershop.de',
} as User,
};

it('should call the api service when Create event is called', done => {
const action = createUser({
customer: {
isBusinessCustomer: true,
customerNo: 'PC',
},
user: {
email: 'test@intershop.de',
},
} as CustomerRegistrationType);

when(userServiceMock.createUser(anything())).thenReturn(of(customerLoginType));

actions$ = of(action);

effects.createUser$.subscribe(() => {
Expand All @@ -262,14 +288,45 @@ describe('User Effects', () => {
});
});

it('should dispatch a loadPGID action on successful user creation', () => {
it('should dispatch a createUserSuccess and a loginUserWithToken action on successful user creation', () => {
const credentials: Credentials = { login: '1234', password: 'xxx' };
const customer: Customer = { isBusinessCustomer: true, customerNo: 'PC' };

when(userServiceMock.createUser(anything())).thenReturn(of(customerLoginType));

const action = createUser({ credentials } as CustomerRegistrationType);
const completion = loginUserSuccess({} as CustomerUserType);
const action = createUser({ customer, credentials } as CustomerRegistrationType);
const completion1 = createUserSuccess({ email: customerLoginType.user.email });
const completion2 = loginUserWithToken({ token: undefined });

actions$ = hot('-a', { a: action });
const expected$ = cold('-b', { b: completion });
const expected$ = cold('-(bc)', { b: completion1, c: completion2 });

expect(effects.createUser$).toBeObservable(expected$);
});

it('should dispatch a createUserSuccess and a createUserApprovalRequired action if customer approval is enabled', () => {
const credentials: Credentials = { login: '1234', password: 'xxx' };
const customer: Customer = { isBusinessCustomer: false, customerNo: 'PC' };

const customerLoginType = {
customer: {
isBusinessCustomer: false,
customerNo: 'PC',
},
user: {
firstName: 'Klaus',
lastName: 'Klausen',
email: 'test@intershop.de',
},
};
when(userServiceMock.createUser(anything())).thenReturn(of(customerLoginType));

const action = createUser({ customer, credentials } as CustomerRegistrationType);
const completion1 = createUserSuccess({ email: customerLoginType.user.email });
const completion2 = createUserApprovalRequired({ email: customerLoginType.user.email });

actions$ = hot('-a', { a: action });
const expected$ = cold('-(bc)', { b: completion1, c: completion2 });

expect(effects.createUser$).toBeObservable(expected$);
});
Expand All @@ -286,6 +343,19 @@ describe('User Effects', () => {

expect(effects.createUser$).toBeObservable(expected$);
});

it('should navigate to /register/approval if customer approval is needed', done => {
actions$ = of(createUserApprovalRequired({ email: 'test@intershop.de' }));

effects.redirectAfterUserCreationWithCustomerApproval$.subscribe({
next: () => {
expect(location.path()).toEqual('/register/approval');
done();
},
error: fail,
complete: noop,
});
});
});

describe('updateUser$', () => {
Expand Down
25 changes: 24 additions & 1 deletion src/app/core/store/customer/user/user.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import { PaymentService } from 'ish-core/services/payment/payment.service';
import { UserService } from 'ish-core/services/user/user.service';
import { displaySuccessMessage } from 'ish-core/store/core/messages';
import { selectQueryParam, selectUrl } from 'ish-core/store/core/router';
import { getServerConfigParameter } from 'ish-core/store/core/server-config';
import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service';
import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators';

import { getPGID, personalizationStatusDetermined } from '.';
import {
createUser,
createUserApprovalRequired,
createUserFail,
createUserSuccess,
deleteUserPaymentInstrument,
deleteUserPaymentInstrumentFail,
deleteUserPaymentInstrumentSuccess,
Expand Down Expand Up @@ -118,11 +121,31 @@ export class UserEffects {
ofType(createUser),
mapToPayload(),
mergeMap((data: CustomerRegistrationType) =>
this.userService.createUser(data).pipe(map(loginUserSuccess), mapErrorToAction(createUserFail))
this.userService.createUser(data).pipe(
withLatestFrom(
this.store.pipe(select(getServerConfigParameter<string[]>('general.customerTypeForLoginApproval')))
),
concatMap(([createUserResponse, customerTypeForLoginApproval]) => [
createUserSuccess({ email: createUserResponse.user.email }),
customerTypeForLoginApproval?.includes(createUserResponse.customer.isBusinessCustomer ? 'SMB' : 'PRIVATE')
? createUserApprovalRequired({ email: createUserResponse.user.email })
: loginUserWithToken({ token: undefined }),
]),
mapErrorToAction(createUserFail)
)
)
)
);

redirectAfterUserCreationWithCustomerApproval$ = createEffect(
() =>
this.actions$.pipe(
ofType(createUserApprovalRequired),
concatMap(() => from(this.router.navigate(['/register/approval'])))
),
{ dispatch: false }
);

updateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(updateUser),
Expand Down
30 changes: 21 additions & 9 deletions src/app/core/store/customer/user/user.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import {
updateUserPasswordSuccess,
updateUserSuccess,
userErrorReset,
createUserSuccess,
createUserApprovalRequired,
} from './user.actions';

export interface UserState {
Expand All @@ -56,6 +58,7 @@ export interface UserState {
pgid: string;
passwordReminderSuccess: boolean;
passwordReminderError: HttpError;
customerApprovalEmail: string;
}

const initialState: UserState = {
Expand All @@ -69,6 +72,7 @@ const initialState: UserState = {
pgid: undefined,
passwordReminderSuccess: undefined,
passwordReminderError: undefined,
customerApprovalEmail: undefined,
};

export const userReducer = createReducer(
Expand Down Expand Up @@ -100,6 +104,17 @@ export const userReducer = createReducer(
updateUserPasswordByPasswordReminderFail,
requestPasswordReminderFail
),
unsetLoadingAndErrorOn(
loginUserSuccess,
loadCompanyUserSuccess,
createUserSuccess,
updateUserSuccess,
updateUserPasswordSuccess,
updateCustomerSuccess,
loadUserCostCentersSuccess,
loadUserPaymentMethodsSuccess,
deleteUserPaymentInstrumentSuccess
),
setErrorOn(
updateUserFail,
updateUserPasswordFail,
Expand All @@ -117,15 +132,12 @@ export const userReducer = createReducer(
error,
};
}),
unsetLoadingAndErrorOn(
loginUserSuccess,
loadCompanyUserSuccess,
updateUserSuccess,
updateUserPasswordSuccess,
updateCustomerSuccess,
loadUserCostCentersSuccess,
loadUserPaymentMethodsSuccess,
deleteUserPaymentInstrumentSuccess
on(
createUserApprovalRequired,
(state, action): UserState => ({
...state,
customerApprovalEmail: action.payload.email,
})
),
on(loginUserSuccess, (state: UserState, action): UserState => {
const customer = action.payload.customer;
Expand Down
14 changes: 14 additions & 0 deletions src/app/core/store/customer/user/user.selectors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';
import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing';

import {
createUserApprovalRequired,
loadCompanyUserSuccess,
loadUserCostCentersSuccess,
loadUserPaymentMethods,
Expand All @@ -26,6 +27,7 @@ import {
updateUserPassword,
} from './user.actions';
import {
getCustomerApprovalEmail,
getLoggedInCustomer,
getLoggedInUser,
getPasswordReminderError,
Expand Down Expand Up @@ -271,4 +273,16 @@ describe('User Selectors', () => {
});
});
});

describe('getCustomerApprovalEmail', () => {
it('should be undefined if no createUserApprovalRequired action was triggert', () => {
expect(getCustomerApprovalEmail(store$.state)).toBeUndefined();
});

it('should return the email address of the user to be approved', () => {
const email = 'test@intershop.com';
store$.dispatch(createUserApprovalRequired({ email }));
expect(getCustomerApprovalEmail(store$.state)).toEqual(email);
});
});
});
2 changes: 2 additions & 0 deletions src/app/core/store/customer/user/user.selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export const getPasswordReminderSuccess = createSelector(getUserState, state =>

export const getPasswordReminderError = createSelector(getUserState, state => state.passwordReminderError);

export const getCustomerApprovalEmail = createSelector(getUserState, state => state.customerApprovalEmail);

export const getPriceDisplayType = createSelector(
getUserAuthorized,
isBusinessCustomer,
Expand Down
7 changes: 5 additions & 2 deletions src/app/core/utils/http-error/login-user.error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ export class LoginUserErrorHandler implements SpecialHttpErrorHandler {
test(error: HttpErrorResponse, request: HttpRequest<unknown>): boolean {
return (
request.headers.has(ApiService.AUTHORIZATION_HEADER_KEY) &&
error.status === 401 &&
(error.status === 401 || error.status === 403) &&
error.url.includes('customers/-')
);
}
map(): Partial<HttpError> {
map(error: HttpErrorResponse): Partial<HttpError> {
if (error.status === 403) {
return { code: 'account.login.customer_approval.error.invalid' };
}
if (this.loginType === 'email') {
return { code: 'account.login.email_password.error.invalid' };
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<ng-container *ngIf="email$ | async as email">
<h1>{{ 'account.customer.registered.title' | translate }}</h1>
<div [ishServerHtml]="'account.customer.approval.message' | translate: { '0': email }"></div>
</ng-container>
Loading

0 comments on commit 2c6ac07

Please sign in to comment.