NIFI-12543: Users/User Groups (#8191)

* NIFI-12543:
- Users/User Groups.

* NIFI-12543:
- Users/User Groups Deletion.
- Establishing routes for selection, editing, and access policies.

* NIFI-12543:
- User/User Group Creation.
- User/User Group Configuration.
- Renaming existing User State to Current User State.

* NIFI-12543:
- User access policies table.

* NIFI-12543:
- Sorting users/groups in the edit dialog.

* NIFI-12543:
- Addressing review feedback.

This closes #8191
This commit is contained in:
Matt Gilman 2024-01-02 11:17:00 -05:00 committed by GitHub
parent aaa812b1b5
commit 76f880588f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 4414 additions and 172 deletions

View File

@ -47,6 +47,11 @@ const routes: Routes = [
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/counters/feature/counters.module').then((m) => m.CountersModule)
},
{
path: 'users',
canMatch: [authenticationGuard],
loadChildren: () => import('./pages/users/feature/users.module').then((m) => m.UsersModule)
},
{
path: 'summary',
canMatch: [authenticationGuard],

View File

@ -27,7 +27,7 @@ import { environment } from './environments/environment';
import { HTTP_INTERCEPTORS, HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';
import { NavigationActionTiming, RouterState, StoreRouterConnectingModule } from '@ngrx/router-store';
import { rootReducers } from './state';
import { UserEffects } from './state/user/user.effects';
import { CurrentUserEffects } from './state/current-user/current-user.effects';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { LoadingInterceptor } from './service/interceptors/loading.interceptor';
import { AuthInterceptor } from './service/interceptors/auth.interceptor';
@ -59,7 +59,7 @@ import { SystemDiagnosticsEffects } from './state/system-diagnostics/system-diag
navigationActionTiming: NavigationActionTiming.PostActivation
}),
EffectsModule.forRoot(
UserEffects,
CurrentUserEffects,
ExtensionTypesEffects,
AboutEffects,
StatusHistoryEffects,

View File

@ -19,13 +19,13 @@ import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Counters } from './counters.component';
import { authorizationGuard } from '../../../service/guard/authorization.guard';
import { User } from '../../../state/user';
import { CurrentUser } from '../../../state/current-user';
const routes: Routes = [
{
path: '',
component: Counters,
canMatch: [authorizationGuard((user: User) => user.countersPermissions.canRead)]
canMatch: [authorizationGuard((user: CurrentUser) => user.countersPermissions.canRead)]
}
];

View File

@ -18,7 +18,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { resetCounterState } from '../state/counter-listing/counter-listing.actions';
@Component({
@ -30,11 +30,11 @@ export class Counters implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
}
ngOnDestroy(): void {
this.store.dispatch(resetCounterState());
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -24,7 +24,6 @@ import { countersFeatureKey, reducers } from '../state';
import { EffectsModule } from '@ngrx/effects';
import { CounterListingEffects } from '../state/counter-listing/counter-listing.effects';
import { CounterListingModule } from '../ui/counter-listing/counter-listing.module';
import { ParameterContextListingModule } from '../../parameter-contexts/ui/parameter-context-listing/parameter-context-listing.module';
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
@ -36,7 +35,6 @@ import { MatDialogModule } from '@angular/material/dialog';
StoreModule.forFeature(countersFeatureKey, reducers),
EffectsModule.forFeature(CounterListingEffects),
CounterListingModule,
ParameterContextListingModule,
MatDialogModule
]
})

View File

