diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index f049897472..b1bd620793 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -11,6 +11,9 @@ kb_sync_latest_only We introduced the feature toggle 'guestCheckout' in the `environment.model.ts`. +We switched to encode user logins when used in the api service. +This is to enable special characters (like the #) that are sometimes present in user logins (SSO case!) but would've lead to errors before. + ## 0.28 to 0.29 We activated TypeScript's [`noImplicitAny`](https://www.typescriptlang.org/tsconfig#noImplicitAny). diff --git a/projects/organization-management/src/app/services/users/users.service.spec.ts b/projects/organization-management/src/app/services/users/users.service.spec.ts index 7ad33db486..276dc6c8dd 100644 --- a/projects/organization-management/src/app/services/users/users.service.spec.ts +++ b/projects/organization-management/src/app/services/users/users.service.spec.ts @@ -64,7 +64,7 @@ describe('Users Service', () => { verify(apiService.get(anything())).once(); expect(capture(apiService.get).last()).toMatchInlineSnapshot(` Array [ - "customers/4711/users/pmiller@test.intershop.de", + "customers/4711/users/pmiller%40test.intershop.de", ] `); done(); @@ -76,7 +76,7 @@ describe('Users Service', () => { verify(apiService.delete(anything())).once(); expect(capture(apiService.delete).last()).toMatchInlineSnapshot(` Array [ - "customers/4711/users/pmiller@test.intershop.de", + "customers/4711/users/pmiller%40test.intershop.de", ] `); done(); @@ -99,7 +99,7 @@ describe('Users Service', () => { usersService.updateUser(user).subscribe(() => { verify(apiService.put(anything(), anything())).once(); expect(capture(apiService.put).last()[0]).toMatchInlineSnapshot( - `"customers/4711/users/pmiller@test.intershop.de"` + `"customers/4711/users/pmiller%40test.intershop.de"` ); done(); }); @@ -117,7 +117,7 @@ describe('Users Service', () => { verify(apiService.put(anything(), anything())).once(); expect(capture(apiService.put).last()).toMatchInlineSnapshot(` Array [ - "customers/4711/users/pmiller@test.intershop.de/roles", + "customers/4711/users/pmiller%40test.intershop.de/roles", Object { "userRoles": Array [], }, @@ -141,7 +141,7 @@ describe('Users Service', () => { verify(apiService.put(anything(), anything())).once(); expect(capture(apiService.put).last()).toMatchInlineSnapshot(` Array [ - "customers/4711/users/pmiller@test.intershop.de/budgets", + "customers/4711/users/pmiller%40test.intershop.de/budgets", Object { "budget": Object { "currency": "USD", diff --git a/projects/organization-management/src/app/services/users/users.service.ts b/projects/organization-management/src/app/services/users/users.service.ts index af24fac708..b2dfc69746 100644 --- a/projects/organization-management/src/app/services/users/users.service.ts +++ b/projects/organization-management/src/app/services/users/users.service.ts @@ -49,7 +49,9 @@ export class UsersService { getUser(login: string): Observable { return this.currentCustomer$.pipe( switchMap(customer => - this.apiService.get(`customers/${customer.customerNo}/users/${login}`).pipe(map(B2bUserMapper.fromData)) + this.apiService + .get(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}`) + .pipe(map(B2bUserMapper.fromData)) ) ); } @@ -113,7 +115,7 @@ export class UsersService { return this.currentCustomer$.pipe( switchMap(customer => this.apiService - .put(`customers/${customer.customerNo}/users/${user.login}`, { + .put(`customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}`, { ...customer, ...user, preferredInvoiceToAddress: { urn: user.preferredInvoiceToAddressUrn }, @@ -139,7 +141,9 @@ export class UsersService { } return this.currentCustomer$.pipe( - switchMap(customer => this.apiService.delete(`customers/${customer.customerNo}/users/${login}`)) + switchMap(customer => + this.apiService.delete(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}`) + ) ); } @@ -162,10 +166,12 @@ export class UsersService { setUserRoles(login: string, userRoles: string[]): Observable { return this.currentCustomer$.pipe( switchMap(customer => - this.apiService.put(`customers/${customer.customerNo}/users/${login}/roles`, { userRoles }).pipe( - unpackEnvelope('userRoles'), - map(data => data.map(r => r.roleID)) - ) + this.apiService + .put(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}/roles`, { userRoles }) + .pipe( + unpackEnvelope('userRoles'), + map(data => data.map(r => r.roleID)) + ) ) ); } @@ -183,7 +189,10 @@ export class UsersService { } return this.currentCustomer$.pipe( switchMap(customer => - this.apiService.put(`customers/${customer.customerNo}/users/${login}/budgets`, budget) + this.apiService.put( + `customers/${customer.customerNo}/users/${encodeURIComponent(login)}/budgets`, + budget + ) ) ); } diff --git a/src/app/core/guards/identity-provider-invite.guard.ts b/src/app/core/guards/identity-provider-invite.guard.ts new file mode 100644 index 0000000000..cdb958f104 --- /dev/null +++ b/src/app/core/guards/identity-provider-invite.guard.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router'; + +import { IdentityProviderFactory } from 'ish-core/identity-provider/identity-provider.factory'; + +@Injectable({ providedIn: 'root' }) +export class IdentityProviderInviteGuard implements CanActivate { + constructor(private identityProviderFactory: IdentityProviderFactory) {} + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this.identityProviderFactory.getInstance().triggerInvite + ? this.identityProviderFactory.getInstance().triggerInvite(route, state) + : false; + } +} diff --git a/src/app/core/identity-provider/auth0.identity-provider.ts b/src/app/core/identity-provider/auth0.identity-provider.ts index e7b534894e..73bc739912 100644 --- a/src/app/core/identity-provider/auth0.identity-provider.ts +++ b/src/app/core/identity-provider/auth0.identity-provider.ts @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; import { OAuthService } from 'angular-oauth2-oidc'; -import { Observable, combineLatest, from, iif, of, race, timer } from 'rxjs'; +import { Observable, combineLatest, from, of, race, timer } from 'rxjs'; import { catchError, filter, first, map, mapTo, switchMap, switchMapTo, take, tap } from 'rxjs/operators'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; @@ -92,65 +92,18 @@ export class Auth0IdentityProvider implements IdentityProvider { ) ), whenTruthy(), - switchMap(idToken => - this.apiService - .post('users/processtoken', { - id_token: idToken, - options: ['CREATE_USER'], - }) - .pipe( - tap(() => { - this.store.dispatch(loadUserByAPIToken()); - }), - switchMap((userData: UserData) => - combineLatest([ - this.store.pipe(select(getLoggedInCustomer)), - this.store.pipe(select(getUserLoading)), - ]).pipe( - filter(([, loading]) => !loading), - first(), - switchMap(([customer]) => - iif( - () => !customer, - this.router.navigate(['/register', 'sso'], { - queryParams: { - userid: userData.businessPartnerNo, - firstName: userData.firstName, - lastName: userData.lastName, - }, - }), - of(false) - ) - ), - switchMap((navigated: boolean) => - navigated || navigated === null - ? race( - this.store.pipe( - select(getSsoRegistrationRegistered), - whenTruthy(), - tap(() => { - this.store.dispatch(loadUserByAPIToken()); - }) - ), - this.store.pipe( - select(getSsoRegistrationCancelled), - whenTruthy(), - mapTo(false), - tap(() => this.router.navigateByUrl('/logout')) - ) - ) - : of(navigated) - ) - ) - ), - switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first())), - catchError((error: HttpError) => { - this.apiTokenService.removeApiToken(); - this.triggerLogout(); - return of(error); - }) - ) - ) + switchMap(idToken => { + const inviteUserId = window.sessionStorage.getItem('invite-userid'); + const inviteHash = window.sessionStorage.getItem('invite-hash'); + return inviteUserId && inviteHash + ? this.inviteRegistration(idToken, inviteUserId, inviteHash).pipe( + tap(() => { + window.sessionStorage.removeItem('invite-userid'); + window.sessionStorage.removeItem('invite-hash'); + }) + ) + : this.normalSignInRegistration(idToken); + }) ) .subscribe(() => { this.apiTokenService.removeApiToken(); @@ -160,6 +113,84 @@ export class Auth0IdentityProvider implements IdentityProvider { }); } + private normalSignInRegistration(idToken: string) { + return this.apiService + .post('users/processtoken', { + id_token: idToken, + options: ['CREATE_USER'], + }) + .pipe( + tap(() => { + this.store.dispatch(loadUserByAPIToken()); + }), + switchMap((userData: UserData) => + combineLatest([this.store.pipe(select(getLoggedInCustomer)), this.store.pipe(select(getUserLoading))]).pipe( + filter(([, loading]) => !loading), + first(), + switchMap(([customer]) => + !customer + ? this.router.navigate(['/register', 'sso'], { + queryParams: { + userid: userData.businessPartnerNo, + firstName: userData.firstName, + lastName: userData.lastName, + }, + }) + : of(false) + ), + switchMap((navigated: boolean) => + navigated || navigated === null + ? race( + this.store.pipe( + select(getSsoRegistrationRegistered), + whenTruthy(), + tap(() => { + this.store.dispatch(loadUserByAPIToken()); + }) + ), + this.store.pipe( + select(getSsoRegistrationCancelled), + whenTruthy(), + mapTo(false), + tap(() => this.router.navigateByUrl('/logout')) + ) + ) + : of(navigated) + ) + ) + ), + switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first())), + catchError((error: HttpError) => { + this.apiTokenService.removeApiToken(); + this.triggerLogout(); + return of(error); + }) + ); + } + + private inviteRegistration(idToken: string, userId: string, hash: string) { + return this.apiService + .post('users/processtoken', { + id_token: idToken, + secure_user_ref: { + user_id: userId, + secure_code: hash, + }, + options: ['UPDATE'], + }) + .pipe( + tap(() => { + this.store.dispatch(loadUserByAPIToken()); + }), + switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first())), + catchError((error: HttpError) => { + this.apiTokenService.removeApiToken(); + this.triggerLogout(); + return of(error); + }) + ); + } + triggerRegister(route: ActivatedRouteSnapshot): TriggerReturnType { if (route.queryParamMap.get('userid')) { return of(true); @@ -178,6 +209,15 @@ export class Auth0IdentityProvider implements IdentityProvider { }); } + triggerInvite(route: ActivatedRouteSnapshot): TriggerReturnType { + this.router.navigateByUrl('/loading'); + window.sessionStorage.setItem('invite-userid', route.queryParams.uid); + window.sessionStorage.setItem('invite-hash', route.queryParams.Hash); + return this.oauthService.loadDiscoveryDocumentAndLogin({ + state: route.queryParams.returnUrl, + }); + } + triggerLogout(): TriggerReturnType { if (this.oauthService.hasValidIdToken()) { this.oauthService.revokeTokenAndLogout( diff --git a/src/app/core/identity-provider/icm.identity-provider.ts b/src/app/core/identity-provider/icm.identity-provider.ts index 2088fce788..5992fc90d1 100644 --- a/src/app/core/identity-provider/icm.identity-provider.ts +++ b/src/app/core/identity-provider/icm.identity-provider.ts @@ -1,6 +1,6 @@ import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; import { Observable, noop } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -9,7 +9,7 @@ import { selectQueryParam } from 'ish-core/store/core/router'; import { logoutUser } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; -import { IdentityProvider } from './identity-provider.interface'; +import { IdentityProvider, TriggerReturnType } from './identity-provider.interface'; @Injectable({ providedIn: 'root' }) export class ICMIdentityProvider implements IdentityProvider { @@ -36,11 +36,11 @@ export class ICMIdentityProvider implements IdentityProvider { }); } - triggerLogin() { + triggerLogin(): TriggerReturnType { return true; } - triggerLogout() { + triggerLogout(): TriggerReturnType { this.store.dispatch(logoutUser()); this.apiTokenService.removeApiToken(); return this.store.pipe( @@ -50,10 +50,16 @@ export class ICMIdentityProvider implements IdentityProvider { ); } - triggerRegister() { + triggerRegister(): TriggerReturnType { return true; } + triggerInvite(route: ActivatedRouteSnapshot): TriggerReturnType { + return this.router.createUrlTree(['forgotPassword', 'updatePassword'], { + queryParams: { uid: route.queryParams.uid, Hash: route.queryParams.Hash }, + }); + } + intercept(req: HttpRequest, next: HttpHandler): Observable> { return this.apiTokenService.intercept(req, next); } diff --git a/src/app/core/identity-provider/identity-provider.interface.ts b/src/app/core/identity-provider/identity-provider.interface.ts index 7ea8449a9e..842abed46c 100644 --- a/src/app/core/identity-provider/identity-provider.interface.ts +++ b/src/app/core/identity-provider/identity-provider.interface.ts @@ -31,6 +31,11 @@ export interface IdentityProvider { */ triggerRegister?(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): TriggerReturnType; + /** + * Route guard for inviting. + */ + triggerInvite?(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): TriggerReturnType; + /** * Route guard for logout */ diff --git a/src/app/core/services/api/api.service.ts b/src/app/core/services/api/api.service.ts index 4fb32e0236..baef4603a9 100644 --- a/src/app/core/services/api/api.service.ts +++ b/src/app/core/services/api/api.service.ts @@ -285,31 +285,43 @@ export class ApiService { get: (path: string, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => - this.get(`customers/${customer.customerNo}/users/${user.login}/${path}`, options) + this.get(`customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/${path}`, options) ) ), delete: (path: string, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => - this.delete(`customers/${customer.customerNo}/users/${user.login}/${path}`, options) + this.delete(`customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/${path}`, options) ) ), put: (path: string, body = {}, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => - this.put(`customers/${customer.customerNo}/users/${user.login}/${path}`, body, options) + this.put( + `customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/${path}`, + body, + options + ) ) ), patch: (path: string, body = {}, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => - this.patch(`customers/${customer.customerNo}/users/${user.login}/${path}`, body, options) + this.patch( + `customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/${path}`, + body, + options + ) ) ), post: (path: string, body = {}, options?: AvailableOptions) => ids$.pipe( concatMap(([user, customer]) => - this.post(`customers/${customer.customerNo}/users/${user.login}/${path}`, body, options) + this.post( + `customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/${path}`, + body, + options + ) ) ), }; diff --git a/src/app/core/services/authorization/authorization.service.ts b/src/app/core/services/authorization/authorization.service.ts index 46b4f41a9f..4b3046e140 100644 --- a/src/app/core/services/authorization/authorization.service.ts +++ b/src/app/core/services/authorization/authorization.service.ts @@ -21,7 +21,7 @@ export class AuthorizationService { } return this.apiService - .get(`customers/${customer.customerNo}/users/${user.login}/roles`) + .get(`customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}/roles`) .pipe(map(data => this.authorizationMapper.fromData(data))); } } diff --git a/src/app/core/store/core/router/router.selectors.ts b/src/app/core/store/core/router/router.selectors.ts index 55adca24de..15f757aac4 100644 --- a/src/app/core/store/core/router/router.selectors.ts +++ b/src/app/core/store/core/router/router.selectors.ts @@ -8,7 +8,7 @@ export const selectRouter = (state: { router?: RouterReducerState } export const selectRouteData = (key: string) => createSelector(selectRouter, (state): T => state?.state?.data && state.state.data[key]); -export const selectQueryParams = createSelector(selectRouter, state => state?.state?.queryParams || {}); +export const selectQueryParams = createSelector(selectRouter, state => state?.state?.queryParams ?? {}); export const selectQueryParam = (key: string) => createSelector(selectQueryParams, (queryParams): string => queryParams && queryParams[key]); diff --git a/src/app/extensions/punchout/services/punchout/punchout.service.ts b/src/app/extensions/punchout/services/punchout/punchout.service.ts index cee602080c..474d253a1d 100644 --- a/src/app/extensions/punchout/services/punchout/punchout.service.ts +++ b/src/app/extensions/punchout/services/punchout/punchout.service.ts @@ -117,7 +117,9 @@ export class PunchoutService { switchMap(customer => this.apiService .put( - `customers/${customer.customerNo}/punchouts/${this.getResourceType(user.punchoutType)}/users/${user.login}`, + `customers/${customer.customerNo}/punchouts/${this.getResourceType( + user.punchoutType + )}/users/${encodeURIComponent(user.login)}`, user, { headers: this.punchoutHeaders, diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index 6296d4e810..1ca89f4ef0 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router'; import { FeatureToggleGuard } from 'ish-core/feature-toggle.module'; import { AuthGuard } from 'ish-core/guards/auth.guard'; +import { IdentityProviderInviteGuard } from 'ish-core/guards/identity-provider-invite.guard'; import { IdentityProviderLoginGuard } from 'ish-core/guards/identity-provider-login.guard'; import { IdentityProviderLogoutGuard } from 'ish-core/guards/identity-provider-logout.guard'; import { IdentityProviderRegisterGuard } from 'ish-core/guards/identity-provider-register.guard'; @@ -116,6 +117,11 @@ const routes: Routes = [ canActivate: [IdentityProviderLogoutGuard], children: [], }, + { + path: 'invite', + canActivate: [IdentityProviderInviteGuard], + children: [], + }, { path: 'forgotPassword', loadChildren: () => import('./forgot-password/forgot-password-page.module').then(m => m.ForgotPasswordPageModule),