Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
feat(server/ui): toggle components feature when it's not available (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
KarimGl committed May 4, 2023
1 parent 31e672e commit 0e15566
Show file tree
Hide file tree
Showing 30 changed files with 255 additions and 77 deletions.
3 changes: 2 additions & 1 deletion .idea/runConfigurations/server_all_tests.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.chutneytesting.feature;

import com.chutneytesting.server.core.domain.feature.Feature;
import org.springframework.stereotype.Component;

@Component
public class ComponentFeature implements Feature {

@Override
public String name() {
return "COMPONENT";
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.chutneytesting.server.core.domain.feature;

public interface Feature {

String name();
default boolean active() {return true;}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.chutneytesting.feature.api;

import com.chutneytesting.feature.api.dto.FeatureDto;
import com.chutneytesting.server.core.domain.feature.Feature;
import java.util.List;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v2/features")
@CrossOrigin(origins = "*")
public class FeatureController {

private final List<Feature> features;

public FeatureController(List<Feature> features) {
this.features = features;
}

@GetMapping
public List<FeatureDto> getAll() {
return features.stream().map(feature -> new FeatureDto(feature.name(), feature.active())).toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.chutneytesting.feature.api.dto;

public record FeatureDto(String name, boolean active) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ private static Object[] securedEndPointList() {
{DELETE, "/api/v2/environments/envName/targets/targetName", "ENVIRONMENT_ACCESS", null, NOT_FOUND},
// Must be at the end because the network configuration is in wrong staten, why ??
{POST, "/api/v1/agentnetwork/wrapup", "ADMIN_ACCESS", "{\"agentsGraph\":{\"agents\":[]},\"networkConfiguration\":{\"creationDate\":\"2021-09-06T10:08:36.569227Z\",\"agentNetworkConfiguration\":[],\"environmentsConfiguration\":[]}}", OK},
{GET, "/api/v2/features", null, null, OK},
};
}

Expand Down
62 changes: 39 additions & 23 deletions ui/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,92 +7,108 @@ import { Authorization } from '@model';
import { ParentComponent } from '@core/components/parent/parent.component';
import { ChutneyMainHeaderComponent } from '@shared/components/layout/header/chutney-main-header.component';
import { ChutneyLeftMenuComponent } from '@shared/components/layout/left-menu/chutney-left-menu.component';
import { FeatureName } from '@core/feature/feature.model';
import { FeaturesGuard } from '@core/guards/features.guard';
import { FeaturesResolver } from '@core/feature/features.resolver';

export const appRoutes: Routes = [
{ path: 'login', component: LoginComponent },
{ path: 'login/:action', component: LoginComponent },
{ path: '', component: ParentComponent, children: [
{ path: '', component: ChutneyMainHeaderComponent, outlet: 'header' },
{ path: '', component: ChutneyLeftMenuComponent, outlet: 'left-side-bar' },
{ path: '', redirectTo: '/login', pathMatch: 'full' },
{path: 'login', component: LoginComponent},
{path: 'login/:action', component: LoginComponent},
{
path: '', component: ParentComponent,
canActivate: [AuthGuard],
resolve: {'features': FeaturesResolver},
children: [
{path: '', component: ChutneyMainHeaderComponent, outlet: 'header'},
{path: '', component: ChutneyLeftMenuComponent, outlet: 'left-side-bar'},
{path: '', redirectTo: '/scenario', pathMatch: 'full'},
{
path: 'scenario',
loadChildren: () => import('./modules/scenarios/scenario.module').then(m => m.ScenarioModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.SCENARIO_READ,Authorization.SCENARIO_WRITE,Authorization.SCENARIO_EXECUTE ] }
data: {'authorizations': [Authorization.SCENARIO_READ, Authorization.SCENARIO_WRITE, Authorization.SCENARIO_EXECUTE]}
},
{
path: 'campaign',
loadChildren: () => import('./modules/campaign/campaign.module').then(m => m.CampaignModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.CAMPAIGN_READ,Authorization.CAMPAIGN_WRITE,Authorization.CAMPAIGN_EXECUTE ] }
data: {'authorizations': [Authorization.CAMPAIGN_READ, Authorization.CAMPAIGN_WRITE, Authorization.CAMPAIGN_EXECUTE]}
},
{
path: 'component',
loadChildren: () => import('./modules/component/component.module').then(m => m.ComponentModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.COMPONENT_READ,Authorization.COMPONENT_WRITE ] }
canActivate: [AuthGuard, FeaturesGuard],
data: {
'authorizations': [Authorization.COMPONENT_READ, Authorization.COMPONENT_WRITE],
'feature': FeatureName.COMPONENT
}
},
{
path: 'variable',
loadChildren: () => import('./modules/global-variable/global-variable.module').then(m => m.GlobalVariableModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.GLOBAL_VAR_READ,Authorization.GLOBAL_VAR_WRITE ] }
data: {'authorizations': [Authorization.GLOBAL_VAR_READ, Authorization.GLOBAL_VAR_WRITE]}
},
{
path: 'dataset',
loadChildren: () => import('./modules/dataset/dataset.module').then(m => m.DatasetModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.DATASET_READ,Authorization.DATASET_WRITE ] }
canActivate: [AuthGuard, FeaturesGuard], // add requiredAuthorizations
data: {
'authorizations': [Authorization.DATASET_READ, Authorization.DATASET_WRITE],
'feature': FeatureName.COMPONENT
}
},
{
path: 'configurationAgent',
loadChildren: () => import('./modules/agent-network/agent-network.module').then(m => m.AgentNetworkModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ADMIN_ACCESS]}
},
{
path: 'plugins',
loadChildren: () => import('./modules/plugins/plugin-configuration.module').then(m => m.PluginConfigurationModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ADMIN_ACCESS]}
},
{
path: 'databaseAdmin',
loadChildren: () => import('./modules/database-admin/database-admin.module').then(m => m.DatabaseAdminModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ADMIN_ACCESS]}
},
{
path: 'targets',
loadChildren: () => import('./modules/target/target.module').then(m => m.TargetModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ENVIRONMENT_ACCESS,Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ENVIRONMENT_ACCESS, Authorization.ADMIN_ACCESS]}
},
{
path: 'environments',
loadChildren: () => import('./modules/environment/environment.module').then(m => m.EnvironmentModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ENVIRONMENT_ACCESS,Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ENVIRONMENT_ACCESS, Authorization.ADMIN_ACCESS]}
},
{
path: 'backups',
loadChildren: () => import('./modules/backups/backups.module').then(m => m.BackupsModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ADMIN_ACCESS]}
},
{
path: 'roles',
loadChildren: () => import('./modules/roles/roles.module').then(m => m.RolesModule),
canActivate: [AuthGuard],
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
data: {'authorizations': [Authorization.ADMIN_ACCESS]}
}
] },
]
},
{path: '**', redirectTo: ''}

];