@ -17,8 +17,13 @@
import { CounterListingState } from './index';
import { createReducer, on } from '@ngrx/store';
import { loadCounters, loadCountersSuccess, resetCounterState, resetCounterSuccess } from './counter-listing.actions';
import { parameterContextListingApiError } from '../../../parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions';
import {
counterListingApiError,
loadCounters,
loadCountersSuccess,
resetCounterState,
resetCounterSuccess
} from './counter-listing.actions';
import { produce } from 'immer';
export const initialState: CounterListingState = {
@ -42,7 +47,7 @@ export const counterListingReducer = createReducer(
error: null,
status: 'success' as const
})),
on(parameterContextListingApiError, (state, { error }) => ({
on(counterListingApiError, (state, { error }) => ({
...state,
saving: false,
error,

View File

@ -21,7 +21,7 @@ import { Store } from '@ngrx/store';
import { loadCounters, promptCounterReset } from '../../state/counter-listing/counter-listing.actions';
import { selectCounterListingState } from '../../state/counter-listing/counter-listing.selectors';
import { initialState } from '../../state/counter-listing/counter-listing.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
@Component({
selector: 'counter-listing',
@ -30,7 +30,7 @@ import { selectUser } from '../../../../state/user/user.selectors';
})
export class CounterListing implements OnInit {
counterListingState$ = this.store.select(selectCounterListingState);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<CounterListingState>) {}

View File

@ -17,7 +17,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { loadExtensionTypesForCanvas } from '../../../state/extension-types/extension-types.actions';
import { NiFiState } from '../../../state';
@ -30,11 +30,11 @@ export class FlowDesigner implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
this.store.dispatch(loadExtensionTypesForCanvas());
}
ngOnDestroy(): void {
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -27,8 +27,8 @@ import { CanvasState } from '../../state';
import { transformFeatureKey } from '../../state/transform';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -53,7 +53,7 @@ describe('ConnectableBehavior', () => {
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { transformFeatureKey } from '../../state/transform';
import { selectFlowState } from '../../state/flow/flow.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('DraggableBehavior', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import * as fromTransform from '../../state/transform/transform.reducer';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('EditableBehaviorService', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -27,8 +27,8 @@ import { transformFeatureKey } from '../../state/transform';
import * as fromTransform from '../../state/transform/transform.reducer';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -53,7 +53,7 @@ describe('QuickSelectBehavior', () => {
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -26,8 +26,8 @@ import { provideMockStore } from '@ngrx/store/testing';
import { selectFlowState } from '../../state/flow/flow.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -52,7 +52,7 @@ describe('SelectableBehavior', () => {
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../state/flow/flow.selectors';
import { selectTransform } from '../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import * as fromUser from '../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../state/parameter';
import * as fromParameter from '../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('BirdseyeView', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -27,8 +27,8 @@ import { provideMockStore } from '@ngrx/store/testing';
import { selectFlowState } from '../state/flow/flow.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import * as fromUser from '../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../state/parameter';
import * as fromParameter from '../state/parameter/parameter.reducer';
@ -53,7 +53,7 @@ describe('CanvasUtils', () => {
value: initialState[flowFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -32,9 +32,9 @@ import { BulletinsTip } from '../../../ui/common/tooltips/bulletins-tip/bulletin
import { Position } from '../state/shared';
import { ComponentType, Permissions } from '../../../state/shared';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { User } from '../../../state/user';
import { initialState as initialUserState } from '../../../state/user/user.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import { CurrentUser } from '../../../state/current-user';
import { initialState as initialUserState } from '../../../state/current-user/current-user.reducer';
import { selectCurrentUser } from '../../../state/current-user/current-user.selectors';
@Injectable({
providedIn: 'root'
@ -48,7 +48,7 @@ export class CanvasUtils {
private currentProcessGroupId: string = initialFlowState.id;
private parentProcessGroupId: string | null = initialFlowState.flow.processGroupFlow.parentGroupId;
private canvasPermissions: Permissions = initialFlowState.flow.permissions;
private currentUser: User = initialUserState.user;
private currentUser: CurrentUser = initialUserState.user;
private connections: any[] = [];
private readonly humanizeDuration: Humanizer;
@ -89,7 +89,7 @@ export class CanvasUtils {
});
this.store
.select(selectUser)
.select(selectCurrentUser)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((user) => {
this.currentUser = user;

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../state/flow/flow.selectors';
import { selectTransform } from '../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../state/user/user.selectors';
import * as fromUser from '../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../state/parameter';
import * as fromParameter from '../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('CanvasView', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('ConnectionManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('FunnelManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('LabelManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('PortManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('ProcessGroupManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('ProcessorManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -28,8 +28,8 @@ import { selectFlowState } from '../../state/flow/flow.selectors';
import { selectTransform } from '../../state/transform/transform.selectors';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import * as fromControllerServices from '../../state/controller-services/controller-services.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import * as fromUser from '../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../state/current-user/current-user.reducer';
import { parameterFeatureKey } from '../../state/parameter';
import * as fromParameter from '../../state/parameter/parameter.reducer';
@ -58,7 +58,7 @@ describe('RemoteProcessGroupManager', () => {
value: initialState[transformFeatureKey]
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -115,7 +115,11 @@
Node Status History
</button>
<mat-divider></mat-divider>
<button mat-menu-item class="global-menu-item">
<button
mat-menu-item
class="global-menu-item"
[routerLink]="['/users']"
[disabled]="!user.tenantsPermissions.canRead">
<i class="fa fa-fw fa-users mr-2"></i>
Users
</button>

View File

@ -36,8 +36,8 @@ import { ClusterSummary, ControllerStatus } from '../../../state/flow';
import { Search } from './search/search.component';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { selectUser } from '../../../../../state/user/user.selectors';
import * as fromUser from '../../../../../state/user/user.reducer';
import { selectCurrentUser } from '../../../../../state/current-user/current-user.selectors';
import * as fromUser from '../../../../../state/current-user/current-user.reducer';
describe('HeaderComponent', () => {
let component: HeaderComponent;
@ -103,7 +103,7 @@ describe('HeaderComponent', () => {
value: []
},
{
selector: selectUser,
selector: selectCurrentUser,
value: fromUser.initialState.user
}
]

View File

@ -26,8 +26,8 @@ import {
selectCurrentProcessGroupId,
selectLastRefreshed
} from '../../../state/flow/flow.selectors';
import { selectUser } from '../../../../../state/user/user.selectors';
import { User } from '../../../../../state/user';
import { selectCurrentUser } from '../../../../../state/current-user/current-user.selectors';
import { CurrentUser } from '../../../../../state/current-user';
import { AuthStorage } from '../../../../../service/auth-storage.service';
import { AuthService } from '../../../../../service/auth.service';
import { LoadingService } from '../../../../../service/loading.service';
@ -63,7 +63,7 @@ export class HeaderComponent {
lastRefreshed$ = this.store.select(selectLastRefreshed);
clusterSummary$ = this.store.select(selectClusterSummary);
controllerBulletins$ = this.store.select(selectControllerBulletins);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
currentProcessGroupId$ = this.store.select(selectCurrentProcessGroupId);
constructor(
@ -73,7 +73,7 @@ export class HeaderComponent {
public loadingService: LoadingService
) {}
allowLogin(user: User): boolean {
allowLogin(user: CurrentUser): boolean {
return user.anonymous && location.protocol === 'https:';
}

View File

@ -20,7 +20,7 @@ import { FlowService } from '../../../service/flow.service';
import { inject } from '@angular/core';
import { switchMap, take } from 'rxjs';
import { Store } from '@ngrx/store';
import { UserState } from '../../../../../state/user';
import { CurrentUserState } from '../../../../../state/current-user';
import { FlowState } from '../../../state/flow';
import { selectCurrentProcessGroupId } from '../../../state/flow/flow.selectors';
import { initialState } from '../../../state/flow/flow.reducer';
@ -28,7 +28,7 @@ import { initialState } from '../../../state/flow/flow.reducer';
export const rootGroupGuard: CanActivateFn = (route, state) => {
const router: Router = inject(Router);
const flowService: FlowService = inject(FlowService);
const store: Store<UserState> = inject(Store<FlowState>);
const store: Store<CurrentUserState> = inject(Store<FlowState>);
return store.select(selectCurrentProcessGroupId).pipe(
take(1),

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Login } from './login.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../state/user/user.reducer';
import { initialState } from '../../../state/current-user/current-user.reducer';
describe('Login', () => {
let component: Login;

View File

@ -19,7 +19,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginForm } from './login-form.component';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../state/user/user.reducer';
import { initialState } from '../../../../state/current-user/current-user.reducer';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { MatFormFieldModule } from '@angular/material/form-field';
import { RouterModule } from '@angular/router';

View File

@ -18,7 +18,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
@Component({
selector: 'parameter-contexts',
@ -29,10 +29,10 @@ export class ParameterContexts implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
}
ngOnDestroy(): void {
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -18,7 +18,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { loadProvenanceOptions } from '../state/provenance-event-listing/provenance-event-listing.actions';
import { loadAbout } from '../../../state/about/about.actions';
@ -31,12 +31,12 @@ export class Provenance implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
this.store.dispatch(loadProvenanceOptions());
this.store.dispatch(loadAbout());
}
ngOnDestroy(): void {
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -25,13 +25,13 @@ import { FlowAnalysisRules } from '../ui/flow-analysis-rules/flow-analysis-rules
import { RegistryClients } from '../ui/registry-clients/registry-clients.component';
import { ParameterProviders } from '../ui/parameter-providers/parameter-providers.component';
import { authorizationGuard } from '../../../service/guard/authorization.guard';
import { User } from '../../../state/user';
import { CurrentUser } from '../../../state/current-user';
const routes: Routes = [
{
path: '',
component: Settings,
canMatch: [authorizationGuard((user: User) => user.controllerPermissions.canRead)],
canMatch: [authorizationGuard((user: CurrentUser) => user.controllerPermissions.canRead)],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'general' },
{ path: 'general', component: General },

View File

@ -18,7 +18,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { loadExtensionTypesForSettings } from '../../../state/extension-types/extension-types.actions';
@Component({
@ -57,11 +57,11 @@ export class Settings implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
this.store.dispatch(loadExtensionTypesForSettings());
}
ngOnDestroy(): void {
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -21,7 +21,7 @@ import { ControllerEntity, GeneralState, UpdateControllerConfigRequest } from '.
import { Store } from '@ngrx/store';
import { updateControllerConfig } from '../../../state/general/general.actions';
import { Client } from '../../../../../service/client.service';
import { selectUser } from '../../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../../state/current-user/current-user.selectors';
@Component({
selector: 'general-form',
@ -36,7 +36,7 @@ export class GeneralForm {
this.controllerForm.get('timerDrivenThreadCount')?.setValue(controller.component.maxTimerDrivenThreadCount);
}
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
controllerForm: FormGroup;
constructor(

View File

@ -39,7 +39,7 @@ import { ControllerServiceEntity } from '../../../../state/shared';
import { initialState } from '../../state/management-controller-services/management-controller-services.reducer';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { NiFiState } from '../../../../state';
import { state } from '@angular/animations';
import { resetEnableControllerServiceState } from '../../../../state/contoller-service-state/controller-service-state.actions';
@ -52,7 +52,7 @@ import { resetEnableControllerServiceState } from '../../../../state/contoller-s
export class ManagementControllerServices implements OnInit, OnDestroy {
serviceState$ = this.store.select(selectManagementControllerServicesState);
selectedServiceId$ = this.store.select(selectControllerServiceIdFromRoute);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<NiFiState>) {
this.store

View File

@ -34,7 +34,7 @@ import {
} from '../../state/registry-clients/registry-clients.actions';
import { RegistryClientEntity, RegistryClientsState } from '../../state/registry-clients';
import { initialState } from '../../state/registry-clients/registry-clients.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { NiFiState } from '../../../../state';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -48,7 +48,7 @@ import { state } from '@angular/animations';
export class RegistryClients implements OnInit, OnDestroy {
registryClientsState$ = this.store.select(selectRegistryClientsState);
selectedRegistryClientId$ = this.store.select(selectRegistryClientIdFromRoute);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<NiFiState>) {
this.store

View File

@ -32,7 +32,7 @@ import {
stopReportingTask
} from '../../state/reporting-tasks/reporting-tasks.actions';
import { initialState } from '../../state/reporting-tasks/reporting-tasks.reducer';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { NiFiState } from '../../../../state';
import { state } from '@angular/animations';
@ -44,7 +44,7 @@ import { state } from '@angular/animations';
export class ReportingTasks implements OnInit, OnDestroy {
reportingTaskState$ = this.store.select(selectReportingTasksState);
selectedReportingTaskId$ = this.store.select(selectReportingTaskIdFromRoute);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<NiFiState>) {}

View File

@ -18,7 +18,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startUserPolling, stopUserPolling } from '../../../state/user/user.actions';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { loadSummaryListing, resetSummaryState } from '../state/summary-listing/summary-listing.actions';
interface TabLink {
@ -44,12 +44,12 @@ export class Summary implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startUserPolling());
this.store.dispatch(startCurrentUserPolling());
this.store.dispatch(loadSummaryListing({ recursive: true }));
}
ngOnDestroy(): void {
this.store.dispatch(resetSummaryState());
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -33,7 +33,7 @@ import {
selectSummaryListingStatus,
selectViewStatusHistory
} from '../../state/summary-listing/summary-listing.selectors';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
@ -51,7 +51,7 @@ import { getSystemDiagnosticsAndOpenDialog } from '../../../../state/system-diag
export class ConnectionStatusListing {
loadedTimestamp$ = this.store.select(selectSummaryListingLoadedTimestamp);
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
connectionStatusSnapshots$ = this.store.select(selectConnectionStatusSnapshots);
selectedConnectionId$ = this.store.select(selectConnectionIdFromRoute);

View File

@ -22,7 +22,7 @@ import {
selectSummaryListingLoadedTimestamp,
selectSummaryListingStatus
} from '../../state/summary-listing/summary-listing.selectors';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { PortStatusSnapshotEntity, SummaryListingState } from '../../state/summary-listing';
import { Store } from '@ngrx/store';
import { initialState } from '../../state/summary-listing/summary-listing.reducer';
@ -38,7 +38,7 @@ export class InputPortStatusListing {
portStatusSnapshots$ = this.store.select(selectInputPortStatusSnapshots);
loadedTimestamp$ = this.store.select(selectSummaryListingLoadedTimestamp);
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
selectedPortId$ = this.store.select(selectInputPortIdFromRoute);
constructor(private store: Store<SummaryListingState>) {}

View File

@ -24,7 +24,7 @@ import {
selectSummaryListingLoadedTimestamp,
selectSummaryListingStatus
} from '../../state/summary-listing/summary-listing.selectors';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { Store } from '@ngrx/store';
import { PortStatusSnapshotEntity, SummaryListingState } from '../../state/summary-listing';
import { initialState } from '../../state/summary-listing/summary-listing.reducer';
@ -40,7 +40,7 @@ export class OutputPortStatusListing {
portStatusSnapshots$ = this.store.select(selectOutputPortStatusSnapshots);
loadedTimestamp$ = this.store.select(selectSummaryListingLoadedTimestamp);
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
selectedPortId$ = this.store.select(selectOutputPortIdFromRoute);
constructor(private store: Store<SummaryListingState>) {}

View File

@ -42,7 +42,7 @@ import {
openStatusHistoryDialog
} from '../../../../state/status-history/status-history.actions';
import { ComponentType } from '../../../../state/shared';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { getSystemDiagnosticsAndOpenDialog } from '../../../../state/system-diagnostics/system-diagnostics.actions';
@Component({
@ -54,7 +54,7 @@ export class ProcessGroupStatusListing {
processGroupStatusSnapshots$ = this.store.select(selectProcessGroupStatusSnapshots);
loadedTimestamp$ = this.store.select(selectSummaryListingLoadedTimestamp);
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
selectedProcessGroupId$ = this.store.select(selectProcessGroupIdFromRoute);
processGroupStatus$ = this.store.select(selectProcessGroupStatus);

View File

@ -26,7 +26,7 @@ import {
selectViewStatusHistory
} from '../../state/summary-listing/summary-listing.selectors';
import { ProcessorStatusSnapshotEntity, SummaryListingState } from '../../state/summary-listing';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { initialState } from '../../state/summary-listing/summary-listing.reducer';
import {
getStatusHistoryAndOpenDialog,
@ -49,7 +49,7 @@ export class ProcessorStatusListing {
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
selectedProcessorId$ = this.store.select(selectProcessorIdFromRoute);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<SummaryListingState>) {
this.store

View File

@ -24,7 +24,7 @@ import {
selectSummaryListingStatus,
selectViewStatusHistory
} from '../../state/summary-listing/summary-listing.selectors';
import { selectUser } from '../../../../state/user/user.selectors';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { Store } from '@ngrx/store';
import { RemoteProcessGroupStatusSnapshotEntity, SummaryListingState } from '../../state/summary-listing';
import { filter, switchMap, take } from 'rxjs';
@ -46,7 +46,7 @@ import { getSystemDiagnosticsAndOpenDialog } from '../../../../state/system-diag
export class RemoteProcessGroupStatusListing {
loadedTimestamp$ = this.store.select(selectSummaryListingLoadedTimestamp);
summaryListingStatus$ = this.store.select(selectSummaryListingStatus);
currentUser$ = this.store.select(selectUser);
currentUser$ = this.store.select(selectCurrentUser);
rpgStatusSnapshots$ = this.store.select(selectRemoteProcessGroupStatusSnapshots);
selectedRpgId$ = this.store.select(selectRemoteProcessGroupIdFromRoute);

View File

@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RouterModule, Routes } from '@angular/router';
import { NgModule } from '@angular/core';
import { Users } from './users.component';
import { authorizationGuard } from '../../../service/guard/authorization.guard';
import { CurrentUser } from '../../../state/current-user';
const routes: Routes = [
{
path: '',
component: Users,
canMatch: [authorizationGuard((user: CurrentUser) => user.tenantsPermissions.canRead)],
children: [
{
path: ':id',
component: Users,
children: [
{
path: 'edit',
component: Users
},
{
path: 'policies',
component: Users
}
]
}
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class UsersRoutingModule {}

View File

@ -0,0 +1,28 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="p-4 flex flex-col h-screen justify-between gap-y-5">
<div class="flex justify-between">
<h3 class="text-xl bold user-header">NiFi Users</h3>
<button class="nifi-button" [routerLink]="['/']">
<i class="fa fa-times"></i>
</button>
</div>
<div class="flex-1">
<user-listing></user-listing>
</div>
</div>

View File

@ -0,0 +1,20 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.user-header {
color: #728e9b;
}

View File

@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Users } from './users.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { RouterModule } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { UserListing } from '../ui/user-listing/user-listing.component';
import { initialState } from '../state/user-listing/user-listing.reducer';
describe('Users', () => {
let component: Users;
let fixture: ComponentFixture<Users>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [Users, UserListing],
imports: [RouterModule, RouterTestingModule],
providers: [provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(Users);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../state';
import { startCurrentUserPolling, stopCurrentUserPolling } from '../../../state/current-user/current-user.actions';
import { resetUsersState } from '../state/user-listing/user-listing.actions';
@Component({
selector: 'users',
templateUrl: './users.component.html',
styleUrls: ['./users.component.scss']
})
export class Users implements OnInit, OnDestroy {
constructor(private store: Store<NiFiState>) {}
ngOnInit(): void {
this.store.dispatch(startCurrentUserPolling());
}
ngOnDestroy(): void {
this.store.dispatch(resetUsersState());
this.store.dispatch(stopCurrentUserPolling());
}
}

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Users } from './users.component';
import { UsersRoutingModule } from './users-routing.module';
import { StoreModule } from '@ngrx/store';
import { usersFeatureKey, reducers } from '../state';
import { EffectsModule } from '@ngrx/effects';
import { MatDialogModule } from '@angular/material/dialog';
import { UserListingEffects } from '../state/user-listing/user-listing.effects';
import { UserListingModule } from '../ui/user-listing/user-listing.module';
@NgModule({
declarations: [Users],
exports: [Users],
imports: [
CommonModule,
UsersRoutingModule,
StoreModule.forFeature(usersFeatureKey, reducers),
EffectsModule.forFeature(UserListingEffects),
MatDialogModule,
UserListingModule
]
})
export class UsersModule {}

View File

@ -0,0 +1,109 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Client } from '../../../service/client.service';
import { Observable } from 'rxjs';
import { NiFiCommon } from '../../../service/nifi-common.service';
import { UserEntity, UserGroupEntity } from '../../../state/shared';
import {
CreateUserGroupRequest,
CreateUserRequest,
UpdateUserGroupRequest,
UpdateUserRequest
} from '../state/user-listing';
@Injectable({ providedIn: 'root' })
export class UsersService {
private static readonly API: string = '../nifi-api';
/**
* The NiFi model contain the url for each component. That URL is an absolute URL. Angular CSRF handling
* does not work on absolute URLs, so we need to strip off the proto for the request header to be added.
*
* https://stackoverflow.com/a/59586462
*
* @param url
* @private
*/
private stripProtocol(url: string): string {
return this.nifiCommon.substringAfterFirst(url, ':');
}
constructor(
private httpClient: HttpClient,
private client: Client,
private nifiCommon: NiFiCommon
) {}
getUsers(): Observable<any> {
return this.httpClient.get(`${UsersService.API}/tenants/users`);
}
getUserGroups(): Observable<any> {
return this.httpClient.get(`${UsersService.API}/tenants/user-groups`);
}
createUser(request: CreateUserRequest): Observable<any> {
const payload: any = {
revision: request.revision,
component: request.userPayload
};
return this.httpClient.post(`${UsersService.API}/tenants/users`, payload);
}
createUserGroup(request: CreateUserGroupRequest): Observable<any> {
const payload: any = {
revision: request.revision,
component: request.userGroupPayload
};
return this.httpClient.post(`${UsersService.API}/tenants/user-groups`, payload);
}
updateUser(request: UpdateUserRequest): Observable<any> {
const payload: any = {
revision: request.revision,
component: {
id: request.id,
...request.userPayload
}
};
return this.httpClient.put(this.stripProtocol(request.uri), payload);
}
updateUserGroup(request: UpdateUserGroupRequest): Observable<any> {
const payload: any = {
revision: request.revision,
component: {
id: request.id,
...request.userGroupPayload
}
};
return this.httpClient.put(this.stripProtocol(request.uri), payload);
}
deleteUser(user: UserEntity): Observable<any> {
const revision: any = this.client.getRevision(user);
return this.httpClient.delete(this.stripProtocol(user.uri), { params: revision });
}
deleteUserGroup(userGroup: UserGroupEntity): Observable<any> {
const revision: any = this.client.getRevision(userGroup);
return this.httpClient.delete(this.stripProtocol(userGroup.uri), { params: revision });
}
}

View File

@ -0,0 +1,34 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Action, combineReducers, createFeatureSelector } from '@ngrx/store';
import { userListingReducer } from './user-listing/user-listing.reducer';
import { UserListingState } from './user-listing';
export const usersFeatureKey = 'users';
export interface UsersState {
[usersFeatureKey]: UserListingState;
}
export function reducers(state: UsersState | undefined, action: Action) {
return combineReducers({
[usersFeatureKey]: userListingReducer
})(state, action);
}
export const selectUserState = createFeatureSelector<UsersState>(usersFeatureKey);

View File

@ -0,0 +1,121 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AccessPolicySummaryEntity, Revision, UserEntity, UserGroupEntity } from '../../../../state/shared';
export interface SelectedTenant {
id: string;
user?: UserEntity;
userGroup?: UserGroupEntity;
}
export interface LoadTenantsSuccess {
users: UserEntity[];
userGroups: UserGroupEntity[];
loadedTimestamp: string;
}
export interface CreateUserRequest {
revision: Revision;
userPayload: any;
userGroupUpdate?: {
requestId: number;
userGroups: string[];
};
}
export interface CreateUserResponse {
user: UserEntity;
userGroupUpdate?: {
requestId: number;
userGroups: string[];
};
}
export interface CreateUserGroupRequest {
revision: Revision;
userGroupPayload: any;
}
export interface CreateUserGroupResponse {
userGroup: UserGroupEntity;
}
export interface UpdateUserRequest {
revision: Revision;
id: string;
uri: string;
userPayload: any;
userGroupUpdate?: {
requestId: number;
userGroupsAdded: string[];
userGroupsRemoved: string[];
};
}
export interface UpdateUserResponse {
user: UserEntity;
userGroupUpdate?: {
requestId: number;
userGroupsAdded: string[];
userGroupsRemoved: string[];
};
}
export interface UpdateUserGroupRequest {
requestId?: number;
revision: Revision;
id: string;
uri: string;
userGroupPayload: any;
}
export interface UpdateUserGroupResponse {
requestId?: number;
userGroup: UserGroupEntity;
}
export interface EditUserDialogRequest {
user: UserEntity;
}
export interface EditUserGroupDialogRequest {
userGroup: UserGroupEntity;
}
export interface DeleteUserRequest {
user: UserEntity;
}
export interface DeleteUserGroupRequest {
userGroup: UserGroupEntity;
}
export interface UserAccessPoliciesDialogRequest {
id: string;
identity: string;
accessPolicies: AccessPolicySummaryEntity[];
}
export interface UserListingState {
users: UserEntity[];
userGroups: UserGroupEntity[];
saving: boolean;
loadedTimestamp: string;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -0,0 +1,145 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createAction, props } from '@ngrx/store';
import {
CreateUserGroupRequest,
CreateUserGroupResponse,
CreateUserRequest,
CreateUserResponse,
DeleteUserGroupRequest,
DeleteUserRequest,
EditUserGroupDialogRequest,
EditUserDialogRequest,
LoadTenantsSuccess,
UserAccessPoliciesDialogRequest,
UpdateUserRequest,
UpdateUserResponse,
UpdateUserGroupRequest,
UpdateUserGroupResponse
} from './index';
const USER_PREFIX: string = '[User Listing]';
export const resetUsersState = createAction(`${USER_PREFIX} Reset Users State`);
export const loadTenants = createAction(`${USER_PREFIX} Load Tenants`);
export const loadTenantsSuccess = createAction(
`${USER_PREFIX} Load Tenants Success`,
props<{ response: LoadTenantsSuccess }>()
);
export const usersApiError = createAction(`${USER_PREFIX} Users Api Error`, props<{ error: string }>());
export const openCreateTenantDialog = createAction(`${USER_PREFIX} Open Create Tenant Dialog`);
export const createUser = createAction(`${USER_PREFIX} Create User`, props<{ request: CreateUserRequest }>());
export const createUserSuccess = createAction(
`${USER_PREFIX} Create User Success`,
props<{
response: CreateUserResponse;
}>()
);
export const createUserComplete = createAction(
`${USER_PREFIX} Create User Complete`,
props<{
response: CreateUserResponse;
}>()
);
export const createUserGroup = createAction(
`${USER_PREFIX} Create User Group`,
props<{
request: CreateUserGroupRequest;
}>()
);
export const createUserGroupSuccess = createAction(
`${USER_PREFIX} Create User Group Success`,
props<{
response: CreateUserGroupResponse;
}>()
);
export const updateUser = createAction(`${USER_PREFIX} Update User`, props<{ request: UpdateUserRequest }>());
export const updateUserSuccess = createAction(
`${USER_PREFIX} Update User Success`,
props<{
response: UpdateUserResponse;
}>()
);
export const updateUserComplete = createAction(`${USER_PREFIX} Update User Complete`);
export const updateUserGroup = createAction(
`${USER_PREFIX} Update User Group`,
props<{
request: UpdateUserGroupRequest;
}>()
);
export const updateUserGroupSuccess = createAction(
`${USER_PREFIX} Update User Group Success`,
props<{
response: UpdateUserGroupResponse;
}>()
);
export const selectTenant = createAction(`${USER_PREFIX} Select Tenant`, props<{ id: string }>());
export const navigateToEditTenant = createAction(`${USER_PREFIX} Navigate To Edit Tenant`, props<{ id: string }>());
export const openConfigureUserDialog = createAction(
`${USER_PREFIX} Open Configure User Dialog`,
props<{ request: EditUserDialogRequest }>()
);
export const openConfigureUserGroupDialog = createAction(
`${USER_PREFIX} Open Configure User Group Dialog`,
props<{ request: EditUserGroupDialogRequest }>()
);
export const navigateToViewAccessPolicies = createAction(
`${USER_PREFIX} Navigate To View Access Policies`,
props<{ id: string }>()
);
export const openUserAccessPoliciesDialog = createAction(
`${USER_PREFIX} Open User Access Policy Dialog`,
props<{ request: UserAccessPoliciesDialogRequest }>()
);
export const promptDeleteUser = createAction(
`${USER_PREFIX} Prompt Delete User`,
props<{ request: DeleteUserRequest }>()
);
export const deleteUser = createAction(`${USER_PREFIX} Delete User`, props<{ request: DeleteUserRequest }>());
export const promptDeleteUserGroup = createAction(
`${USER_PREFIX} Prompt Delete User Group`,
props<{ request: DeleteUserGroupRequest }>()
);
export const deleteUserGroup = createAction(
`${USER_PREFIX} Delete User Group`,
props<{ request: DeleteUserGroupRequest }>()
);

View File

@ -0,0 +1,734 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { NiFiState } from '../../../../state';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import * as UserListingActions from './user-listing.actions';
import {
catchError,
combineLatest,
filter,
from,
map,
mergeMap,
of,
switchMap,
take,
takeUntil,
tap,
withLatestFrom
} from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { UsersService } from '../../service/users.service';
import { YesNoDialog } from '../../../../ui/common/yes-no-dialog/yes-no-dialog.component';
import { EditTenantDialog } from '../../../../ui/common/edit-tenant/edit-tenant-dialog.component';
import { selectSaving, selectUserGroups, selectUsers } from './user-listing.selectors';
import { EditTenantRequest, UserGroupEntity } from '../../../../state/shared';
import { selectTenant } from './user-listing.actions';
import { Client } from '../../../../service/client.service';
import { NiFiCommon } from '../../../../service/nifi-common.service';
import { UserAccessPolicies } from '../../ui/user-listing/user-access-policies/user-access-policies.component';
@Injectable()
export class UserListingEffects {
private requestId: number = 0;
constructor(
private actions$: Actions,
private client: Client,
private nifiCommon: NiFiCommon,
private store: Store<NiFiState>,
private router: Router,
private usersService: UsersService,
private dialog: MatDialog
) {}
loadTenants$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.loadTenants),
switchMap(() =>
combineLatest([this.usersService.getUsers(), this.usersService.getUserGroups()]).pipe(
map(([usersResponse, userGroupsResponse]) =>
UserListingActions.loadTenantsSuccess({
response: {
users: usersResponse.users,
userGroups: userGroupsResponse.userGroups,
loadedTimestamp: usersResponse.generated
}
})
),
catchError((error) =>
of(
UserListingActions.usersApiError({
error: error.error
})
)
)
)
)
)
);
selectTenant$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.selectTenant),
map((action) => action.id),
tap((id) => {
this.router.navigate(['/users', id]);
})
),
{ dispatch: false }
);
openCreateTenantDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.openCreateTenantDialog),
withLatestFrom(this.store.select(selectUsers), this.store.select(selectUserGroups)),
tap(([action, existingUsers, existingUserGroups]) => {
const editTenantRequest: EditTenantRequest = {
existingUsers,
existingUserGroups
};
const dialogReference = this.dialog.open(EditTenantDialog, {
data: editTenantRequest,
panelClass: 'medium-dialog'
});
dialogReference.componentInstance.saving$ = this.store.select(selectSaving);
dialogReference.componentInstance.editTenant
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe((response) => {
if (response.user) {
this.store.dispatch(
UserListingActions.createUser({
request: {
revision: response.revision,
userPayload: response.user.payload,
userGroupUpdate: {
requestId: this.requestId++,
userGroups: response.user.userGroupsAdded
}
}
})
);
} else if (response.userGroup) {
const users: any[] = response.userGroup.users.map((id: string) => {
return { id };
});
this.store.dispatch(
UserListingActions.createUserGroup({
request: {
revision: response.revision,
userGroupPayload: {
...response.userGroup.payload,
users
}
}
})
);
}
});
})
),
{ dispatch: false }
);
createUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUser),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.createUser(request)).pipe(
map((response) =>
UserListingActions.createUserSuccess({
response: {
user: response,
userGroupUpdate: request.userGroupUpdate
}
})
),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
createUserSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUserSuccess),
map((action) => action.response),
withLatestFrom(this.store.select(selectUserGroups)),
switchMap(([response, userGroups]) => {
if (response.userGroupUpdate) {
const userGroupUpdate = response.userGroupUpdate;
const userGroupUpdates = [];
if (!this.nifiCommon.isEmpty(userGroupUpdate.userGroups)) {
userGroupUpdates.push(
...userGroupUpdate.userGroups
.map((userGroupId: string) =>
userGroups.find((userGroup) => userGroup.id == userGroupId)
)
.filter((userGroup) => userGroup != null)
.map((userGroup) => {
// @ts-ignore
const ug: UserGroupEntity = userGroup;
const users: any[] = [
...ug.component.users.map((user) => {
return {
id: user.id
};
}),
{ id: response.user.id }
];
return UserListingActions.updateUserGroup({
request: {
requestId: userGroupUpdate.requestId,
id: ug.id,
uri: ug.uri,
revision: this.client.getRevision(userGroup),
userGroupPayload: {
...ug.component,
users
}
}
});
})
);
}
if (userGroupUpdates.length === 0) {
return of(UserListingActions.createUserComplete({ response }));
} else {
return userGroupUpdates;
}
} else {
return of(UserListingActions.createUserComplete({ response }));
}
})
)
);
awaitUpdateUserGroupsForCreateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUserSuccess),
map((action) => action.response),
filter((response) => response.userGroupUpdate != null),
mergeMap((createUserResponse) =>
this.actions$.pipe(
ofType(UserListingActions.updateUserGroupSuccess),
filter(
(updateSuccess) =>
// @ts-ignore
createUserResponse.userGroupUpdate.requestId === updateSuccess.response.requestId
),
map((response) => UserListingActions.createUserComplete({ response: createUserResponse }))
)
)
)
);
createUserComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUserComplete),
map((action) => action.response),
tap((response) => {
this.dialog.closeAll();
this.store.dispatch(selectTenant({ id: response.user.id }));
}),
switchMap(() => of(UserListingActions.loadTenants()))
)
);
createUserGroup$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUserGroup),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.createUserGroup(request)).pipe(
map((response) =>
UserListingActions.createUserGroupSuccess({
response: {
userGroup: response
}
})
),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
createUserGroupSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.createUserGroupSuccess),
map((action) => action.response),
tap((response) => {
this.dialog.closeAll();
this.store.dispatch(selectTenant({ id: response.userGroup.id }));
}),
switchMap(() => of(UserListingActions.loadTenants()))
)
);
navigateToEditTenant$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.navigateToEditTenant),
map((action) => action.id),
tap((id) => {
this.router.navigate(['/users', id, 'edit']);
})
),
{ dispatch: false }
);
openConfigureUserDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.openConfigureUserDialog),
map((action) => action.request),
withLatestFrom(this.store.select(selectUsers), this.store.select(selectUserGroups)),
tap(([request, existingUsers, existingUserGroups]) => {
const editTenantRequest: EditTenantRequest = {
user: request.user,
existingUsers,
existingUserGroups
};
const dialogReference = this.dialog.open(EditTenantDialog, {
data: editTenantRequest,
panelClass: 'medium-dialog'
});
dialogReference.componentInstance.saving$ = this.store.select(selectSaving);
dialogReference.componentInstance.editTenant
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe((response) => {
if (response.user) {
const userGroupsAdded: string[] = response.user.userGroupsAdded;
const userGroupsRemoved: string[] = response.user.userGroupsRemoved;
if (
this.nifiCommon.isEmpty(userGroupsAdded) &&
this.nifiCommon.isEmpty(userGroupsRemoved)
) {
this.store.dispatch(
UserListingActions.updateUser({
request: {
revision: response.revision,
id: request.user.id,
uri: request.user.uri,
userPayload: {
...request.user.component,
...response.user.payload
}
}
})
);
} else {
this.store.dispatch(
UserListingActions.updateUser({
request: {
revision: response.revision,
id: request.user.id,
uri: request.user.uri,
userPayload: {
...request.user.component,
...response.user.payload
},
userGroupUpdate: {
requestId: this.requestId++,
userGroupsAdded: response.user.userGroupsAdded,
userGroupsRemoved: response.user.userGroupsRemoved
}
}
})
);
}
}
});
dialogReference.afterClosed().subscribe((response) => {
this.store.dispatch(
selectTenant({
id: request.user.id
})
);
});
})
),
{ dispatch: false }
);
updateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUser),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.updateUser(request)).pipe(
map((response) =>
UserListingActions.updateUserSuccess({
response: {
user: response,
userGroupUpdate: request.userGroupUpdate
}
})
),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
updateUserSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUserSuccess),
map((action) => action.response),
withLatestFrom(this.store.select(selectUserGroups)),
switchMap(([response, userGroups]) => {
if (response.userGroupUpdate) {
const userGroupUpdate = response.userGroupUpdate;
const userGroupUpdates = [];
if (!this.nifiCommon.isEmpty(userGroupUpdate.userGroupsAdded)) {
userGroupUpdates.push(
...userGroupUpdate.userGroupsAdded
.map((userGroupId: string) =>
userGroups.find((userGroup) => userGroup.id == userGroupId)
)
.filter((userGroup) => userGroup != null)
.map((userGroup) => {
// @ts-ignore
const ug: UserGroupEntity = userGroup;
const users: any[] = [
...ug.component.users.map((user) => {
return {
id: user.id
};
}),
{ id: response.user.id }
];
return UserListingActions.updateUserGroup({
request: {
requestId: userGroupUpdate.requestId,
revision: this.client.getRevision(userGroup),
id: ug.id,
uri: ug.uri,
userGroupPayload: {
...ug.component,
users
}
}
});
})
);
}
if (!this.nifiCommon.isEmpty(userGroupUpdate.userGroupsRemoved)) {
userGroupUpdates.push(
...userGroupUpdate.userGroupsRemoved
.map((userGroupId: string) =>
userGroups.find((userGroup) => userGroup.id == userGroupId)
)
.filter((userGroup) => userGroup != null)
.map((userGroup) => {
// @ts-ignore
const ug: UserGroupEntity = userGroup;
const users: any[] = [
...ug.component.users
.filter((user) => user.id != response.user.id)
.map((user) => {
return {
id: user.id
};
})
];
return UserListingActions.updateUserGroup({
request: {
requestId: userGroupUpdate.requestId,
revision: this.client.getRevision(userGroup),
id: ug.id,
uri: ug.uri,
userGroupPayload: {
...ug.component,
users
}
}
});
})
);
}
if (userGroupUpdates.length === 0) {
return of(UserListingActions.updateUserComplete());
} else {
return userGroupUpdates;
}
} else {
return of(UserListingActions.updateUserComplete());
}
})
)
);
awaitUpdateUserGroupsForUpdateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUserSuccess),
map((action) => action.response),
filter((response) => response.userGroupUpdate != null),
mergeMap((updateUserResponse) =>
this.actions$.pipe(
ofType(UserListingActions.updateUserGroupSuccess),
filter(
(updateSuccess) =>
// @ts-ignore
updateUserResponse.userGroupUpdate.requestId === updateSuccess.response.requestId
),
map((response) => UserListingActions.updateUserComplete())
)
)
)
);
updateUserComplete$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUserComplete),
tap(() => {
this.dialog.closeAll();
}),
switchMap((request) => of(UserListingActions.loadTenants()))
)
);
openConfigureUserGroupDialog$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.openConfigureUserGroupDialog),
map((action) => action.request),
withLatestFrom(this.store.select(selectUsers), this.store.select(selectUserGroups)),
tap(([request, existingUsers, existingUserGroups]) => {
const editTenantRequest: EditTenantRequest = {
userGroup: request.userGroup,
existingUsers,
existingUserGroups
};
const dialogReference = this.dialog.open(EditTenantDialog, {
data: editTenantRequest,
panelClass: 'medium-dialog'
});
dialogReference.componentInstance.saving$ = this.store.select(selectSaving);
dialogReference.componentInstance.editTenant
.pipe(takeUntil(dialogReference.afterClosed()))
.subscribe((response) => {
if (response.userGroup) {
const users: any[] = response.userGroup.users.map((id: string) => {
return { id };
});
this.store.dispatch(
UserListingActions.updateUserGroup({
request: {
revision: response.revision,
id: response.userGroup.id,
uri: request.userGroup.uri,
userGroupPayload: {
...request.userGroup.component,
...response.userGroup.payload,
users
}
}
})
);
}
});
dialogReference.afterClosed().subscribe(() => {
this.store.dispatch(
selectTenant({
id: request.userGroup.id
})
);
});
})
),
{ dispatch: false }
);
updateUserGroup$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUserGroup),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.updateUserGroup(request)).pipe(
map((response) =>
UserListingActions.updateUserGroupSuccess({
response: {
requestId: request.requestId,
userGroup: response
}
})
),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
updateUserGroupSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.updateUserGroupSuccess),
map((action) => action.response),
filter((response) => response.requestId == null),
tap(() => {
this.dialog.closeAll();
}),
switchMap((request) => of(UserListingActions.loadTenants()))
)
);
navigateToViewAccessPolicies$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.navigateToViewAccessPolicies),
map((action) => action.id),
tap((id) => {
this.router.navigate(['/users', id, 'policies']);
})
),
{ dispatch: false }
);
openUserAccessPoliciesDialog = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.openUserAccessPoliciesDialog),
map((action) => action.request),
tap((request) => {
this.dialog
.open(UserAccessPolicies, {
data: request,
panelClass: 'large-dialog'
})
.afterClosed()
.subscribe((response) => {
if (response != 'ROUTED') {
this.store.dispatch(
selectTenant({
id: request.id
})
);
}
});
})
),
{ dispatch: false }
);
promptDeleteUser$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.promptDeleteUser),
map((action) => action.request),
tap((request) => {
const dialogReference = this.dialog.open(YesNoDialog, {
data: {
title: 'Delete User Account',
message: `Are you sure you want to delete the user account for '${request.user.component.identity}'?`
},
panelClass: 'small-dialog'
});
dialogReference.componentInstance.yes.pipe(take(1)).subscribe(() => {
this.store.dispatch(
UserListingActions.deleteUser({
request
})
);
});
})
),
{ dispatch: false }
);
deleteUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.deleteUser),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.deleteUser(request.user)).pipe(
map((response) => UserListingActions.loadTenants()),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
promptDeleteUserGroup$ = createEffect(
() =>
this.actions$.pipe(
ofType(UserListingActions.promptDeleteUserGroup),
map((action) => action.request),
tap((request) => {
const dialogReference = this.dialog.open(YesNoDialog, {
data: {
title: 'Delete User Account',
message: `Are you sure you want to delete the user group account for '${request.userGroup.component.identity}'?`
},
panelClass: 'small-dialog'
});
dialogReference.componentInstance.yes.pipe(take(1)).subscribe(() => {
this.store.dispatch(
UserListingActions.deleteUserGroup({
request
})
);
});
})
),
{ dispatch: false }
);
deleteUserGroup$ = createEffect(() =>
this.actions$.pipe(
ofType(UserListingActions.deleteUserGroup),
map((action) => action.request),
switchMap((request) =>
from(this.usersService.deleteUserGroup(request.userGroup)).pipe(
map(() => UserListingActions.loadTenants()),
catchError((error) => of(UserListingActions.usersApiError({ error: error.error })))
)
)
)
);
}

