From 00d55e470f99fb1defa1ae7250af793d6fe70115 Mon Sep 17 00:00:00 2001 From: Timo Bejan Date: Fri, 31 Mar 2023 12:30:39 +0300 Subject: [PATCH 1/3] RED-6523 - multitenancy --- src/index.ts | 1 + src/lib/caching/cache-utils.ts | 3 - src/lib/error/server-error-interceptor.ts | 7 +- .../services/permissions-guard.service.ts | 6 +- src/lib/services/index.ts | 2 + src/lib/tenants/index.ts | 3 + src/lib/tenants/services/index.ts | 4 + .../tenants/services/tenant-context-holder.ts | 15 ++++ src/lib/tenants/services/tenant-context.ts | 69 ++++++++++++++++++ .../tenants/services/tenant-id-interceptor.ts | 23 ++++++ .../tenant-id-response-interceptor.ts | 22 ++++++ .../tenant-resolve.component.html | 25 +++++++ .../tenant-resolve.component.scss | 13 ++++ .../tenant-resolve.component.ts | 54 ++++++++++++++ src/lib/tenants/tenants.module.ts | 61 ++++++++++++++++ .../users/guards/iqser-auth-guard.service.ts | 4 +- .../users/guards/iqser-role-guard.service.ts | 4 +- src/lib/users/index.ts | 1 + src/lib/users/iqser-users.module.ts | 73 ++++++++++++++----- src/lib/users/services/iqser-user.service.ts | 9 +-- .../users/services/keycloak-status.service.ts | 52 +++++++++++++ 21 files changed, 418 insertions(+), 33 deletions(-) create mode 100644 src/lib/tenants/index.ts create mode 100644 src/lib/tenants/services/index.ts create mode 100644 src/lib/tenants/services/tenant-context-holder.ts create mode 100644 src/lib/tenants/services/tenant-context.ts create mode 100644 src/lib/tenants/services/tenant-id-interceptor.ts create mode 100644 src/lib/tenants/services/tenant-id-response-interceptor.ts create mode 100644 src/lib/tenants/tenant-resolve/tenant-resolve.component.html create mode 100644 src/lib/tenants/tenant-resolve/tenant-resolve.component.scss create mode 100644 src/lib/tenants/tenant-resolve/tenant-resolve.component.ts create mode 100644 src/lib/tenants/tenants.module.ts create mode 100644 src/lib/users/services/keycloak-status.service.ts diff --git a/src/index.ts b/src/index.ts index c088530..bf0a0de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,3 +21,4 @@ export * from './lib/translations'; export * from './lib/pipes'; export * from './lib/permissions'; export * from './lib/directives'; +export * from './lib/tenants'; diff --git a/src/lib/caching/cache-utils.ts b/src/lib/caching/cache-utils.ts index dfc2d43..dc8b8de 100644 --- a/src/lib/caching/cache-utils.ts +++ b/src/lib/caching/cache-utils.ts @@ -6,7 +6,6 @@ export const APP_LEVEL_CACHE = 'app-level-cache'; export const DYNAMIC_CACHES = new InjectionToken('dynamic-caches'); export async function wipeAllCaches() { - console.log('get caches keys'); const keys = (await caches?.keys()) ?? []; for (const cache of keys) { await wipeCache(cache); @@ -14,7 +13,6 @@ export async function wipeAllCaches() { } export function wipeCache(cacheName: string) { - console.log('delete cache: ', cacheName); return caches?.delete(cacheName); } @@ -23,7 +21,6 @@ export async function wipeCacheEntry(cacheName: string, entry: string) { return; } - console.log('open cache: ', cacheName); const cache = await caches.open(cacheName); return cache.delete(entry, { ignoreSearch: false }); } diff --git a/src/lib/error/server-error-interceptor.ts b/src/lib/error/server-error-interceptor.ts index 5619b4b..163e58c 100644 --- a/src/lib/error/server-error-interceptor.ts +++ b/src/lib/error/server-error-interceptor.ts @@ -6,6 +6,7 @@ import { MAX_RETRIES_ON_SERVER_ERROR, SERVER_ERROR_SKIP_PATHS } from './tokens'; import { ErrorService } from './error.service'; import { KeycloakService } from 'keycloak-angular'; import { IqserConfigService } from '../services'; +import { KeycloakStatusService } from '../users'; function updateSeconds(seconds: number) { if (seconds === 0 || seconds === 1) { @@ -49,6 +50,7 @@ export class ServerErrorInterceptor implements HttpInterceptor { private readonly _errorService: ErrorService, private readonly _keycloakService: KeycloakService, private readonly _configService: IqserConfigService, + private readonly _keycloakStatusService: KeycloakStatusService, @Optional() @Inject(MAX_RETRIES_ON_SERVER_ERROR) private readonly _maxRetries: number, @Optional() @Inject(SERVER_ERROR_SKIP_PATHS) private readonly _skippedPaths: string[], ) {} @@ -58,10 +60,7 @@ export class ServerErrorInterceptor implements HttpInterceptor { catchError((error: HttpErrorResponse) => { // token expired if (error.status === HttpStatusCode.Unauthorized) { - window.location.href = this._keycloakService.getKeycloakInstance().createLoginUrl({ - redirectUri: window.location.href, - idpHint: this._configService.values.OAUTH_IDP_HINT, - }); + this._keycloakStatusService.createLoginUrlAndExecute(); } // server error diff --git a/src/lib/permissions/services/permissions-guard.service.ts b/src/lib/permissions/services/permissions-guard.service.ts index 921eb81..e10c512 100644 --- a/src/lib/permissions/services/permissions-guard.service.ts +++ b/src/lib/permissions/services/permissions-guard.service.ts @@ -17,6 +17,7 @@ import { IqserPermissionsService } from './permissions.service'; import { IqserRolesService } from './roles.service'; import { isArray, isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils'; import { List } from '../../utils'; +import { TenantContextHolder } from '../../tenants'; export interface IqserPermissionsData { readonly allow: string | List; @@ -29,6 +30,7 @@ export interface IqserPermissionsData { export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivateChild { constructor( private readonly _permissionsService: IqserPermissionsService, + private readonly _tenantContextHolder: TenantContextHolder, private readonly _rolesService: IqserRolesService, private readonly _router: Router, ) {} @@ -83,10 +85,10 @@ export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivate } if (Array.isArray(_redirectTo)) { - return this._router.navigate(_redirectTo); + return this._router.navigate([this._tenantContextHolder.currentTenant, ..._redirectTo]); } - return this._router.navigate([_redirectTo]); + return this._router.navigate([`${this._tenantContextHolder.currentTenant}${_redirectTo}`]); } #checkRedirect(permissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 274d888..fd5d01e 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -1,3 +1,5 @@ +import exp from 'constants'; + export * from './toaster.service'; export * from './error-message.service'; export * from './generic.service'; diff --git a/src/lib/tenants/index.ts b/src/lib/tenants/index.ts new file mode 100644 index 0000000..9893fb6 --- /dev/null +++ b/src/lib/tenants/index.ts @@ -0,0 +1,3 @@ +export * from './services'; +export * from './tenants.module'; +export * from './tenant-resolve/tenant-resolve.component'; diff --git a/src/lib/tenants/services/index.ts b/src/lib/tenants/services/index.ts new file mode 100644 index 0000000..6880cf3 --- /dev/null +++ b/src/lib/tenants/services/index.ts @@ -0,0 +1,4 @@ +export * from './tenant-context-holder'; +export * from './tenant-context'; +export * from './tenant-id-interceptor'; +export * from './tenant-id-response-interceptor'; diff --git a/src/lib/tenants/services/tenant-context-holder.ts b/src/lib/tenants/services/tenant-context-holder.ts new file mode 100644 index 0000000..28f3301 --- /dev/null +++ b/src/lib/tenants/services/tenant-context-holder.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class TenantContextHolder { + readonly #activeTenantId$ = new BehaviorSubject(''); + + setCurrentTenantId(tenantId: string) { + this.#activeTenantId$.next(tenantId); + } + + get currentTenant() { + return this.#activeTenantId$.value; + } +} diff --git a/src/lib/tenants/services/tenant-context.ts b/src/lib/tenants/services/tenant-context.ts new file mode 100644 index 0000000..99f50f1 --- /dev/null +++ b/src/lib/tenants/services/tenant-context.ts @@ -0,0 +1,69 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { NGXLogger } from 'ngx-logger'; +import { tap } from 'rxjs/operators'; +import { IqserConfigService } from '../../services/iqser-config.service'; +import { TenantContextHolder } from './tenant-context-holder'; +import { ActivatedRoute } from '@angular/router'; +import { BASE_HREF } from '../../utils'; + +export interface IBaseTenant { + tenantId: string; + displayName: string; + guid: string; +} + +@Injectable({ providedIn: 'root' }) +export class TenantContext { + hasMultipleTenants = false; + readonly tenantData$ = new BehaviorSubject(undefined); + readonly tenantsReady$ = new BehaviorSubject(false); + protected readonly _http = inject(HttpClient); + protected readonly _configService = inject(IqserConfigService); + protected readonly _tenantContextHolder = inject(TenantContextHolder); + protected readonly _route = inject(ActivatedRoute); + protected _storageReference: any; + constructor(private readonly _logger: NGXLogger) { + this._storageReference = { + getItem: localStorage.getItem.bind(localStorage), + setItem: localStorage.setItem.bind(localStorage), + removeItem: localStorage.removeItem.bind(localStorage), + key: localStorage.key.bind(localStorage), + }; + } + + loadTenants() { + const base = inject(BASE_HREF); + const path = window.location.pathname; + const nextSlash = path.indexOf('/', base.length + 1); + const tenant = path.substring(base.length + 1, nextSlash >= 0 ? nextSlash : path.length); + return this._http.get('/tenants/simple').pipe( + tap(tenants => { + this.hasMultipleTenants = tenants.length > 1; + this.tenantData$.next(tenants); + this.tenantSelected(tenant); + this.tenantsReady$.next(true); + }), + ); + } + + tenantSelected(tenantId: string) { + if (this.tenantData$.value?.map(t => t.tenantId).includes(tenantId)) { + this._mutateStorage(tenantId); + this._tenantContextHolder.setCurrentTenantId(tenantId); + } + } + + private _mutateStorage(tenant: string) { + localStorage.getItem = (key: string) => { + return this._storageReference.getItem(tenant + ':' + key); + }; + localStorage.setItem = (key: string, value: string) => { + this._storageReference.setItem(tenant + ':' + key, value); + }; + localStorage.removeItem = (key: string) => { + this._storageReference.removeItem(tenant + ':' + key); + }; + } +} diff --git a/src/lib/tenants/services/tenant-id-interceptor.ts b/src/lib/tenants/services/tenant-id-interceptor.ts new file mode 100644 index 0000000..71bdff9 --- /dev/null +++ b/src/lib/tenants/services/tenant-id-interceptor.ts @@ -0,0 +1,23 @@ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { TenantContextHolder } from './tenant-context-holder'; +import { KeycloakService } from 'keycloak-angular'; + +@Injectable() +export class TenantIdInterceptor implements HttpInterceptor { + protected readonly _tenantContext = inject(TenantContextHolder); + + protected readonly _keycloakService = inject(KeycloakService); + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (this._tenantContext.currentTenant) { + const updatedRequest = req.clone({ + setHeaders: { 'X-TENANT-ID': this._tenantContext.currentTenant }, + }); + + return next.handle(updatedRequest); + } + return next.handle(req); + } +} diff --git a/src/lib/tenants/services/tenant-id-response-interceptor.ts b/src/lib/tenants/services/tenant-id-response-interceptor.ts new file mode 100644 index 0000000..25449f0 --- /dev/null +++ b/src/lib/tenants/services/tenant-id-response-interceptor.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +const VALID_TENANT_IDS = ['redaction']; + +@Injectable() +export class TenantIdResponseInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + return next.handle(req).pipe( + tap(event => { + if (event instanceof HttpResponse) { + const xTenantId = event.headers.get('X-TENANT-ID'); + if (VALID_TENANT_IDS.includes(xTenantId)) { + //TODO add logic to deny the response when backend will send the header + } + } + }), + ); + } +} diff --git a/src/lib/tenants/tenant-resolve/tenant-resolve.component.html b/src/lib/tenants/tenant-resolve/tenant-resolve.component.html new file mode 100644 index 0000000..6ac6e53 --- /dev/null +++ b/src/lib/tenants/tenant-resolve/tenant-resolve.component.html @@ -0,0 +1,25 @@ +
+
+
+ +
+ + + + {{ option.displayName || option.tenantId }} + + + +
+ + +
+
diff --git a/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss b/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss new file mode 100644 index 0000000..f6f439e --- /dev/null +++ b/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss @@ -0,0 +1,13 @@ +.tenant-section { + display: flex; + align-items: flex-start; + justify-content: center; + width: 100vw; + height: 100vh; + + margin-top: 32px; + + > form > *{ + padding: 8px; + } +} diff --git a/src/lib/tenants/tenant-resolve/tenant-resolve.component.ts b/src/lib/tenants/tenant-resolve/tenant-resolve.component.ts new file mode 100644 index 0000000..738e4e0 --- /dev/null +++ b/src/lib/tenants/tenant-resolve/tenant-resolve.component.ts @@ -0,0 +1,54 @@ +import { Component, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { TenantContext } from '../services/tenant-context'; +import { IconButtonTypes } from '../../buttons/types/icon-button.type'; +import { KeycloakService } from 'keycloak-angular'; +import { BASE_HREF } from '../../utils'; +import { LoadingService } from '../../loading'; + +@Component({ + selector: 'iqser-tenant-resolve', + templateUrl: './tenant-resolve.component.html', + styleUrls: ['./tenant-resolve.component.scss'], +}) +export class TenantResolveComponent { + private readonly _route = inject(ActivatedRoute); + + readonly loadingService = inject(LoadingService); + private readonly _router = inject(Router); + private readonly _formBuilder = inject(FormBuilder); + private readonly _baseHref = inject(BASE_HREF); + private readonly _keycloakService = inject(KeycloakService); + protected _tenantContext = inject(TenantContext); + readonly iconButtonTypes = IconButtonTypes; + form?: UntypedFormGroup; + + constructor() { + this.loadingService.start(); + this.form = this._formBuilder.group({ + tenantId: [undefined, Validators.required], + }); + if (this._route.snapshot.paramMap.get('tenant')) { + this._keycloakService.isLoggedIn().then(isLoggedIn => { + if (isLoggedIn && this._route.snapshot.paramMap.get('tenant')) { + this.loadingService.stop(); + this._router.navigate([this._route.snapshot.paramMap.get('tenant'), 'main']); + } + }); + } else { + if (!this._tenantContext.hasMultipleTenants) { + const singleTenant = this._tenantContext.tenantData$.value[0]; + window.location.href = window.location.origin + this._baseHref + '/' + singleTenant.tenantId; + } + } + this.loadingService.stop(); + } + + async updateTenantSelection() { + console.log('update selection'); + const tenant = this.form.get('tenantId').value; + console.log('update to: ', tenant); + window.location.href = window.location.origin + this._baseHref + '/' + tenant; + } +} diff --git a/src/lib/tenants/tenants.module.ts b/src/lib/tenants/tenants.module.ts new file mode 100644 index 0000000..963ea82 --- /dev/null +++ b/src/lib/tenants/tenants.module.ts @@ -0,0 +1,61 @@ +import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { CircleButtonComponent, IconButtonComponent } from '../buttons'; +import { TenantResolveComponent } from './tenant-resolve/tenant-resolve.component'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { ReactiveFormsModule } from '@angular/forms'; +import { IqserConfigService } from '../services/iqser-config.service'; +import { TenantContext, TenantIdInterceptor, TenantIdResponseInterceptor } from './services'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { lastValueFrom } from 'rxjs'; +import { IqserLoadingModule } from '../loading'; + +const components = [TenantResolveComponent]; + +@NgModule({ + declarations: [...components], + imports: [ + CommonModule, + MatDialogModule, + TranslateModule, + CircleButtonComponent, + MatInputModule, + MatSelectModule, + IconButtonComponent, + ReactiveFormsModule, + ], + exports: [...components], +}) +export class TenantsModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: TenantsModule, + providers: [ + { + provide: HTTP_INTERCEPTORS, + multi: true, + useClass: TenantIdInterceptor, + }, + { + provide: HTTP_INTERCEPTORS, + multi: true, + useClass: TenantIdResponseInterceptor, + }, + { + provide: APP_INITIALIZER, + multi: true, + useFactory: tenantInitializer, + deps: [IqserConfigService, TenantContext], + }, + ], + }; + } +} + +export function tenantInitializer(configService: IqserConfigService, tenantContext: TenantContext) { + const tenants = lastValueFrom(tenantContext.loadTenants()); + return () => tenants; +} diff --git a/src/lib/users/guards/iqser-auth-guard.service.ts b/src/lib/users/guards/iqser-auth-guard.service.ts index 856acbc..4045c86 100644 --- a/src/lib/users/guards/iqser-auth-guard.service.ts +++ b/src/lib/users/guards/iqser-auth-guard.service.ts @@ -3,6 +3,7 @@ import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { KeycloakAuthGuard, KeycloakService } from 'keycloak-angular'; import { IqserConfigService } from '../../services'; import { IqserUserService } from '../services/iqser-user.service'; +import { TenantContextHolder } from '../../tenants'; @Injectable() export class IqserAuthGuard extends KeycloakAuthGuard { @@ -11,6 +12,7 @@ export class IqserAuthGuard extends KeycloakAuthGuard { protected readonly _keycloak: KeycloakService, private readonly _configService: IqserConfigService, private readonly _userService: IqserUserService, + private readonly _tenantContextHolder: TenantContextHolder, ) { super(_router, _keycloak); } @@ -30,7 +32,7 @@ export class IqserAuthGuard extends KeycloakAuthGuard { const user = await this._userService.loadCurrentUser(); if (user?.hasAnyRole && route.routeConfig?.path === 'auth-error') { - await this._router.navigate(['/main']); + await this._router.navigate([`/${this._tenantContextHolder.currentTenant}/main`]); return false; } diff --git a/src/lib/users/guards/iqser-role-guard.service.ts b/src/lib/users/guards/iqser-role-guard.service.ts index 1bb193c..c31d834 100644 --- a/src/lib/users/guards/iqser-role-guard.service.ts +++ b/src/lib/users/guards/iqser-role-guard.service.ts @@ -2,17 +2,19 @@ import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; import { LoadingService } from '../../loading'; import { IqserUserService } from '../services/iqser-user.service'; +import { TenantContextHolder } from '../../tenants'; @Injectable() export class IqserRoleGuard implements CanActivate { protected readonly _router = inject(Router); + protected readonly _tenantContextHolder = inject(TenantContextHolder); protected readonly _loadingService = inject(LoadingService); protected readonly _userService = inject(IqserUserService); async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise { const currentUser = this._userService.currentUser; if (!currentUser || !currentUser.hasAnyRole) { - await this._router.navigate(['/auth-error']); + await this._router.navigate([`/${this._tenantContextHolder.currentTenant}/auth-error`]); this._loadingService.stop(); return false; } diff --git a/src/lib/users/index.ts b/src/lib/users/index.ts index 4ccf4a5..4877496 100644 --- a/src/lib/users/index.ts +++ b/src/lib/users/index.ts @@ -8,6 +8,7 @@ export * from './types/name-pipe-options'; export * from './iqser-user.model'; export * from './services/iqser-user.service'; export * from './services/default-user.service'; +export * from './services/keycloak-status.service'; export * from './iqser-users.module'; export * from './guards/iqser-auth-guard.service'; export * from './guards/iqser-role-guard.service'; diff --git a/src/lib/users/iqser-users.module.ts b/src/lib/users/iqser-users.module.ts index 3bdf686..6a847f9 100644 --- a/src/lib/users/iqser-users.module.ts +++ b/src/lib/users/iqser-users.module.ts @@ -1,6 +1,6 @@ import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; -import { KeycloakAngularModule, KeycloakOptions, KeycloakService } from 'keycloak-angular'; +import { KeycloakAngularModule, KeycloakEventType, KeycloakOptions, KeycloakService } from 'keycloak-angular'; import { DefaultUserService } from './services/default-user.service'; import { IIqserUser } from './types/user.response'; import { IqserUserService } from './services/iqser-user.service'; @@ -18,16 +18,17 @@ import { UserButtonComponent } from './components/user-button/user-button.compon import { MatIconModule } from '@angular/material/icon'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; import { TranslateModule } from '@ngx-translate/core'; +import { TenantContext, TenantContextHolder } from '../tenants'; +import { filter, firstValueFrom, of, switchMap } from 'rxjs'; +import { KeycloakStatus, KeycloakStatusService } from './services/keycloak-status.service'; +import { map, tap } from 'rxjs/operators'; -function getKeycloakOptions(baseUrl: string, configService: IqserConfigService): KeycloakOptions { - let url: string = configService.values.OAUTH_URL; - url = url.replace(/\/$/, ''); // remove trailing slash - const realm = url.substring(url.lastIndexOf('/') + 1, url.length); - url = url.substring(0, url.lastIndexOf('/realms')); +function getKeycloakOptions(baseUrl: string, tenantContextHolder: TenantContextHolder, configService: IqserConfigService): KeycloakOptions { + console.log('keycloak config for: ', tenantContextHolder.currentTenant); return { config: { - url: url, - realm: realm, + url: configService.values.OAUTH_URL, + realm: tenantContextHolder.currentTenant, clientId: configService.values.OAUTH_CLIENT_ID, }, initOptions: { @@ -40,12 +41,9 @@ function getKeycloakOptions(baseUrl: string, configService: IqserConfigService): }; } -function configureAutomaticRedirectToLoginScreen(keyCloakService: KeycloakService, configService: IqserConfigService) { +function configureAutomaticRedirectToLoginScreen(keyCloakService: KeycloakService, keycloakStatusService: KeycloakStatusService) { keyCloakService.getKeycloakInstance().onAuthRefreshError = () => { - window.location.href = keyCloakService.getKeycloakInstance().createLoginUrl({ - redirectUri: window.location.href, - idpHint: configService.values.OAUTH_IDP_HINT, - }); + keycloakStatusService.createLoginUrlAndExecute(); }; } @@ -53,9 +51,44 @@ export function keycloakInitializer( keycloakService: KeycloakService, configService: IqserConfigService, baseUrl: string, -): () => Promise { - const x = keycloakService.init(getKeycloakOptions(baseUrl, configService)); - return () => x.then(() => configureAutomaticRedirectToLoginScreen(keycloakService, configService)); + keycloakStatusService: KeycloakStatusService, + tenantContext: TenantContext, + tenantContextHolder: TenantContextHolder, +): () => Promise { + const tenantsReady = tenantContext.tenantsReady$.pipe( + filter(t => t), + switchMap(() => { + if (tenantContextHolder.currentTenant) { + const x = keycloakService.init(getKeycloakOptions(baseUrl, tenantContextHolder, configService)); + configureAutomaticRedirectToLoginScreen(keycloakService, keycloakStatusService); + keycloakStatusService.updateStatus(KeycloakStatus.PENDING); + return x; + } else { + keycloakStatusService.updateStatus(KeycloakStatus.NOT_ACTIVE); + return of(true); + } + }), + ); + + return () => firstValueFrom(tenantsReady); +} + +export function keycloakStatusInitializer(keycloakService: KeycloakService, keycloakStatusService: KeycloakStatusService) { + const pipe = keycloakStatusService.keycloakStatus$.pipe( + filter(status => status === KeycloakStatus.PENDING || status === KeycloakStatus.NOT_ACTIVE), + switchMap(status => { + if (status === KeycloakStatus.NOT_ACTIVE) { + return of(true); + } else { + return keycloakService.keycloakEvents$.pipe( + filter(event => event.type === KeycloakEventType.OnReady), + tap(() => keycloakStatusService.updateStatus(KeycloakStatus.READY)), + map(() => true), + ); + } + }), + ); + return () => firstValueFrom(pipe); } const components = [NamePipe, InitialsAvatarComponent, UserButtonComponent]; @@ -81,11 +114,17 @@ export class IqserUsersModule { userService, roleGuard, IqserAuthGuard, + { + provide: APP_INITIALIZER, + useFactory: keycloakStatusInitializer, + multi: true, + deps: [KeycloakService, KeycloakStatusService], + }, { provide: APP_INITIALIZER, useFactory: keycloakInitializer, multi: true, - deps: [KeycloakService, IqserConfigService, BASE_HREF], + deps: [KeycloakService, IqserConfigService, BASE_HREF, KeycloakStatusService, TenantContext, TenantContextHolder], }, ], }; diff --git a/src/lib/users/services/iqser-user.service.ts b/src/lib/users/services/iqser-user.service.ts index 870e4bf..757b239 100644 --- a/src/lib/users/services/iqser-user.service.ts +++ b/src/lib/users/services/iqser-user.service.ts @@ -16,6 +16,7 @@ import { IqserUser } from '../iqser-user.model'; import { IqserPermissionsService, IqserRolesService } from '../../permissions'; import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { KeycloakStatusService } from './keycloak-status.service'; @Injectable() export abstract class IqserUserService< @@ -33,6 +34,7 @@ export abstract class IqserUserService< protected readonly _configService = inject(IqserConfigService); protected readonly _keycloakService = inject(KeycloakService); protected readonly _cacheApiService = inject(CacheApiService); + protected readonly _keycloakStatusService = inject(KeycloakStatusService); protected readonly _permissionsService = inject(IqserPermissionsService, { optional: true }); protected readonly _rolesService = inject(IqserRolesService, { optional: true }); @@ -60,7 +62,7 @@ export abstract class IqserUserService< try { await this._keycloakService.loadUserProfile(true); await this._cacheApiService.wipeCaches(); - await this._keycloakService.logout(); + await this._keycloakService.logout(this._keycloakStatusService.createLoginUrl()); } catch (e) { await this.redirectToLogin(); } @@ -68,10 +70,7 @@ export abstract class IqserUserService< async redirectToLogin() { await this._cacheApiService.wipeCaches(); - window.location.href = this._keycloakService.getKeycloakInstance().createLoginUrl({ - redirectUri: window.location.origin + this._baseHref, - idpHint: this._configService.values.OAUTH_IDP_HINT, - }); + this._keycloakStatusService.createLoginUrlAndExecute(); } loadAll() { diff --git a/src/lib/users/services/keycloak-status.service.ts b/src/lib/users/services/keycloak-status.service.ts new file mode 100644 index 0000000..0b3ce04 --- /dev/null +++ b/src/lib/users/services/keycloak-status.service.ts @@ -0,0 +1,52 @@ +import { inject, Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { KeycloakService } from 'keycloak-angular'; +import { IqserConfigService } from '../../services'; +import { TenantContextHolder } from '../../tenants'; +import { BASE_HREF } from '../../utils'; + +export enum KeycloakStatus { + UNKNOWN = 'UNKNOWN', + PENDING = 'PENDING', + READY = 'READY', + NOT_ACTIVE = 'NOT_ACTIVE', +} + +@Injectable({ providedIn: 'root' }) +export class KeycloakStatusService { + readonly keycloakStatus$ = new BehaviorSubject(KeycloakStatus.UNKNOWN); + + private readonly _keyCloakService = inject(KeycloakService); + private readonly _configService = inject(IqserConfigService); + private readonly _tenantContextHolder = inject(TenantContextHolder); + private readonly _baseHref = inject(BASE_HREF); + + updateStatus(status: KeycloakStatus) { + this.keycloakStatus$.next(status); + } + + createLoginUrlAndExecute() { + window.location.href = this._keyCloakService.getKeycloakInstance().createLoginUrl({ + redirectUri: this.createLoginUrl(), + idpHint: this._configService.values.OAUTH_IDP_HINT, + }); + } + + createLoginUrl() { + let url; + if ( + this._tenantContextHolder.currentTenant && + window.location.href.indexOf('/' + this._tenantContextHolder.currentTenant + '/main') > 0 + ) { + url = window.location.href; + } else { + url = window.location.origin + this._baseHref; + if (this._tenantContextHolder.currentTenant) { + url = url + '/' + this._tenantContextHolder.currentTenant + '/main'; + } + } + + console.log('Created Url', url, this._baseHref, this._tenantContextHolder.currentTenant); + return url; + } +} From 5c24018d1265f53ec312ce79f03bbc7af14a53f5 Mon Sep 17 00:00:00 2001 From: Dan Percic Date: Fri, 31 Mar 2023 13:38:39 +0300 Subject: [PATCH 2/3] fix routerLinks for multi tenancy --- src/lib/listing/listing.module.ts | 2 ++ .../table-content/table-content.component.html | 4 ++-- src/lib/tenants/index.ts | 1 + src/lib/tenants/tenant.pipe.ts | 16 ++++++++++++++++ src/lib/utils/tokens.ts | 2 ++ 5 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 src/lib/tenants/tenant.pipe.ts diff --git a/src/lib/listing/listing.module.ts b/src/lib/listing/listing.module.ts index bd16572..575ac93 100644 --- a/src/lib/listing/listing.module.ts +++ b/src/lib/listing/listing.module.ts @@ -21,6 +21,7 @@ import { CircleButtonComponent, IconButtonComponent } from '../buttons'; import { MatIconModule } from '@angular/material/icon'; import { EmptyStateComponent } from '../empty-state'; import { InputWithActionComponent, RoundCheckboxComponent } from '../inputs'; +import { TenantPipe } from '../tenants/tenant.pipe'; const matModules = [MatTooltipModule, MatIconModule]; const components = [ @@ -50,6 +51,7 @@ const modules = [DragDropModule, TranslateModule, IqserFiltersModule, ScrollingM RoundCheckboxComponent, InputWithActionComponent, SyncWidthDirective, + TenantPipe, ], }) export class IqserListingModule {} diff --git a/src/lib/listing/table-content/table-content.component.html b/src/lib/listing/table-content/table-content.component.html index af9337d..d2c8ecc 100644 --- a/src/lib/listing/table-content/table-content.component.html +++ b/src/lib/listing/table-content/table-content.component.html @@ -17,7 +17,7 @@ [class.help-mode]="helpModeService?.isHelpModeActive$ | async" [id]="'item-' + entity.id" [ngClass]="getTableItemClasses(entity)" - [routerLink]="entity.routerLink" + [routerLink]="entity.routerLink | tenant" > ('BASE_HREF', { return baseUrl.substring(0, baseUrl.length - 1); } + console.log('Base URL:', baseUrl); + return baseUrl; }, }); From 86b52a643a1ffa0acd50dd59e90bf16e98999d27 Mon Sep 17 00:00:00 2001 From: Dan Percic Date: Fri, 31 Mar 2023 13:39:35 +0300 Subject: [PATCH 3/3] lint fix --- src/lib/services/generic.service.ts | 4 +++- src/lib/tenants/tenant-resolve/tenant-resolve.component.scss | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/services/generic.service.ts b/src/lib/services/generic.service.ts index d9978bf..2f1af1e 100644 --- a/src/lib/services/generic.service.ts +++ b/src/lib/services/generic.service.ts @@ -24,7 +24,9 @@ export interface QueryParam { */ export abstract class GenericService { protected readonly _http = inject(HttpClient); - protected readonly _lastCheckedForChanges = new Map([[ROOT_CHANGES_KEY, new Date(Date.now() - LAST_CHECKED_OFFSET).toISOString()]]); + protected readonly _lastCheckedForChanges = new Map([ + [ROOT_CHANGES_KEY, new Date(Date.now() - LAST_CHECKED_OFFSET).toISOString()], + ]); protected abstract readonly _defaultModelPath: string; get(): Observable; diff --git a/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss b/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss index f6f439e..4838f02 100644 --- a/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss +++ b/src/lib/tenants/tenant-resolve/tenant-resolve.component.scss @@ -7,7 +7,7 @@ margin-top: 32px; - > form > *{ + > form > * { padding: 8px; } }