@NgModule({
imports: [RouterModule.forRoot(appRoutes, { useHash: true, enableTracing: false, relativeLinkResolution: 'legacy' })],
imports: [RouterModule.forRoot(appRoutes, {useHash: true, enableTracing: false, relativeLinkResolution: 'legacy'})],
exports: [RouterModule]
})
export class AppRoutingModule { }
export class AppRoutingModule {
}
9 changes: 5 additions & 4 deletions ui/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { DragulaModule } from 'ng2-dragula';
// Internal common
import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { DefaultMissingTranslationHandler, HttpLoaderFactory } from './app.translate.factory';
import { SharedModule } from '@shared/shared.module';
import { CoreModule } from '@core/core.module';
import { ModalModule, BsModalService } from 'ngx-bootstrap/modal';
import { ThemeService } from '@core/theme/theme.service';
import { initializeThemeFactory } from './app.theme.factory';
import { DefaultMissingTranslationHandler, HttpLoaderFactory } from '@core/initializer/app.translate.factory';
import { themeInitializer } from '@core/initializer/theme.initializer';

@NgModule({
declarations: [
Expand Down Expand Up @@ -57,10 +57,11 @@ import { initializeThemeFactory } from './app.theme.factory';
providers: [BsModalService,
{
provide: APP_INITIALIZER,
useFactory: initializeThemeFactory,
useFactory: themeInitializer,
deps: [ThemeService],
multi: true
}],
}
],
bootstrap: [AppComponent]
})
export class ChutneyAppModule { }
Expand Down
12 changes: 3 additions & 9 deletions ui/src/app/core/components/parent/parent.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,9 @@ export class ParentComponent implements OnInit, OnDestroy {
private linkifierSubscription: Subscription;

constructor(public layoutOptions: LayoutOptions,
private linkifierService: LinkifierService,
private loginService: LoginService) {
this.linkifierSubscription = this.loginService.getUser().subscribe(
user => {
if (this.loginService.isAuthenticated()) {
this.linkifierService.loadLinkifiers().subscribe(); // needed to fetch linkifiers into sessionStorage
}
}
);
private linkifierService: LinkifierService) {
this.linkifierSubscription = this.linkifierService.loadLinkifiers().subscribe();

}

ngOnInit(): void {
Expand Down
8 changes: 8 additions & 0 deletions ui/src/app/core/feature/feature.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Feature {
name: FeatureName,
active: boolean
}

export enum FeatureName {
COMPONENT='COMPONENT'
}
34 changes: 34 additions & 0 deletions ui/src/app/core/feature/feature.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { Feature, FeatureName } from '@core/feature/feature.model';
import { environment } from '@env/environment';
import { filter, tap } from 'rxjs/operators';

@Injectable({
providedIn: 'root'
})
export class FeatureService {

private readonly featuresApi = '/api/v2/features';

private features$:BehaviorSubject<Feature[]> = new BehaviorSubject([]);

constructor(private http: HttpClient) {
}

active(featuresName: FeatureName): boolean {
if(!featuresName) {
return true;
}
const activeFeatures = this.features$.value.filter(feature => feature.active).map(feature => feature.name);
return activeFeatures.includes(featuresName);
}

loadFeatures(): Observable<Feature[]> {
return this.http.get<Feature[]>(environment.backend + this.featuresApi)
.pipe(
tap(features => this.features$.next(features))
);
}
}
18 changes: 18 additions & 0 deletions ui/src/app/core/feature/features.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { Feature } from '@core/feature/feature.model';
import { FeatureService } from '@core/feature/feature.service';

@Injectable({
providedIn: 'root'
})
export class FeaturesResolver implements Resolve<Feature[]> {

constructor(private featureService: FeatureService) {
}

resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Feature[]> {
return this.featureService.loadFeatures();
}
}
1 change: 1 addition & 0 deletions ui/src/app/core/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Authorization } from '@model';
@Injectable({
providedIn: 'root'
})
// TODO Separate authentication and authorization
export class AuthGuard implements CanActivate {

private unauthorizedMessage: string = '';
Expand Down
23 changes: 23 additions & 0 deletions ui/src/app/core/guards/features.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
import { FeatureService } from '@core/feature/feature.service';
import { AlertService } from '@shared';
import { TranslateService } from '@ngx-translate/core';

@Injectable({
providedIn: 'root'
})
export class FeaturesGuard implements CanActivate {
constructor(private featureService: FeatureService,
private alertService: AlertService,
private translateService: TranslateService) {
}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
const canAccess = this.featureService.active(route.data['feature']);
if (!canAccess) {
this.alertService.error(this.translateService.instant('login.unauthorized'), { timeOut: 0, extendedTimeOut: 0, closeButton: true });
}
return canAccess;
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ThemeService } from '@core/theme/theme.service';


export function initializeThemeFactory(themeService: ThemeService): () => void {
export function themeInitializer(themeService: ThemeService): () => void {
return () => themeService.applyCurrentTheme();
}
4 changes: 3 additions & 1 deletion ui/src/app/core/services/login.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class LoginService {
if (this.isAuthenticated()) {
const user: User = this.user$.getValue();
this.router.navigateByUrl(nextUrl ? nextUrl : this.defaultForwardUrl(user));
} else {
this.router.navigateByUrl('/login');
}
}

Expand All @@ -73,7 +75,7 @@ export class LoginService {
delay(500)
).subscribe(
() => {
this.router.navigateByUrl('/');
this.router.navigateByUrl('/login');
}
);
}
Expand Down
4 changes: 3 additions & 1 deletion ui/src/app/modules/agent-network/agent-network.routes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { AgentNetworkComponent } from './components/agent-network/agent-network.component';
import { Routes } from '@angular/router';
import { Authorization } from '@model';

export const AgentNetworkRoute: Routes = [
{
path: '',
component: AgentNetworkComponent
component: AgentNetworkComponent,
data: { 'authorizations': [ Authorization.ADMIN_ACCESS ] }
}
];
Loading

0 comments on commit 0e15566

Please sign in to comment.