View File

@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UserListingState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
createUser,
createUserComplete,
createUserGroup,
createUserGroupSuccess,
loadTenants,
loadTenantsSuccess,
resetUsersState,
updateUser,
updateUserComplete,
updateUserGroup,
updateUserGroupSuccess
} from './user-listing.actions';
export const initialState: UserListingState = {
users: [],
userGroups: [],
saving: false,
loadedTimestamp: '',
error: null,
status: 'pending'
};
export const userListingReducer = createReducer(
initialState,
on(resetUsersState, (state) => ({
...initialState
})),
on(loadTenants, (state) => ({
...state,
status: 'loading' as const
})),
on(loadTenantsSuccess, (state, { response }) => ({
...state,
users: response.users,
userGroups: response.userGroups,
loadedTimestamp: response.loadedTimestamp,
error: null,
status: 'success' as const
})),
on(createUser, updateUser, createUserGroup, updateUserGroup, (state) => ({
...state,
saving: true
})),
on(createUserComplete, (state) => ({
...state,
saving: false
})),
on(updateUserComplete, (state) => ({
...state,
saving: false
})),
on(createUserGroupSuccess, (state, { response }) => ({
...state,
saving: false
})),
on(updateUserGroupSuccess, (state, { response }) => ({
...state,
saving: response.requestId == null ? false : state.saving
}))
);

