diff --git a/apps/red-ui/src/app/app-routing.module.ts b/apps/red-ui/src/app/app-routing.module.ts index 909b4d090..ed3ddb7b1 100644 --- a/apps/red-ui/src/app/app-routing.module.ts +++ b/apps/red-ui/src/app/app-routing.module.ts @@ -9,6 +9,8 @@ import { DownloadsListScreenComponent } from '@components/downloads-list-screen/ import { DossierTemplatesGuard } from '@guards/dossier-templates.guard'; import { DossiersGuard } from '@guards/dossiers.guard'; import { ACTIVE_DOSSIERS_SERVICE, ARCHIVED_DOSSIERS_SERVICE } from './tokens'; +import { FeaturesGuard } from '@guards/features-guard.service'; +import { DOSSIERS_ARCHIVE } from '@utils/constants'; const routes: Routes = [ { @@ -48,9 +50,10 @@ const routes: Routes = [ loadChildren: () => import('./modules/archive/archive.module').then(m => m.ArchiveModule), canActivate: [CompositeRouteGuard], data: { - routeGuards: [AuthGuard, RedRoleGuard, DossierTemplatesGuard, DossiersGuard], + routeGuards: [FeaturesGuard, AuthGuard, RedRoleGuard, DossierTemplatesGuard, DossiersGuard], requiredRoles: ['RED_USER', 'RED_MANAGER'], dossiersService: ARCHIVED_DOSSIERS_SERVICE, + features: [DOSSIERS_ARCHIVE], }, }, { diff --git a/apps/red-ui/src/app/app.module.ts b/apps/red-ui/src/app/app.module.ts index 8518d439b..8459bdb2b 100644 --- a/apps/red-ui/src/app/app.module.ts +++ b/apps/red-ui/src/app/app.module.ts @@ -46,6 +46,7 @@ import { UserPreferenceService } from '@services/user-preference.service'; import { UserService } from '@services/user.service'; import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; import { ArchivedDossiersService } from '@services/dossiers/archived-dossiers.service'; +import { FeaturesService } from '@services/features.service'; export function httpLoaderFactory(httpClient: HttpClient, configService: ConfigService): PruningTranslationLoader { return new PruningTranslationLoader(httpClient, '/assets/i18n/', `.json?version=${configService.values.FRONTEND_APP_VERSION}`); @@ -126,7 +127,16 @@ const components = [AppComponent, AuthErrorComponent, NotificationsComponent, Sp provide: APP_INITIALIZER, multi: true, useFactory: configurationInitializer, - deps: [KeycloakService, Title, ConfigService, GeneralSettingsService, LanguageService, UserService, UserPreferenceService], + deps: [ + KeycloakService, + Title, + ConfigService, + FeaturesService, + GeneralSettingsService, + LanguageService, + UserService, + UserPreferenceService, + ], }, { provide: MissingTranslationHandler, diff --git a/apps/red-ui/src/app/components/base-screen/base-screen.component.ts b/apps/red-ui/src/app/components/base-screen/base-screen.component.ts index eea8ce8fc..6bcafd1f1 100644 --- a/apps/red-ui/src/app/components/base-screen/base-screen.component.ts +++ b/apps/red-ui/src/app/components/base-screen/base-screen.component.ts @@ -9,6 +9,8 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { filter, map, startWith } from 'rxjs/operators'; import { shareDistinctLast } from '@iqser/common-ui'; import { BreadcrumbsService } from '@services/breadcrumbs.service'; +import { FeaturesService } from '@services/features.service'; +import { DOSSIERS_ARCHIVE } from '@utils/constants'; interface MenuItem { readonly name: string; @@ -59,6 +61,7 @@ export class BaseScreenComponent { { text: this._translateService.instant('search.active-dossiers'), icon: 'red:enter', + hide: () => !this._featuresService.isEnabled(DOSSIERS_ARCHIVE), action: (query): void => this._search(query, [], true), }, { @@ -76,11 +79,12 @@ export class BaseScreenComponent { readonly isSearchScreen$ = this._navigationStart$.pipe(map(isSearchScreen)); constructor( + private readonly _router: Router, + private readonly _translateService: TranslateService, + private readonly _featuresService: FeaturesService, readonly userService: UserService, readonly userPreferenceService: UserPreferenceService, readonly titleService: Title, - private readonly _router: Router, - private readonly _translateService: TranslateService, readonly breadcrumbsService: BreadcrumbsService, ) {} diff --git a/apps/red-ui/src/app/guards/features-guard.service.ts b/apps/red-ui/src/app/guards/features-guard.service.ts new file mode 100644 index 000000000..3a793e766 --- /dev/null +++ b/apps/red-ui/src/app/guards/features-guard.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; +import { FeaturesService } from '@services/features.service'; + +@Injectable({ providedIn: 'root' }) +export class FeaturesGuard implements CanActivate { + constructor(private readonly _router: Router, private readonly _featuresService: FeaturesService) {} + + async canActivate(route: ActivatedRouteSnapshot): Promise { + const features: string[] = route.data.features || []; + + if (features.find(feature => !this._featuresService.isEnabled(feature))) { + await this._router.navigate(['']); + return false; + } + + return true; + } +} diff --git a/apps/red-ui/src/app/modules/search/search-screen/search-screen.component.ts b/apps/red-ui/src/app/modules/search/search-screen/search-screen.component.ts index bc707022d..900b677e7 100644 --- a/apps/red-ui/src/app/modules/search/search-screen/search-screen.component.ts +++ b/apps/red-ui/src/app/modules/search/search-screen/search-screen.component.ts @@ -10,7 +10,7 @@ import { SortingOrders, TableColumnConfig, } from '@iqser/common-ui'; -import { combineLatest, Observable } from 'rxjs'; +import { combineLatest, Observable, of } from 'rxjs'; import { debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators'; import { ActivatedRoute, Router } from '@angular/router'; import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; @@ -22,6 +22,8 @@ import { FilesMapService } from '@services/entity-services/files-map.service'; import { PlatformSearchService } from '@services/entity-services/platform-search.service'; import { ActiveDossiersService } from '@services/dossiers/active-dossiers.service'; import { ArchivedDossiersService } from '@services/dossiers/archived-dossiers.service'; +import { FeaturesService } from '@services/features.service'; +import { DOSSIERS_ARCHIVE } from '@utils/constants'; @Component({ templateUrl: './search-screen.component.html', @@ -69,6 +71,7 @@ export class SearchScreenComponent extends ListingComponent imp private readonly _translateService: TranslateService, private readonly _filesMapService: FilesMapService, private readonly _platformSearchService: PlatformSearchService, + private readonly _featuresService: FeaturesService, ) { super(_injector); this.searchService.skip = true; @@ -90,7 +93,9 @@ export class SearchScreenComponent extends ListingComponent imp }; this.filterService.addFilterGroups([dossierNameFilter]); const onlyActiveLabel = this._translateService.instant('search-screen.filters.only-active'); - this.filterService.addSingleFilter({ id: 'onlyActiveDossiers', label: onlyActiveLabel, checked: this._routeOnlyActive }); + if (this.#enabledArchive) { + this.filterService.addSingleFilter({ id: 'onlyActiveDossiers', label: onlyActiveLabel, checked: this._routeOnlyActive }); + } } private get _routeDossierIds(): string[] { @@ -113,8 +118,14 @@ export class SearchScreenComponent extends ListingComponent imp ); } + get #enabledArchive(): boolean { + return this._featuresService.isEnabled(DOSSIERS_ARCHIVE); + } + private get _filtersChanged$(): Observable<[string[], boolean]> { - const onlyActiveDossiers$ = this.filterService.getSingleFilter('onlyActiveDossiers').pipe(map(f => !!f.checked)); + const onlyActiveDossiers$ = this.#enabledArchive + ? this.filterService.getSingleFilter('onlyActiveDossiers').pipe(map(f => !!f.checked)) + : of(true); const filterGroups$ = this.filterService.filterGroups$; return combineLatest([filterGroups$, onlyActiveDossiers$]).pipe( map(([groups, onlyActive]) => { diff --git a/apps/red-ui/src/app/services/breadcrumbs.service.ts b/apps/red-ui/src/app/services/breadcrumbs.service.ts index 16f90f59d..12238cd6b 100644 --- a/apps/red-ui/src/app/services/breadcrumbs.service.ts +++ b/apps/red-ui/src/app/services/breadcrumbs.service.ts @@ -6,9 +6,10 @@ import { filter, pluck } from 'rxjs/operators'; import { FilesMapService } from '@services/entity-services/files-map.service'; import { TranslateService } from '@ngx-translate/core'; import { BreadcrumbTypes } from '@red/domain'; -import { DOSSIER_ID, FILE_ID } from '@utils/constants'; +import { DOSSIER_ID, DOSSIERS_ARCHIVE, FILE_ID } from '@utils/constants'; import { DossiersService } from '@services/dossiers/dossiers.service'; import { dossiersServiceResolver } from '@services/entity-services/dossiers.service.provider'; +import { FeaturesService } from '@services/features.service'; export type RouterLinkActiveOptions = { exact: boolean } | IsActiveMatchOptions; export type BreadcrumbDisplayType = 'text' | 'dropdown'; @@ -39,6 +40,7 @@ export class BreadcrumbsService { private readonly _router: Router, private readonly _translateService: TranslateService, private readonly _filesMapService: FilesMapService, + private readonly _featuresService: FeaturesService, ) { this.breadcrumbs$ = this._store$.asObservable(); @@ -95,7 +97,7 @@ export class BreadcrumbsService { const breadcrumbs = route.data.breadcrumbs || []; - if (breadcrumbs.length === 1) { + if (breadcrumbs.length === 1 && this._featuresService.isEnabled(DOSSIERS_ARCHIVE)) { if (breadcrumbs[0] === BreadcrumbTypes.main) { this._addMainDropdownBreadcrumb('active'); return; diff --git a/apps/red-ui/src/app/services/dossiers/archived-dossiers.service.ts b/apps/red-ui/src/app/services/dossiers/archived-dossiers.service.ts index 75ae0b05c..0f496c809 100644 --- a/apps/red-ui/src/app/services/dossiers/archived-dossiers.service.ts +++ b/apps/red-ui/src/app/services/dossiers/archived-dossiers.service.ts @@ -6,6 +6,8 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { ActiveDossiersService } from './active-dossiers.service'; import { DossiersService } from './dossiers.service'; import { FilesMapService } from '../entity-services/files-map.service'; +import { FeaturesService } from '@services/features.service'; +import { DOSSIERS_ARCHIVE } from '@utils/constants'; @Injectable({ providedIn: 'root' }) export class ArchivedDossiersService extends DossiersService { @@ -13,6 +15,7 @@ export class ArchivedDossiersService extends DossiersService { protected readonly _injector: Injector, private readonly _activeDossiersService: ActiveDossiersService, private readonly _filesMapService: FilesMapService, + private readonly _featuresService: FeaturesService, ) { super(_injector, 'archived-dossiers', 'archive'); } @@ -31,6 +34,10 @@ export class ArchivedDossiersService extends DossiersService { ); } + loadAll(): Observable { + return this._featuresService.isEnabled(DOSSIERS_ARCHIVE) ? super.loadAll() : of([]); + } + #removeFromActiveDossiers(archivedDossiersIds: string[]): void { const remainingEntities = this._activeDossiersService.all.filter(d => !archivedDossiersIds.includes(d.dossierId)); this._activeDossiersService.setEntities(remainingEntities); diff --git a/apps/red-ui/src/app/services/features.service.ts b/apps/red-ui/src/app/services/features.service.ts new file mode 100644 index 000000000..c2c139832 --- /dev/null +++ b/apps/red-ui/src/app/services/features.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; +import { ConfigService } from './config.service'; + +interface Feature { + name: string; + minVersion: string; +} + +function transform(version: string): number { + const [major, minor, patch, release] = version.split(/[.-]/).map(x => parseInt(x, 10)); + return major * 1e7 + minor * 1e5 + patch * 1e3 + (release || 0); +} + +@Injectable({ + providedIn: 'root', +}) +export class FeaturesService { + private _features = new Map(); + + constructor(private readonly _httpClient: HttpClient, private readonly _configService: ConfigService) {} + + isEnabled(feature: string): boolean { + // If feature is not defined in config object return true + return this._features.get(feature) !== false; + } + + loadConfig(): Observable { + return this._httpClient.get('/assets/config/features.json').pipe( + tap((config: { features: Feature[] }) => { + const BACKEND_APP_VERSION = transform(this._configService.values.BACKEND_APP_VERSION as string); + config.features.forEach(feature => { + this._features.set(feature.name, transform(feature.minVersion) <= BACKEND_APP_VERSION); + }); + }), + ); + } +} diff --git a/apps/red-ui/src/app/services/permissions.service.ts b/apps/red-ui/src/app/services/permissions.service.ts index 19dcbd1fc..12c71b15c 100644 --- a/apps/red-ui/src/app/services/permissions.service.ts +++ b/apps/red-ui/src/app/services/permissions.service.ts @@ -5,6 +5,8 @@ import { DossiersService } from '@services/dossiers/dossiers.service'; import { ActivatedRoute } from '@angular/router'; import { dossiersServiceResolver } from '@services/entity-services/dossiers.service.provider'; import { FilesMapService } from '@services/entity-services/files-map.service'; +import { FeaturesService } from '@services/features.service'; +import { DOSSIERS_ARCHIVE } from '@utils/constants'; @Injectable({ providedIn: 'root' }) export class PermissionsService { @@ -13,6 +15,7 @@ export class PermissionsService { private readonly _route: ActivatedRoute, private readonly _injector: Injector, private readonly _filesMapService: FilesMapService, + private readonly _featuresService: FeaturesService, ) {} private get _dossiersService(): DossiersService { @@ -185,7 +188,9 @@ export class PermissionsService { } canArchiveDossier(dossier: Dossier): boolean { - return dossier.isActive && dossier.ownerId === this._userService.currentUser.id; + return ( + this._featuresService.isEnabled(DOSSIERS_ARCHIVE) && dossier.isActive && dossier.ownerId === this._userService.currentUser.id + ); } canEditDossier(dossier: Dossier, user = this._userService.currentUser): boolean { diff --git a/apps/red-ui/src/app/utils/configuration.initializer.ts b/apps/red-ui/src/app/utils/configuration.initializer.ts index 32240e222..9f74cf700 100644 --- a/apps/red-ui/src/app/utils/configuration.initializer.ts +++ b/apps/red-ui/src/app/utils/configuration.initializer.ts @@ -7,11 +7,13 @@ import { GeneralSettingsService } from '@services/general-settings.service'; import { LanguageService } from '@i18n/language.service'; import { UserPreferenceService } from '@services/user-preference.service'; import { UserService } from '@services/user.service'; +import { FeaturesService } from '@services/features.service'; export function configurationInitializer( keycloakService: KeycloakService, title: Title, configService: ConfigService, + featuresService: FeaturesService, generalSettingsService: GeneralSettingsService, languageService: LanguageService, userService: UserService, @@ -21,6 +23,7 @@ export function configurationInitializer( firstValueFrom( keycloakService.keycloakEvents$.pipe( filter(event => event.type === KeycloakEventType.OnReady), + switchMap(() => from(featuresService.loadConfig())), switchMap(() => from(keycloakService.isLoggedIn())), switchMap(loggedIn => (!loggedIn ? throwError('Not Logged In') : of({}))), switchMap(() => from(userService.loadCurrentUser())), diff --git a/apps/red-ui/src/app/utils/constants.ts b/apps/red-ui/src/app/utils/constants.ts index 37387b622..bf535d4d8 100644 --- a/apps/red-ui/src/app/utils/constants.ts +++ b/apps/red-ui/src/app/utils/constants.ts @@ -4,3 +4,4 @@ export const DOSSIER_ID = 'dossierId'; export const FILE_ID = 'fileId'; export const DOSSIER_TEMPLATE_ID = 'dossierTemplateId'; export const DICTIONARY_TYPE = 'dictionary'; +export const DOSSIERS_ARCHIVE = 'DOSSIERS_ARCHIVE'; diff --git a/apps/red-ui/src/assets/config/features.json b/apps/red-ui/src/assets/config/features.json new file mode 100644 index 000000000..bbcfecc3a --- /dev/null +++ b/apps/red-ui/src/assets/config/features.json @@ -0,0 +1,8 @@ +{ + "features": [ + { + "name": "DOSSIERS_ARCHIVE", + "minVersion": "3.3.0" + } + ] +}