View File

@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSelector } from '@ngrx/store';
import { usersFeatureKey, UsersState, selectUserState } from '../index';
import { SelectedTenant, UserListingState } from './index';
import { selectCurrentRoute } from '../../../../state/router/router.selectors';
export const selectUserListingState = createSelector(selectUserState, (state: UsersState) => state[usersFeatureKey]);
export const selectSaving = createSelector(selectUserListingState, (state: UserListingState) => state.saving);
export const selectUsers = createSelector(selectUserListingState, (state: UserListingState) => state.users);
export const selectUserGroups = createSelector(selectUserListingState, (state: UserListingState) => state.userGroups);
export const selectTenantIdFromRoute = createSelector(selectCurrentRoute, (route) => {
if (route) {
return route.params.id;
}
return null;
});
export const selectSingleEditedTenant = createSelector(selectCurrentRoute, (route) => {
if (route?.routeConfig?.path == 'edit') {
return route.params.id;
}
return null;
});
export const selectTenantForAccessPolicies = createSelector(selectCurrentRoute, (route) => {
if (route?.routeConfig?.path == 'policies') {
return route.params.id;
}
return null;
});
export const selectSelectedTenant = (id: string) =>
createSelector(selectUserListingState, (state: UserListingState) => {
const user = state.users.find((user) => id == user.id);
if (user) {
return {
id,
user
} as SelectedTenant;
}
const userGroup = state.userGroups.find((userGroup) => id == userGroup.id);
if (userGroup) {
return {
id,
userGroup
} as SelectedTenant;
}
return null;
});

View File

@ -0,0 +1,88 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="user-access-policies" tabindex="0">
<h3 mat-dialog-title>User Policies</h3>
<mat-dialog-content>
<div class="flex flex-col justify-between gap-y-3">
<div class="flex flex-col">
<div>User</div>
<div class="value">{{ request.identity }}</div>
</div>
<div class="listing-table">
<div class="h-96 overflow-y-auto overflow-x-hidden border">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="updateSort($event)"
[matSortActive]="sort.active"
[matSortDirection]="sort.direction">
<!-- Policy Column -->
<ng-container matColumnDef="policy">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Policy</th>
<td mat-cell *matCellDef="let item">
<div *ngIf="item.permissions.canRead; else noPermissions">
{{ formatPolicy(item) }}
</div>
<ng-template #noPermissions>
<div class="unset">{{ item.id }}</div>
</ng-template>
</td>
</ng-container>
<!-- Action Column -->
<ng-container matColumnDef="action">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Action</th>
<td mat-cell *matCellDef="let item">
{{ item.component.action }}
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div
class="pointer fa fa-long-arrow-right"
*ngIf="canGoToPolicyTarget(item)"
[routerLink]="getPolicyTargetLink(item)"
mat-dialog-close="ROUTED"
title="Go to"></div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
(click)="selectPolicy(row)"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
</div>
</div>
<div class="value">
Some policies may be inherited by descendant components unless explicitly overridden.
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button color="primary" mat-raised-button mat-dialog-close>Close</button>
</mat-dialog-actions>
</div>

View File

@ -0,0 +1,43 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.user-access-policies {
@include mat.button-density(-1);
font-size: 14px;
.listing-table {
table {
width: auto;
td,
th {
cursor: default;
}
.mat-column-action {
width: 75px;
}
.mat-column-actions {
width: 50px;
}
}
}
}

View File

@ -0,0 +1,66 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAccessPolicies } from './user-access-policies.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { UserAccessPoliciesDialogRequest } from '../../../state/user-listing';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
describe('UserAccessPolicies', () => {
let component: UserAccessPolicies;
let fixture: ComponentFixture<UserAccessPolicies>;
const data: UserAccessPoliciesDialogRequest = {
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
identity: 'group 1',
accessPolicies: [
{
revision: {
clientId: 'b09bd713-018c-1000-e5b8-14855e466f1b',
version: 4
},
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
resource: '/system',
action: 'read',
configurable: true
}
}
]
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [UserAccessPolicies, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(UserAccessPolicies);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,300 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatSortModule, Sort } from '@angular/material/sort';
import { NgIf } from '@angular/common';
import {
AccessPolicySummaryEntity,
ComponentReferenceEntity,
ComponentType,
SelectOption
} from '../../../../../state/shared';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { RouterLink } from '@angular/router';
import { UserAccessPoliciesDialogRequest } from '../../../state/user-listing';
@Component({
selector: 'user-access-policies',
standalone: true,
templateUrl: './user-access-policies.component.html',
imports: [MatButtonModule, MatDialogModule, MatTableModule, MatSortModule, NgIf, RouterLink],
styleUrls: ['./user-access-policies.component.scss', '../../../../../../assets/styles/listing-table.scss']
})
export class UserAccessPolicies {
displayedColumns: string[] = ['policy', 'action', 'actions'];
dataSource: MatTableDataSource<AccessPolicySummaryEntity> = new MatTableDataSource<AccessPolicySummaryEntity>();
selectedPolicyId: string | null = null;
sort: Sort = {
active: 'policy',
direction: 'asc'
};
constructor(
@Inject(MAT_DIALOG_DATA) public request: UserAccessPoliciesDialogRequest,
private nifiCommon: NiFiCommon
) {
this.dataSource.data = this.sortPolicies(request.accessPolicies, this.sort);
}
updateSort(sort: Sort): void {
this.sort = sort;
this.dataSource.data = this.sortPolicies(this.dataSource.data, sort);
}
sortPolicies(policies: AccessPolicySummaryEntity[], sort: Sort): AccessPolicySummaryEntity[] {
const data: AccessPolicySummaryEntity[] = policies.slice();
return data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
let retVal: number = 0;
if (a.permissions.canRead && b.permissions.canRead) {
switch (sort.active) {
case 'policy':
retVal = this.nifiCommon.compareString(this.formatPolicy(a), this.formatPolicy(b));
break;
case 'action':
retVal = this.nifiCommon.compareString(a.component.action, b.component.action);
break;
}
} else {
if (!a.permissions.canRead && !b.permissions.canRead) {
retVal = 0;
}
if (a.permissions.canRead) {
retVal = 1;
} else {
retVal = -1;
}
}
return retVal * (isAsc ? 1 : -1);
});
}
formatPolicy(policy: AccessPolicySummaryEntity): string {
if (policy.component.resource.startsWith('/restricted-components')) {
// restricted components policy
return this.restrictedComponentResourceParser(policy);
} else if (policy.component.componentReference) {
// not restricted/global policy... check if user has access to the component reference
return this.componentResourceParser(policy);
} else {
// may be a global policy
const policyValue: string = this.nifiCommon.substringAfterLast(policy.component.resource, '/');
const policyOption: SelectOption | undefined = this.nifiCommon.getPolicyTypeListing(policyValue);
// if known global policy, format it otherwise format as unknown
if (policyOption) {
return this.globalResourceParser(policyOption);
} else {
return this.unknownResourceParser(policy);
}
}
}
/**
* Generates a human-readable restricted component policy string.
*
* @returns {string}
* @param policy
*/
private restrictedComponentResourceParser(policy: AccessPolicySummaryEntity): string {
const resource: string = policy.component.resource;
if (resource === '/restricted-components') {
return 'Restricted components regardless of restrictions';
}
var subResource = this.nifiCommon.substringAfterFirst(resource, '/restricted-components/');
return `Restricted components requiring '${subResource}'`;
}
/**
* Generates a human-readable component policy string.
*
* @returns {string}
* @param policy
*/
private componentResourceParser(policy: AccessPolicySummaryEntity): string {
let resource: string = policy.component.resource;
let policyLabel: string = '';
// determine policy type
if (resource.startsWith('/policies')) {
resource = this.nifiCommon.substringAfterFirst(resource, '/policies');
policyLabel += 'Admin policy for ';
} else if (resource.startsWith('/data-transfer')) {
resource = this.nifiCommon.substringAfterFirst(resource, '/data-transfer');
policyLabel += 'Site to site policy for ';
} else if (resource.startsWith('/data')) {
resource = this.nifiCommon.substringAfterFirst(resource, '/data');
policyLabel += 'Data policy for ';
} else if (resource.startsWith('/operation')) {
resource = this.nifiCommon.substringAfterFirst(resource, '/operation');
policyLabel += 'Operate policy for ';
} else {
policyLabel += 'Component policy for ';
}
if (resource.startsWith('/processors')) {
policyLabel += 'processor ';
} else if (resource.startsWith('/controller-services')) {
policyLabel += 'controller service ';
} else if (resource.startsWith('/funnels')) {
policyLabel += 'funnel ';
} else if (resource.startsWith('/input-ports')) {
policyLabel += 'input port ';
} else if (resource.startsWith('/labels')) {
policyLabel += 'label ';
} else if (resource.startsWith('/output-ports')) {
policyLabel += 'output port ';
} else if (resource.startsWith('/process-groups')) {
policyLabel += 'process group ';
} else if (resource.startsWith('/remote-process-groups')) {
policyLabel += 'remote process group ';
} else if (resource.startsWith('/reporting-tasks')) {
policyLabel += 'reporting task ';
} else if (resource.startsWith('/parameter-contexts')) {
policyLabel += 'parameter context ';
}
const componentReference: ComponentReferenceEntity | undefined = policy.component.componentReference;
if (componentReference) {
if (componentReference.permissions.canRead) {
policyLabel += componentReference.component.name;
} else {
policyLabel += componentReference.id;
}
}
return policyLabel;
}
/**
* Generates a human-readable global policy string.
*
* @param policy
* @returns {string}
*/
globalResourceParser(policy: SelectOption): string {
return `Global policy to ${policy.text}`;
}
/**
* Generates a human-readable policy string for an unknown resource.
*
* @returns {string}
* @param policy
*/
unknownResourceParser(policy: AccessPolicySummaryEntity): string {
return `Unknown resource ${policy.component.resource}`;
}
canGoToPolicyTarget(policy: AccessPolicySummaryEntity): boolean {
return policy.permissions.canRead && policy.component.componentReference != null;
}
getPolicyTargetLink(policy: AccessPolicySummaryEntity): string[] {
const resource: string = policy.component.resource;
// @ts-ignore
const componentReference: ComponentReferenceEntity = policy.component.componentReference;
if (resource.indexOf('/processors') >= 0) {
return [
'/process-groups',
// @ts-ignore
componentReference.parentGroupId,
ComponentType.Processor,
componentReference.id
];
} else if (resource.indexOf('/controller-services') >= 0) {
if (componentReference.parentGroupId) {
return [
'/process-groups',
componentReference.parentGroupId,
'controller-services',
componentReference.id
];
} else {
return ['/settings', 'management-controller-services', componentReference.id];
}
} else if (resource.indexOf('/funnels') >= 0) {
// @ts-ignore
return ['/process-groups', componentReference.parentGroupId, ComponentType.Funnel, componentReference.id];
} else if (resource.indexOf('/input-ports') >= 0) {
return [
'/process-groups',
// @ts-ignore
componentReference.parentGroupId,
ComponentType.InputPort,
componentReference.id
];
} else if (resource.indexOf('/labels') >= 0) {
// @ts-ignore
return ['/process-groups', componentReference.parentGroupId, ComponentType.Label, componentReference.id];
} else if (resource.indexOf('/output-ports') >= 0) {
return [
'/process-groups',
// @ts-ignore
componentReference.parentGroupId,
ComponentType.OutputPort,
componentReference.id
];
} else if (resource.indexOf('/process-groups') >= 0) {
if (componentReference.parentGroupId) {
return [
'/process-groups',
componentReference.parentGroupId,
ComponentType.ProcessGroup,
componentReference.id
];
} else {
return ['/process-groups', componentReference.id];
}
} else if (resource.indexOf('/remote-process-groups') >= 0) {
return [
'/process-groups',
// @ts-ignore
componentReference.parentGroupId,
ComponentType.RemoteProcessGroup,
componentReference.id
];
} else if (resource.indexOf('/reporting-tasks') >= 0) {
return ['/settings', 'reporting-tasks', componentReference.id];
} else if (resource.indexOf('/parameter-contexts') >= 0) {
return ['/parameter-contexts', componentReference.id];
}
return ['/'];
}
selectPolicy(policy: AccessPolicySummaryEntity): void {
this.selectedPolicyId = policy.id;
}
isSelected(policy: AccessPolicySummaryEntity): boolean {
if (this.selectedPolicyId) {
return policy.id == this.selectedPolicyId;
}
return false;
}
}

View File

@ -0,0 +1,49 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<ng-container *ngIf="userListingState$ | async; let userListingState">
<div *ngIf="isInitialLoading(userListingState); else loaded">
<ngx-skeleton-loader count="3"></ngx-skeleton-loader>
</div>
<ng-template #loaded>
<div class="flex flex-col h-full gap-y-2">
<div class="flex-1" *ngIf="currentUser$ | async as user">
<user-table
[tenants]="{ users: userListingState.users, userGroups: userListingState.userGroups }"
[selectedTenantId]="selectedTenantId$ | async"
[currentUser]="(currentUser$ | async)!"
[configurableUsersAndGroups]="true"
(createTenant)="createTenant()"
(selectTenant)="selectTenant($event)"
(editTenant)="editTenant($event)"
(deleteUser)="deleteUser($event)"
(deleteUserGroup)="deleteUserGroup($event)"
(viewAccessPolicies)="viewAccessPolicies($event)"></user-table>
</div>
<div class="flex justify-between">
<div class="refresh-container flex items-center gap-x-2">
<button class="nifi-button" (click)="refreshUserListing()">
<i class="fa fa-refresh" [class.fa-spin]="userListingState.status === 'loading'"></i>
</button>
<div>Last updated:</div>
<div class="refresh-timestamp">{{ userListingState.loadedTimestamp }}</div>
</div>
</div>
</div>
</ng-template>
</ng-container>

View File

@ -0,0 +1,16 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

View File

@ -0,0 +1,40 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UserListing } from './user-listing.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/user-listing/user-listing.reducer';
describe('UserListing', () => {
let component: UserListing;
let fixture: ComponentFixture<UserListing>;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [UserListing],
providers: [provideMockStore({ initialState })]
});
fixture = TestBed.createComponent(UserListing);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,185 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
import { UserListingState } from '../../state/user-listing';
import {
selectSelectedTenant,
selectSingleEditedTenant,
selectTenantForAccessPolicies,
selectTenantIdFromRoute,
selectUserListingState
} from '../../state/user-listing/user-listing.selectors';
import { initialState } from '../../state/user-listing/user-listing.reducer';
import {
openCreateTenantDialog,
loadTenants,
navigateToEditTenant,
navigateToViewAccessPolicies,
openConfigureUserDialog,
openConfigureUserGroupDialog,
openUserAccessPoliciesDialog,
promptDeleteUser,
promptDeleteUserGroup,
selectTenant
} from '../../state/user-listing/user-listing.actions';
import { filter, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { UserEntity, UserGroupEntity } from '../../../../state/shared';
@Component({
selector: 'user-listing',
templateUrl: './user-listing.component.html',
styleUrls: ['./user-listing.component.scss']
})
export class UserListing implements OnInit {
userListingState$ = this.store.select(selectUserListingState);
selectedTenantId$ = this.store.select(selectTenantIdFromRoute);
currentUser$ = this.store.select(selectCurrentUser);
constructor(private store: Store<UserListingState>) {
this.store
.select(selectSingleEditedTenant)
.pipe(
filter((id: string) => id != null),
switchMap((id: string) =>
this.store.select(selectSelectedTenant(id)).pipe(
filter((entity) => entity != null),
take(1)
)
),
takeUntilDestroyed()
)
.subscribe((selectedTenant) => {
if (selectedTenant?.user) {
this.store.dispatch(
openConfigureUserDialog({
request: {
user: selectedTenant.user
}
})
);
} else if (selectedTenant?.userGroup) {
this.store.dispatch(
openConfigureUserGroupDialog({
request: {
userGroup: selectedTenant.userGroup
}
})
);
}
});
this.store
.select(selectTenantForAccessPolicies)
.pipe(
filter((id: string) => id != null),
switchMap((id: string) =>
this.store.select(selectSelectedTenant(id)).pipe(
filter((entity) => entity != null),
take(1)
)
),
takeUntilDestroyed()
)
.subscribe((selectedTenant) => {
if (selectedTenant?.user) {
this.store.dispatch(
openUserAccessPoliciesDialog({
request: {
id: selectedTenant.user.id,
identity: selectedTenant.user.component.identity,
accessPolicies: selectedTenant.user.component.accessPolicies
}
})
);
} else if (selectedTenant?.userGroup) {
this.store.dispatch(
openUserAccessPoliciesDialog({
request: {
id: selectedTenant.userGroup.id,
identity: selectedTenant.userGroup.component.identity,
accessPolicies: selectedTenant.userGroup.component.accessPolicies
}
})
);
}
});
}
ngOnInit(): void {
this.store.dispatch(loadTenants());
}
isInitialLoading(state: UserListingState): boolean {
return state.loadedTimestamp == initialState.loadedTimestamp;
}
createTenant(): void {
this.store.dispatch(openCreateTenantDialog());
}
selectTenant(id: string): void {
this.store.dispatch(
selectTenant({
id
})
);
}
editTenant(id: string): void {
this.store.dispatch(
navigateToEditTenant({
id
})
);
}
deleteUser(user: UserEntity): void {
this.store.dispatch(
promptDeleteUser({
request: {
user
}
})
);
}
deleteUserGroup(userGroup: UserGroupEntity): void {
this.store.dispatch(
promptDeleteUserGroup({
request: {
userGroup
}
})
);
}
viewAccessPolicies(id: string): void {
this.store.dispatch(
navigateToViewAccessPolicies({
id
})
);
}
refreshUserListing() {
this.store.dispatch(loadTenants());
}
}

View File

@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { UserListing } from './user-listing.component';
import { CommonModule } from '@angular/common';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
import { ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { UserTable } from './user-table/user-table.component';
@NgModule({
declarations: [UserListing, UserTable],
exports: [UserListing],
imports: [
CommonModule,
NgxSkeletonLoaderModule,
MatTableModule,
MatSortModule,
MatInputModule,
ReactiveFormsModule,
MatSelectModule
]
})
export class UserListingModule {}

View File

@ -0,0 +1,106 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="user-table h-full flex flex-col">
<div class="flex justify-between">
<div>
<div class="value">Displaying {{ filteredCount }} of {{ totalCount }}</div>
<form [formGroup]="filterForm">
<div class="flex pt-2">
<div class="mr-2">
<mat-form-field>
<mat-label>Filter</mat-label>
<input matInput type="text" class="small" formControlName="filterTerm" />
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Filter By</mat-label>
<mat-select formControlName="filterColumn">
<mat-option value="user"> user</mat-option>
<mat-option value="membership"> membership</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</form>
</div>
<div class="flex flex-col justify-center">
<button *ngIf="canModifyTenants(currentUser)" class="nifi-button" (click)="createClicked()">
<i class="fa fa-plus"></i>
</button>
</div>
</div>
<div class="flex-1 relative">
<div class="listing-table overflow-y-auto border absolute inset-0">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortDisableClear
(matSortChange)="updateSort($event)"
[matSortActive]="sort.active"
[matSortDirection]="sort.direction">
<!-- User column -->
<ng-container matColumnDef="user">
<th mat-header-cell *matHeaderCellDef mat-sort-header>User</th>
<td mat-cell *matCellDef="let item" class="items-center">
<i *ngIf="item.tenantType === 'userGroup'" class="fa fa-users mr-3"></i>{{ item.user }}
</td>
</ng-container>
<!-- Membership Column -->
<ng-container matColumnDef="membership">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Membership</th>
<td mat-cell *matCellDef="let item">
{{ item.tenantType === 'user' ? 'Member of' : 'Members' }}: {{ formatMembership(item) }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let item">
<div class="flex items-center gap-x-3">
<div
class="pointer fa fa-pencil"
title="Edit"
*ngIf="canEditOrDelete(currentUser, item)"
(click)="editClicked(item, $event)"></div>
<div
class="pointer fa fa-trash"
title="Remove"
*ngIf="canEditOrDelete(currentUser, item)"
(click)="deleteClicked(item)"></div>
<div
class="pointer fa fa-key"
title="View User Policies"
*ngIf="hasAccessPolicies(item)"
(click)="viewAccessPoliciesClicked(item, $event)"></div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr
mat-row
*matRowDef="let row; let even = even; columns: displayedColumns"
(click)="select(row)"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,24 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.user-table {
.listing-table {
.mat-column-actions {
width: 75px;
}
}
}

View File

@ -0,0 +1,194 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserTable } from './user-table.component';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
import { ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { CurrentUser } from '../../../../../state/current-user';
describe('UserTable', () => {
let component: UserTable;
let fixture: ComponentFixture<UserTable>;
const currentUser: CurrentUser = {
identity: 'admin',
anonymous: false,
provenancePermissions: {
canRead: false,
canWrite: false
},
countersPermissions: {
canRead: false,
canWrite: false
},
tenantsPermissions: {
canRead: true,
canWrite: true
},
controllerPermissions: {
canRead: true,
canWrite: true
},
policiesPermissions: {
canRead: true,
canWrite: true
},
systemPermissions: {
canRead: true,
canWrite: false
},
parameterContextPermissions: {
canRead: true,
canWrite: true
},
restrictedComponentsPermissions: {
canRead: false,
canWrite: true
},
componentRestrictionPermissions: [
{
requiredPermission: {
id: 'read-distributed-filesystem',
label: 'read distributed filesystem'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'access-keytab',
label: 'access keytab'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'export-nifi-details',
label: 'export nifi details'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'read-filesystem',
label: 'read filesystem'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'access-environment-credentials',
label: 'access environment credentials'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'reference-remote-resources',
label: 'reference remote resources'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'execute-code',
label: 'execute code'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'access-ticket-cache',
label: 'access ticket cache'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'write-filesystem',
label: 'write filesystem'
},
permissions: {
canRead: false,
canWrite: true
}
},
{
requiredPermission: {
id: 'write-distributed-filesystem',
label: 'write distributed filesystem'
},
permissions: {
canRead: false,
canWrite: true
}
}
],
canVersionFlows: false
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [UserTable],
imports: [
MatTableModule,
MatSortModule,
MatInputModule,
ReactiveFormsModule,
MatSelectModule,
NoopAnimationsModule
]
});
fixture = TestBed.createComponent(UserTable);
component = fixture.componentInstance;
component.currentUser = currentUser;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,245 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AfterViewInit, Component, EventEmitter, Input, Output } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Sort } from '@angular/material/sort';
import { FormBuilder, FormGroup } from '@angular/forms';
import { debounceTime } from 'rxjs';
import { NiFiCommon } from '../../../../../service/nifi-common.service';
import { CurrentUser } from '../../../../../state/current-user';
import { AccessPolicySummaryEntity, UserEntity, UserGroupEntity } from '../../../../../state/shared';
export interface TenantItem {
id: string;
user: string;
tenantType: 'user' | 'userGroup';
membership: string[];
configurable: boolean;
}
export interface Tenants {
users: UserEntity[];
userGroups: UserGroupEntity[];
}
@Component({
selector: 'user-table',
templateUrl: './user-table.component.html',
styleUrls: ['./user-table.component.scss', '../../../../../../assets/styles/listing-table.scss']
})
export class UserTable implements AfterViewInit {
filterTerm: string = '';
filterColumn: 'user' | 'membership' = 'user';
totalCount: number = 0;
filteredCount: number = 0;
displayedColumns: string[] = ['user', 'membership', 'actions'];
dataSource: MatTableDataSource<TenantItem> = new MatTableDataSource<TenantItem>();
filterForm: FormGroup;
userLookup: Map<string, UserEntity> = new Map<string, UserEntity>();
userGroupLookup: Map<string, UserGroupEntity> = new Map<string, UserGroupEntity>();
@Input() set tenants(tenants: Tenants) {
this.userLookup.clear();
this.userGroupLookup.clear();
const tenantItems: TenantItem[] = [];
tenants.users.forEach((user) => {
this.userLookup.set(user.id, user);
tenantItems.push({
id: user.id,
tenantType: 'user',
user: user.component.identity,
membership: user.component.userGroups.map((userGroup) => userGroup.component.identity),
configurable: user.component.configurable
});
});
tenants.userGroups.forEach((userGroup) => {
this.userGroupLookup.set(userGroup.id, userGroup);
tenantItems.push({
id: userGroup.id,
tenantType: 'userGroup',
user: userGroup.component.identity,
membership: userGroup.component.users.map((user) => user.component.identity),
configurable: userGroup.component.configurable
});
});
this.dataSource.data = this.sortUsers(tenantItems, this.sort);
this.dataSource.filterPredicate = (data: TenantItem, filter: string) => {
const { filterTerm, filterColumn } = JSON.parse(filter);
if (filterColumn === 'user') {
return this.nifiCommon.stringContains(data.user, filterTerm, true);
} else {
return this.nifiCommon.stringContains(this.formatMembership(data), filterTerm, true);
}
};
this.totalCount = tenantItems.length;
this.filteredCount = tenantItems.length;
// apply any filtering to the new data
const filterTerm = this.filterForm.get('filterTerm')?.value;
if (filterTerm?.length > 0) {
const filterColumn = this.filterForm.get('filterColumn')?.value;
this.applyFilter(filterTerm, filterColumn);
}
}
@Input() selectedTenantId!: string;
@Input() currentUser!: CurrentUser;
@Input() configurableUsersAndGroups!: boolean;
@Output() createTenant: EventEmitter<void> = new EventEmitter<void>();
@Output() selectTenant: EventEmitter<string> = new EventEmitter<string>();
@Output() editTenant: EventEmitter<string> = new EventEmitter<string>();
@Output() deleteUser: EventEmitter<UserEntity> = new EventEmitter<UserEntity>();
@Output() deleteUserGroup: EventEmitter<UserGroupEntity> = new EventEmitter<UserGroupEntity>();
@Output() viewAccessPolicies: EventEmitter<string> = new EventEmitter<string>();
sort: Sort = {
active: 'user',
direction: 'asc'
};
constructor(
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon
) {
this.filterForm = this.formBuilder.group({ filterTerm: '', filterColumn: 'user' });
}
ngAfterViewInit(): void {
this.filterForm
.get('filterTerm')
?.valueChanges.pipe(debounceTime(500))
.subscribe((filterTerm: string) => {
const filterColumn = this.filterForm.get('filterColumn')?.value;
this.applyFilter(filterTerm, filterColumn);
});
this.filterForm.get('filterColumn')?.valueChanges.subscribe((filterColumn: string) => {
const filterTerm = this.filterForm.get('filterTerm')?.value;
this.applyFilter(filterTerm, filterColumn);
});
}
applyFilter(filterTerm: string, filterColumn: string) {
this.dataSource.filter = JSON.stringify({ filterTerm, filterColumn });
this.filteredCount = this.dataSource.filteredData.length;
}
updateSort(sort: Sort): void {
this.sort = sort;
this.dataSource.data = this.sortUsers(this.dataSource.data, sort);
}
formatMembership(item: TenantItem): string {
return item.membership.sort((a, b) => this.nifiCommon.compareString(a, b)).join(', ');
}
sortUsers(items: TenantItem[], sort: Sort): TenantItem[] {
const data: TenantItem[] = items.slice();
return data.sort((a, b) => {
const isAsc = sort.direction === 'asc';
let retVal: number = 0;
switch (sort.active) {
case 'user':
retVal = this.nifiCommon.compareString(a.user, b.user);
break;
case 'membership':
retVal = this.nifiCommon.compareString(this.formatMembership(a), this.formatMembership(b));
break;
}
return retVal * (isAsc ? 1 : -1);
});
}
select(item: TenantItem): void {
this.selectTenant.next(item.id);
}
isSelected(item: TenantItem): boolean {
if (this.selectedTenantId) {
return item.id == this.selectedTenantId;
}
return false;
}
canModifyTenants(currentUser: CurrentUser): boolean {
return (
currentUser.tenantsPermissions.canRead &&
currentUser.tenantsPermissions.canWrite &&
this.configurableUsersAndGroups
);
}
createClicked(): void {
this.createTenant.next();
}
canEditOrDelete(currentUser: CurrentUser, item: TenantItem): boolean {
return this.canModifyTenants(currentUser) && item.configurable;
}
editClicked(item: TenantItem, event: MouseEvent): void {
event.stopPropagation();
this.editTenant.next(item.id);
}
deleteClicked(item: TenantItem): void {
if (item.tenantType === 'user') {
const user: UserEntity | undefined = this.userLookup.get(item.id);
if (user) {
this.deleteUser.next(user);
}
} else if (item.tenantType === 'userGroup') {
const userGroup: UserGroupEntity | undefined = this.userGroupLookup.get(item.id);
if (userGroup) {
this.deleteUserGroup.next(userGroup);
}
}
}
private getAccessPolicies(item: TenantItem): AccessPolicySummaryEntity[] {
const accessPolicies: AccessPolicySummaryEntity[] = [];
if (item.tenantType === 'user') {
const user: UserEntity | undefined = this.userLookup.get(item.id);
if (user) {
accessPolicies.push(...user.component.accessPolicies);
}
} else if (item.tenantType === 'userGroup') {
const userGroup: UserGroupEntity | undefined = this.userGroupLookup.get(item.id);
if (userGroup) {
accessPolicies.push(...userGroup.component.accessPolicies);
}
}
return accessPolicies;
}
hasAccessPolicies(item: TenantItem): boolean {
return !this.nifiCommon.isEmpty(this.getAccessPolicies(item));
}
viewAccessPoliciesClicked(item: TenantItem, event: MouseEvent): void {
event.stopPropagation();
this.viewAccessPolicies.next(item.id);
}
}

View File

@ -20,12 +20,12 @@ import { Observable, throwError } from 'rxjs';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class UserService {
export class CurrentUserService {
private static readonly API: string = '../nifi-api';
constructor(private httpClient: HttpClient) {}
getUser(): Observable<any> {
return this.httpClient.get(`${UserService.API}/flow/current-user`);
return this.httpClient.get(`${CurrentUserService.API}/flow/current-user`);
}
}

View File

@ -20,17 +20,17 @@ import { inject } from '@angular/core';
import { AuthService } from '../auth.service';
import { AuthStorage } from '../auth-storage.service';
import { take } from 'rxjs';
import { UserService } from '../user.service';
import { CurrentUserService } from '../current-user.service';
import { Store } from '@ngrx/store';
import { UserState } from '../../state/user';
import { loadUserSuccess } from '../../state/user/user.actions';
import { selectUserState } from '../../state/user/user.selectors';
import { CurrentUserState } from '../../state/current-user';
import { loadCurrentUserSuccess } from '../../state/current-user/current-user.actions';
import { selectCurrentUserState } from '../../state/current-user/current-user.selectors';
export const authenticationGuard: CanMatchFn = (route, state) => {
const authStorage: AuthStorage = inject(AuthStorage);
const authService: AuthService = inject(AuthService);
const userService: UserService = inject(UserService);
const store: Store<UserState> = inject(Store<UserState>);
const userService: CurrentUserService = inject(CurrentUserService);
const store: Store<CurrentUserState> = inject(Store<CurrentUserState>);
const handleAuthentication: Promise<boolean> = new Promise((resolve) => {
if (authStorage.hasToken()) {
@ -77,7 +77,7 @@ export const authenticationGuard: CanMatchFn = (route, state) => {
return new Promise<boolean>((resolve) => {
handleAuthentication.finally(() => {
store
.select(selectUserState)
.select(selectCurrentUserState)
.pipe(take(1))
.subscribe((userState) => {
if (userState.status == 'pending') {
@ -88,7 +88,7 @@ export const authenticationGuard: CanMatchFn = (route, state) => {
next: (response) => {
// store the loaded user
store.dispatch(
loadUserSuccess({
loadCurrentUserSuccess({
response: {
user: response
}

View File

@ -19,15 +19,15 @@ import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router';
import { inject } from '@angular/core';
import { map } from 'rxjs';
import { Store } from '@ngrx/store';
import { User, UserState } from '../../state/user';
import { selectUser } from '../../state/user/user.selectors';
import { CurrentUser, CurrentUserState } from '../../state/current-user';
import { selectCurrentUser } from '../../state/current-user/current-user.selectors';
export const authorizationGuard = (authorizationCheck: (user: User) => boolean): CanMatchFn => {
export const authorizationGuard = (authorizationCheck: (user: CurrentUser) => boolean): CanMatchFn => {
return (route: Route, state: UrlSegment[]) => {
const router: Router = inject(Router);
const store: Store<UserState> = inject(Store<UserState>);
const store: Store<CurrentUserState> = inject(Store<CurrentUserState>);
return store.select(selectUser).pipe(
return store.select(selectCurrentUser).pipe(
map((currentUser) => {
if (authorizationCheck(currentUser)) {
return true;

View File

@ -19,7 +19,7 @@ import { TestBed } from '@angular/core/testing';
import { PollingInterceptor } from './polling.interceptor';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/user/user.reducer';
import { initialState } from '../../state/current-user/current-user.reducer';
describe('PollingInterceptor', () => {
let service: PollingInterceptor;

View File

@ -20,7 +20,7 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest
import { Observable, tap } from 'rxjs';
import { NiFiState } from '../../state';
import { Store } from '@ngrx/store';
import { stopUserPolling } from '../../state/user/user.actions';
import { stopCurrentUserPolling } from '../../state/current-user/current-user.actions';
import { stopProcessGroupPolling } from '../../pages/flow-designer/state/flow/flow.actions';
@Injectable({
@ -34,7 +34,7 @@ export class PollingInterceptor implements HttpInterceptor {
tap({
error: (error) => {
if (error instanceof HttpErrorResponse && error.status === 0) {
this.store.dispatch(stopUserPolling());
this.store.dispatch(stopCurrentUserPolling());
this.store.dispatch(stopProcessGroupPolling());
}
}

View File

@ -16,6 +16,7 @@
*/
import { Injectable } from '@angular/core';
import { SelectOption } from '../state/shared';
@Injectable({
providedIn: 'root'
@ -37,6 +38,65 @@ export class NiFiCommon {
public static readonly BYTES_IN_GIGABYTE: number = 1073741824;
public static readonly BYTES_IN_TERABYTE: number = 1099511627776;
private policyTypeListing: SelectOption[] = [
{
text: 'view the user interface',
value: 'flow',
description: 'Allows users to view the user interface'
},
{
text: 'access the controller',
value: 'controller',
description:
'Allows users to view/modify the controller including Management Controller Services, Reporting Tasks, Registry Clients, Parameter Providers and nodes in the cluster'
},
{
text: 'access parameter contexts',
value: 'parameter-contexts',
description: 'Allows users to view/modify Parameter Contexts'
},
{
text: 'query provenance',
value: 'provenance',
description: 'Allows users to submit a Provenance Search and request Event Lineage'
},
{
text: 'access restricted components',
value: 'restricted-components',
description: 'Allows users to create/modify restricted components assuming other permissions are sufficient'
},
{
text: 'access all policies',
value: 'policies',
description: 'Allows users to view/modify the policies for all components'
},
{
text: 'access users/user groups',
value: 'tenants',
description: 'Allows users to view/modify the users and user groups'
},
{
text: 'retrieve site-to-site details',
value: 'site-to-site',
description: 'Allows other NiFi instances to retrieve Site-To-Site details of this NiFi'
},
{
text: 'view system diagnostics',
value: 'system',
description: 'Allows users to view System Diagnostics'
},
{
text: 'proxy user requests',
value: 'proxy',
description: 'Allows proxy machines to send requests on the behalf of others'
},
{
text: 'access counters',
value: 'counters',
description: 'Allows users to view/modify Counters'
}
];
constructor() {}
/**
@ -435,4 +495,13 @@ export class NiFiCommon {
const locale: string = (navigator && navigator.language) || 'en';
return f.toLocaleString(locale, { maximumFractionDigits: 2, minimumFractionDigits: 2 });
}
/**
* Gets the policy type for the specified resource.
*
* @param value
*/
public getPolicyTypeListing(value: string): SelectOption | undefined {
return this.policyTypeListing.find((policy: SelectOption) => value === policy.value);
}
}

View File

@ -16,17 +16,19 @@
*/
import { createAction, props } from '@ngrx/store';
import { LoadUserResponse, UserState } from './index';
import { LoadProcessGroupRequest, LoadProcessGroupResponse } from '../../pages/flow-designer/state/flow';
import { LoadCurrentUserResponse } from './index';
export const loadUser = createAction('[User] Load User');
export const loadCurrentUser = createAction('[Current User] Load Current User');
export const loadUserSuccess = createAction('[User] Load User Success', props<{ response: LoadUserResponse }>());
export const loadCurrentUserSuccess = createAction(
'[Current User] Load Current User Success',
props<{ response: LoadCurrentUserResponse }>()
);
export const userApiError = createAction('[User] User Api Error', props<{ error: string }>());
export const currentUserApiError = createAction('[Current User] Current User Api Error', props<{ error: string }>());
export const clearUserApiError = createAction('[User] Clear User Api Error');
export const clearCurrentUserApiError = createAction('[Current User] Clear Current User Api Error');
export const startUserPolling = createAction('[User] Start User Polling');
export const startCurrentUserPolling = createAction('[Current User] Start Current User Polling');
export const stopUserPolling = createAction('[User] Stop User Polling');
export const stopCurrentUserPolling = createAction('[Current User] Stop Current User Polling');

View File

@ -17,44 +17,46 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as UserActions from './user.actions';
import * as UserActions from './current-user.actions';
import { asyncScheduler, catchError, from, interval, map, of, switchMap, takeUntil } from 'rxjs';
import { UserService } from '../../service/user.service';
import { CurrentUserService } from '../../service/current-user.service';
@Injectable()
export class UserEffects {
export class CurrentUserEffects {
constructor(
private actions$: Actions,
private userService: UserService
private userService: CurrentUserService
) {}
loadUser$ = createEffect(() =>
loadCurrentUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.loadUser),
ofType(UserActions.loadCurrentUser),
switchMap(() => {
return from(
this.userService.getUser().pipe(
map((response) =>
UserActions.loadUserSuccess({
UserActions.loadCurrentUserSuccess({
response: {
user: response
}
})
),
catchError((error) => of(UserActions.userApiError({ error: error.error })))
catchError((error) => of(UserActions.currentUserApiError({ error: error.error })))
)
);
})
)
);
startUserPolling$ = createEffect(() =>
startCurrentUserPolling$ = createEffect(() =>
this.actions$.pipe(
ofType(UserActions.startUserPolling),
ofType(UserActions.startCurrentUserPolling),
switchMap(() =>
interval(30000, asyncScheduler).pipe(takeUntil(this.actions$.pipe(ofType(UserActions.stopUserPolling))))
interval(30000, asyncScheduler).pipe(
takeUntil(this.actions$.pipe(ofType(UserActions.stopCurrentUserPolling)))
)
),
switchMap(() => of(UserActions.loadUser()))
switchMap(() => of(UserActions.loadCurrentUser()))
)
);
}

View File

@ -16,16 +16,21 @@
*/
import { createReducer, on } from '@ngrx/store';
import { UserState } from './index';
import { CurrentUserState } from './index';
import { Permissions } from '../shared';
import { clearUserApiError, loadUser, loadUserSuccess, userApiError } from './user.actions';
import {
clearCurrentUserApiError,
loadCurrentUser,
loadCurrentUserSuccess,
currentUserApiError
} from './current-user.actions';
export const NO_PERMISSIONS: Permissions = {
canRead: false,
canWrite: false
};
export const initialState: UserState = {
export const initialState: CurrentUserState = {
user: {
identity: '',
anonymous: true,
@ -44,24 +49,24 @@ export const initialState: UserState = {
status: 'pending'
};
export const userReducer = createReducer(
export const currentUserReducer = createReducer(
initialState,
on(loadUser, (state) => ({
on(loadCurrentUser, (state) => ({
...state,
status: 'loading' as const
})),
on(loadUserSuccess, (state, { response }) => ({
on(loadCurrentUserSuccess, (state, { response }) => ({
...state,
user: response.user,
error: null,
status: 'success' as const
})),
on(userApiError, (state, { error }) => ({
on(currentUserApiError, (state, { error }) => ({
...state,
error: error,
status: 'error' as const
})),
on(clearUserApiError, (state) => ({
on(clearCurrentUserApiError, (state) => ({
...state,
error: null,
status: 'pending' as const

View File

@ -16,8 +16,8 @@
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { userFeatureKey, UserState } from './index';
import { currentUserFeatureKey, CurrentUserState } from './index';
export const selectUserState = createFeatureSelector<UserState>(userFeatureKey);
export const selectCurrentUserState = createFeatureSelector<CurrentUserState>(currentUserFeatureKey);
export const selectUser = createSelector(selectUserState, (state: UserState) => state.user);
export const selectCurrentUser = createSelector(selectCurrentUserState, (state: CurrentUserState) => state.user);

View File

@ -17,10 +17,10 @@
import { Permissions, RequiredPermission } from '../shared';
export const userFeatureKey = 'user';
export const currentUserFeatureKey = 'currentUser';
export interface LoadUserResponse {
user: User;
export interface LoadCurrentUserResponse {
user: CurrentUser;
}
export interface ComponentRestrictionPermission {
@ -28,7 +28,7 @@ export interface ComponentRestrictionPermission {
permissions: Permissions;
}
export interface User {
export interface CurrentUser {
identity: string;
anonymous: boolean;
canVersionFlows: boolean;
@ -43,8 +43,8 @@ export interface User {
componentRestrictionPermissions: ComponentRestrictionPermission[];
}
export interface UserState {
user: User;
export interface CurrentUserState {
user: CurrentUser;
error: string | null;
status: 'pending' | 'loading' | 'error' | 'success';
}

View File

@ -17,8 +17,8 @@
import { routerReducer, RouterReducerState } from '@ngrx/router-store';
import { ActionReducerMap } from '@ngrx/store';
import { UserState, userFeatureKey } from './user';
import { userReducer } from './user/user.reducer';
import { CurrentUserState, currentUserFeatureKey } from './current-user';
import { currentUserReducer } from './current-user/current-user.reducer';
import { extensionTypesFeatureKey, ExtensionTypesState } from './extension-types';
import { extensionTypesReducer } from './extension-types/extension-types.reducer';
import { aboutFeatureKey, AboutState } from './about';
@ -32,7 +32,7 @@ import { systemDiagnosticsReducer } from './system-diagnostics/system-diagnostic
export interface NiFiState {
router: RouterReducerState;
[userFeatureKey]: UserState;
[currentUserFeatureKey]: CurrentUserState;
[extensionTypesFeatureKey]: ExtensionTypesState;
[aboutFeatureKey]: AboutState;
[statusHistoryFeatureKey]: StatusHistoryState;
@ -42,7 +42,7 @@ export interface NiFiState {
export const rootReducers: ActionReducerMap<NiFiState> = {
router: routerReducer,
[userFeatureKey]: userReducer,
[currentUserFeatureKey]: currentUserReducer,
[extensionTypesFeatureKey]: extensionTypesReducer,
[aboutFeatureKey]: aboutReducer,
[statusHistoryFeatureKey]: statusHistoryReducer,

View File

@ -49,6 +49,58 @@ export interface EditParameterResponse {
parameter: Parameter;
}
export interface UserEntity {
id: string;
permissions: Permissions;
component: User;
revision: Revision;
uri: string;
}
export interface User extends Tenant {
userGroups: TenantEntity[];
accessPolicies: AccessPolicySummaryEntity[];
}
export interface UserGroupEntity {
id: string;
permissions: Permissions;
component: UserGroup;
revision: Revision;
uri: string;
}
export interface UserGroup extends Tenant {
users: TenantEntity[];
accessPolicies: AccessPolicySummaryEntity[];
}
export interface TenantEntity {
id: string;
revision: Revision;
permissions: Permissions;
component: Tenant;
}
export interface Tenant {
id: string;
identity: string;
configurable: boolean;
}
export interface EditTenantRequest {
user?: UserEntity;
userGroup?: UserGroupEntity;
existingUsers: UserEntity[];
existingUserGroups: UserGroupEntity[];
}
export interface EditTenantResponse {
revision: Revision;
user?: any;
userGroup?: any;
}
export interface CreateControllerServiceRequest {
controllerServiceTypes: DocumentedType[];
}
@ -352,6 +404,35 @@ export interface ControllerServiceEntity {
component: any;
}
export interface AccessPolicySummaryEntity {
id: string;
component: AccessPolicySummary;
revision: Revision;
permissions: Permissions;
}
export interface AccessPolicySummary {
id: string;
resource: string;
action: string;
componentReference?: ComponentReferenceEntity;
configurable: boolean;
}
export interface ComponentReferenceEntity {
id: string;
parentGroupId?: string;
component: ComponentReference;
revision: Revision;
permissions: Permissions;
}
export interface ComponentReference {
id: string;
parentGroupId?: string;
name: string;
}
export interface DocumentedType {
bundle: Bundle;
description?: string;

View File

@ -0,0 +1,63 @@
<!--
~ Licensed to the Apache Software Foundation (ASF) under one or more
~ contributor license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright ownership.
~ The ASF licenses this file to You under the Apache License, Version 2.0
~ (the "License"); you may not use this file except in compliance with
~ the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<h2 mat-dialog-title>{{ isNew ? 'Add' : 'Edit' }} {{ isUser ? 'User' : 'User Group' }}</h2>
<form class="edit-tenant-form" [formGroup]="editTenantForm">
<mat-dialog-content>
<div class="mb-6">
<mat-radio-group formControlName="tenantType" (change)="tenantTypeChanged()">
<mat-radio-button [value]="USER">Individual</mat-radio-button>
<mat-radio-button [value]="USER_GROUP">Group</mat-radio-button>
</mat-radio-group>
</div>
<div class="mb-2">
<mat-form-field>
<mat-label>Identity</mat-label>
<input matInput formControlName="identity" type="text" />
<mat-error *ngIf="identity.invalid">{{ getIdentityErrorMessage() }}</mat-error>
</mat-form-field>
</div>
<div *ngIf="isUser; else isUserGroup">
<mat-label>Member of</mat-label>
<mat-selection-list formControlName="userGroups">
<mat-list-option *ngFor="let userGroup of userGroups" togglePosition="before" [value]="userGroup.id"
>{{ userGroup.component.identity }}
</mat-list-option>
</mat-selection-list>
</div>
<ng-template #isUserGroup>
<div>
<mat-label>Members</mat-label>
<mat-selection-list formControlName="users">
<mat-list-option *ngFor="let user of users" togglePosition="before" [value]="user.id"
>{{ user.component.identity }}
</mat-list-option>
</mat-selection-list>
</div>
</ng-template>
</mat-dialog-content>
<mat-dialog-actions align="end" *ngIf="{ value: (saving$ | async)! } as saving">
<button mat-raised-button mat-dialog-close color="accent">Cancel</button>
<button
mat-raised-button
[disabled]="editTenantForm.invalid || saving.value"
(click)="okClicked()"
color="primary">
<span *nifiSpinner="saving.value">Ok</span>
</button>
</mat-dialog-actions>
</form>

View File

@ -0,0 +1,39 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@use '@angular/material' as mat;
.edit-tenant-form {
@include mat.button-density(-1);
.mat-mdc-radio-button ~ .mat-mdc-radio-button {
margin-left: 16px;
}
.mat-mdc-form-field {
width: 100%;
}
.mat-mdc-form-field-error {
font-size: 12px;
}
mat-selection-list {
max-height: 300px;
overflow: auto;
}
}

View File

@ -0,0 +1,798 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditTenantDialog } from './edit-tenant-dialog.component';
import { EditTenantRequest } from '../../../state/shared';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('EditTenantDialog', () => {
let component: EditTenantDialog;
let fixture: ComponentFixture<EditTenantDialog>;
const data: EditTenantRequest = {
user: {
revision: {
version: 0
},
id: 'acfc1479-018c-1000-1025-6cc5b4adefb8',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc1479-018c-1000-1025-6cc5b4adefb8',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc1479-018c-1000-1025-6cc5b4adefb8',
identity: 'Group 2',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
existingUsers: [
{
revision: {
version: 0
},
id: 'ad0ddd93-018c-1000-4e40-8e3b207abdcd',
uri: 'https://localhost:4200/nifi-api/tenants/users/ad0ddd93-018c-1000-4e40-8e3b207abdcd',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0ddd93-018c-1000-4e40-8e3b207abdcd',
identity: 'user 1',
configurable: true,
userGroups: [
{
revision: {
version: 0
},
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
identity: 'group 1',
configurable: true
}
}
],
accessPolicies: [
{
revision: {
clientId: 'b09bd713-018c-1000-e5b8-14855e466f1b',
version: 4
},
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
resource: '/system',
action: 'read',
configurable: true
}
}
]
}
},
{
revision: {
version: 0
},
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
uri: 'https://localhost:4200/nifi-api/tenants/users/ad0875a3-018c-1000-1877-3f4ebf93f91e',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
identity: 'user 2',
configurable: true,
userGroups: [
{
revision: {
version: 0
},
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
identity: 'group 1',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'acfcdcf6-018c-1000-604f-84da632fdbd5',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfcdcf6-018c-1000-604f-84da632fdbd5',
identity: 'group 9',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
identity: 'Group',
configurable: true
}
}
],
accessPolicies: [
{
revision: {
clientId: 'b09bd713-018c-1000-e5b8-14855e466f1b',
version: 4
},
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
resource: '/system',
action: 'read',
configurable: true
}
}
]
}
},
{
revision: {
version: 0
},
id: 'acfc1479-018c-1000-1025-6cc5b4adefb8',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc1479-018c-1000-1025-6cc5b4adefb8',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc1479-018c-1000-1025-6cc5b4adefb8',
identity: 'Group 2',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfc879d-018c-1000-c93e-21350df0f5bf',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc879d-018c-1000-c93e-21350df0f5bf',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc879d-018c-1000-c93e-21350df0f5bf',
identity: 'group 7',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: '21232f29-7a57-35a7-8389-4a0e4a801fc3',
uri: 'https://localhost:4200/nifi-api/tenants/users/21232f29-7a57-35a7-8389-4a0e4a801fc3',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '21232f29-7a57-35a7-8389-4a0e4a801fc3',
identity: 'admin',
configurable: true,
userGroups: [
{
revision: {
version: 0
},
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
identity: 'Group',
configurable: true
}
}
],
accessPolicies: [
{
revision: {
clientId: 'b09bd713-018c-1000-e5b8-14855e466f1b',
version: 4
},
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
resource: '/system',
action: 'read',
configurable: true
}
},
{
revision: {
version: 0
},
id: '15e4e0bd-cb28-34fd-8587-f8d15162cba5',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '15e4e0bd-cb28-34fd-8587-f8d15162cba5',
resource: '/tenants',
action: 'write',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'c6322e6c-4cc1-3bcc-91b3-2ed2111674cf',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'c6322e6c-4cc1-3bcc-91b3-2ed2111674cf',
resource: '/controller',
action: 'write',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'f99bccd1-a30e-3e4a-98a2-dbc708edc67f',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'f99bccd1-a30e-3e4a-98a2-dbc708edc67f',
resource: '/flow',
action: 'read',
configurable: true
}
},
{
revision: {
version: 0
},
id: '627410be-1717-35b4-a06f-e9362b89e0b7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '627410be-1717-35b4-a06f-e9362b89e0b7',
resource: '/tenants',
action: 'read',
configurable: true
}
},
{
revision: {
version: 0
},
id: '2e1015cb-0fed-3005-8e0d-722311f21a03',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '2e1015cb-0fed-3005-8e0d-722311f21a03',
resource: '/controller',
action: 'read',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'b8775bd4-704a-34c6-987b-84f2daf7a515',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b8775bd4-704a-34c6-987b-84f2daf7a515',
resource: '/restricted-components',
action: 'write',
configurable: true
}
},
{
revision: {
version: 0
},
id: '92db2c23-018c-1000-8885-b650a16dcb32',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '92db2c23-018c-1000-8885-b650a16dcb32',
resource: '/process-groups/92dade11-018c-1000-91a9-ad537020d5cb',
action: 'read',
componentReference: {
revision: {
version: 0
},
id: '92dade11-018c-1000-91a9-ad537020d5cb',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '92dade11-018c-1000-91a9-ad537020d5cb',
name: 'NiFi Flow'
}
},
configurable: true
}
},
{
revision: {
version: 0
},
id: '92db4367-018c-1000-e220-2dbec57c389b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '92db4367-018c-1000-e220-2dbec57c389b',
resource: '/process-groups/92dade11-018c-1000-91a9-ad537020d5cb',
action: 'write',
componentReference: {
revision: {
version: 0
},
id: '92dade11-018c-1000-91a9-ad537020d5cb',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '92dade11-018c-1000-91a9-ad537020d5cb',
name: 'NiFi Flow'
}
},
configurable: true
}
},
{
revision: {
version: 0
},
id: 'ad99ea98-3af6-3561-ae27-5bf09e1d969d',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad99ea98-3af6-3561-ae27-5bf09e1d969d',
resource: '/policies',
action: 'write',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'ff96062a-fa99-36dc-9942-0f6442ae7212',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ff96062a-fa99-36dc-9942-0f6442ae7212',
resource: '/policies',
action: 'read',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'b1d4bb80-018c-1000-6cb7-a1e977f8382b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b1d4bb80-018c-1000-6cb7-a1e977f8382b',
resource: '/processors/ad55f343-018c-1000-1291-1992288346e5',
action: 'read',
componentReference: {
revision: {
version: 0
},
id: 'ad55f343-018c-1000-1291-1992288346e5',
permissions: {
canRead: true,
canWrite: true
},
parentGroupId: '92dade11-018c-1000-91a9-ad537020d5cb',
component: {
id: 'ad55f343-018c-1000-1291-1992288346e5',
parentGroupId: '92dade11-018c-1000-91a9-ad537020d5cb',
name: 'InvokeHTTP'
}
},
configurable: true
}
}
]
}
},
{
revision: {
version: 0
},
id: 'abf8bb43-018c-1000-3e0c-a4405f39c934',
uri: 'https://localhost:4200/nifi-api/tenants/users/abf8bb43-018c-1000-3e0c-a4405f39c934',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'abf8bb43-018c-1000-3e0c-a4405f39c934',
identity: 'test',
configurable: true,
userGroups: [
{
revision: {
version: 0
},
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
identity: 'Group',
configurable: true
}
}
],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfc259e-018c-1000-2e1e-01919d4b0546',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc259e-018c-1000-2e1e-01919d4b0546',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc259e-018c-1000-2e1e-01919d4b0546',
identity: 'group 3',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfca1a8-018c-1000-6852-0dd013e20ee7',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfca1a8-018c-1000-6852-0dd013e20ee7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfca1a8-018c-1000-6852-0dd013e20ee7',
identity: 'group 8',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfc5a70-018c-1000-a702-b52236aaacea',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc5a70-018c-1000-a702-b52236aaacea',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc5a70-018c-1000-a702-b52236aaacea',
identity: 'group 5',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfc6ee7-018c-1000-feed-fb1b89482663',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc6ee7-018c-1000-feed-fb1b89482663',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc6ee7-018c-1000-feed-fb1b89482663',
identity: 'group 6',
configurable: true,
userGroups: [],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'acfc418b-018c-1000-bd42-50d38a5f8ec4',
uri: 'https://localhost:4200/nifi-api/tenants/users/acfc418b-018c-1000-bd42-50d38a5f8ec4',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfc418b-018c-1000-bd42-50d38a5f8ec4',
identity: 'group 4',
configurable: true,
userGroups: [],
accessPolicies: []
}
}
],
existingUserGroups: [
{
revision: {
version: 0
},
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
uri: 'https://localhost:4200/nifi-api/tenants/user-groups/acfbfa2c-018c-1000-0311-47b83e34c9c3',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfbfa2c-018c-1000-0311-47b83e34c9c3',
identity: 'group 1',
configurable: true,
users: [
{
revision: {
version: 0
},
id: 'ad0ddd93-018c-1000-4e40-8e3b207abdcd',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0ddd93-018c-1000-4e40-8e3b207abdcd',
identity: 'user 1',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
identity: 'user 2',
configurable: true
}
}
],
accessPolicies: [
{
revision: {
clientId: 'b09bd713-018c-1000-e5b8-14855e466f1b',
version: 4
},
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'b0c3148d-018c-1000-2cfe-8fab902c11f7',
resource: '/system',
action: 'read',
configurable: true
}
}
]
}
},
{
revision: {
version: 0
},
id: 'acfcdcf6-018c-1000-604f-84da632fdbd5',
uri: 'https://localhost:4200/nifi-api/tenants/user-groups/acfcdcf6-018c-1000-604f-84da632fdbd5',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'acfcdcf6-018c-1000-604f-84da632fdbd5',
identity: 'group 9',
configurable: true,
users: [
{
revision: {
version: 0
},
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
identity: 'user 2',
configurable: true
}
}
],
accessPolicies: []
}
},
{
revision: {
version: 0
},
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
uri: 'https://localhost:4200/nifi-api/tenants/user-groups/a69482a2-018c-1000-2c02-fac31f4b102b',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'a69482a2-018c-1000-2c02-fac31f4b102b',
identity: 'Group',
configurable: true,
users: [
{
revision: {
version: 0
},
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'ad0875a3-018c-1000-1877-3f4ebf93f91e',
identity: 'user 2',
configurable: true
}
},
{
revision: {
version: 0
},
id: '21232f29-7a57-35a7-8389-4a0e4a801fc3',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: '21232f29-7a57-35a7-8389-4a0e4a801fc3',
identity: 'admin',
configurable: true
}
},
{
revision: {
version: 0
},
id: 'abf8bb43-018c-1000-3e0c-a4405f39c934',
permissions: {
canRead: true,
canWrite: true
},
component: {
id: 'abf8bb43-018c-1000-3e0c-a4405f39c934',
identity: 'test',
configurable: true
}
}
],
accessPolicies: []
}
}
]
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [EditTenantDialog, BrowserAnimationsModule],
providers: [{ provide: MAT_DIALOG_DATA, useValue: data }]
});
fixture = TestBed.createComponent(EditTenantDialog);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,256 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Output } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { EditTenantRequest, EditTenantResponse, Revision, UserEntity, UserGroupEntity } from '../../../state/shared';
import { MatButtonModule } from '@angular/material/button';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { NifiSpinnerDirective } from '../spinner/nifi-spinner.directive';
import { AsyncPipe, NgForOf, NgIf } from '@angular/common';
import { Observable } from 'rxjs';
import { MatListModule } from '@angular/material/list';
import { Client } from '../../../service/client.service';
import { NiFiCommon } from '../../../service/nifi-common.service';
@Component({
selector: 'edit-tenant-dialog',
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatRadioModule,
MatCheckboxModule,
NifiSpinnerDirective,
NgIf,
AsyncPipe,
MatListModule,
NgForOf
],
templateUrl: './edit-tenant-dialog.component.html',
styleUrls: ['./edit-tenant-dialog.component.scss']
})
export class EditTenantDialog {
@Input() saving$!: Observable<boolean>;
@Output() editTenant: EventEmitter<EditTenantResponse> = new EventEmitter<EditTenantResponse>();
@Output() cancel: EventEmitter<void> = new EventEmitter<void>();
readonly USER: string = 'user';
readonly USER_GROUP: string = 'userGroup';
isUser: boolean = true;
identity: FormControl;
tenantType: FormControl;
editTenantForm: FormGroup;
isNew: boolean;
users: UserEntity[];
userGroups: UserGroupEntity[];
constructor(
@Inject(MAT_DIALOG_DATA) private request: EditTenantRequest,
private formBuilder: FormBuilder,
private nifiCommon: NiFiCommon,
private client: Client
) {
const user: UserEntity | undefined = request.user;
const userGroup: UserGroupEntity | undefined = request.userGroup;
if (user || userGroup) {
this.isNew = false;
let identity: string = '';
let tenantType: string = this.USER;
if (user) {
identity = user.component.identity;
} else if (userGroup) {
identity = userGroup.component.identity;
tenantType = this.USER_GROUP;
}
this.identity = new FormControl(identity, [
Validators.required,
this.existingTenantValidator(request.existingUsers, request.existingUserGroups, identity)
]);
this.tenantType = new FormControl({ value: tenantType, disabled: true });
} else {
this.isNew = true;
this.identity = new FormControl('', [
Validators.required,
this.existingTenantValidator(request.existingUsers, request.existingUserGroups)
]);
this.tenantType = new FormControl(this.USER);
}
this.users = request.existingUsers.slice().sort((a: UserEntity, b: UserEntity) => {
return this.nifiCommon.compareString(a.component.identity, b.component.identity);
});
this.userGroups = request.existingUserGroups.slice().sort((a: UserGroupEntity, b: UserGroupEntity) => {
return this.nifiCommon.compareString(a.component.identity, b.component.identity);
});
this.editTenantForm = this.formBuilder.group({
identity: this.identity,
tenantType: this.tenantType
});
this.tenantTypeChanged();
}
private existingTenantValidator(
existingUsers: UserEntity[],
existingUserGroups: UserGroupEntity[],
currentIdentity?: string
): ValidatorFn {
const existingUserNames: string[] = existingUsers.map((user) => user.component.identity);
const existingUserGroupNames: string[] = existingUserGroups.map((userGroup) => userGroup.component.identity);
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (value === '') {
return null;
}
const existingTenants: string[] = this.isUser ? existingUserNames : existingUserGroupNames;
if (existingTenants.includes(value) && value != currentIdentity) {
return {
existingTenant: true
};
}
return null;
};
}
getIdentityErrorMessage(): string {
if (this.identity.hasError('required')) {
return 'Identity is required.';
}
const tenantType: string = this.isUser ? 'user' : 'user group';
return this.identity.hasError('existingTenant') ? `A ${tenantType} with this identity already exists.` : '';
}
tenantTypeChanged(): void {
this.isUser = this.editTenantForm.get('tenantType')?.value == this.USER;
if (this.isUser) {
this.setupFormWithExistingUserGroups();
} else {
this.setupFormWithExistingUsers();
}
}
setupFormWithExistingUsers(): void {
if (this.editTenantForm.contains('userGroups')) {
this.editTenantForm.removeControl('userGroups');
}
let users: string[] = [];
if (this.request.userGroup) {
users.push(...this.request.userGroup.component.users.map((user) => user.id));
}
this.editTenantForm.addControl('users', new FormControl(users));
}
setupFormWithExistingUserGroups(): void {
if (this.editTenantForm.contains('users')) {
this.editTenantForm.removeControl('users');
}
let userGroups: string[] = [];
if (this.request.user) {
userGroups.push(...this.request.user.component.userGroups.map((userGroup) => userGroup.id));
}
this.editTenantForm.addControl('userGroups', new FormControl(userGroups));
}
cancelClicked(): void {
this.cancel.next();
}
okClicked(): void {
if (this.isUser) {
const revision: Revision = this.isNew
? {
clientId: this.client.getClientId(),
version: 0
}
: this.client.getRevision(this.request.user);
const userGroupsAdded: string[] = [];
const userGroupsRemoved: string[] = [];
const userGroupsSelected: string[] = this.editTenantForm.get('userGroups')?.value;
if (this.request.user) {
const userGroups: string[] = this.request.user.component.userGroups.map((userGroup) => userGroup.id);
userGroupsAdded.push(...userGroupsSelected.filter((x) => !userGroups.includes(x)));
userGroupsRemoved.push(...userGroups.filter((x) => !userGroupsSelected.includes(x)));
} else {
userGroupsAdded.push(...userGroupsSelected);
}
this.editTenant.next({
revision,
user: {
payload: {
identity: this.editTenantForm.get('identity')?.value
},
userGroupsAdded,
userGroupsRemoved
}
});
} else {
const revision: Revision = this.isNew
? {
clientId: this.client.getClientId(),
version: 0
}
: this.client.getRevision(this.request.userGroup);
const users: string[] = this.editTenantForm.get('users')?.value;
this.editTenant.next({
revision,
userGroup: {
payload: {
identity: this.editTenantForm.get('identity')?.value
},
users
}
});
}
}
}