This is an automated email from the ASF dual-hosted git repository.
rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 69de5198a6 NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies
(add, edit,… (#10399)
69de5198a6 is described below
commit 69de5198a6643d174fccf7c0ae122594196a4763
Author: Scott Aslan <[email protected]>
AuthorDate: Thu Nov 6 08:17:03 2025 -0500
NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies (add, edit,…
(#10399)
* NIFI-14607, NIFI-14608, NIFI-15052 Manage bucket policies (add, edit,
delete)
* address review feedback
* address review feedback
* add type column to new policy dialog
* show policiy loading error in global banner and dialog does not open
* identity column width
* type column width
* fix unit test
This closes #10399
---
.../app/pages/buckets/feature/buckets.module.ts | 3 +-
.../add-policy-to-bucket-dialog.component.html | 84 +++++
.../add-policy-to-bucket-dialog.component.scss | 28 ++
.../add-policy-to-bucket-dialog.component.spec.ts | 213 +++++++++++++
.../add-policy-to-bucket-dialog.component.ts | 175 +++++++++++
.../edit-policy-dialog.component.html | 48 +++
.../edit-policy-dialog.component.scss | 18 ++
.../edit-policy-dialog.component.spec.ts | 138 ++++++++
.../edit-policy-dialog.component.ts | 90 ++++++
.../manage-bucket-policies-dialog.component.html | 102 +++++-
...manage-bucket-policies-dialog.component.spec.ts | 326 +++++++++++++++++++
.../manage-bucket-policies-dialog.component.ts | 348 ++++++++++++++++++++-
.../ui/login-form/login-form.component.spec.ts | 18 +-
.../resources/feature/resources.component.html | 7 +-
.../pages/resources/feature/resources.component.ts | 8 +
.../pages/resources/feature/resources.module.ts | 11 +-
.../ui/droplet-table/droplet-table.component.html | 15 +-
.../ui/droplet-table/droplet-table.component.ts | 14 +-
.../src/app/service/buckets.service.ts | 117 ++++++-
.../src/app/state/buckets/buckets.actions.ts | 2 -
.../src/app/state/buckets/buckets.effects.spec.ts | 40 ++-
.../src/app/state/buckets/buckets.effects.ts | 117 ++++++-
.../src/app/state/buckets/buckets.selectors.ts | 1 -
.../current-user/current-user.effects.spec.ts | 3 -
.../nifi-registry/src/app/state/error/index.ts | 1 +
.../apps/nifi-registry/src/app/state/index.ts | 6 +-
.../nifi-registry/src/app/state/policies/index.ts | 49 +++
.../src/app/state/policies/policies.actions.ts | 69 ++++
.../app/state/policies/policies.effects.spec.ts | 276 ++++++++++++++++
.../src/app/state/policies/policies.effects.ts | 146 +++++++++
.../src/app/state/policies/policies.reducer.ts | 131 ++++++++
.../src/app/state/policies/policies.selectors.ts | 73 +++++
.../flow-registry-client-definition.component.html | 4 +-
.../local-changes-table/local-changes-table.ts | 2 +-
34 files changed, 2625 insertions(+), 58 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
index d223afaeb3..9e0d697a39 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
@@ -23,6 +23,7 @@ import { EffectsModule } from '@ngrx/effects';
import { BucketsRoutingModule } from './buckets-routing.module';
import { BucketsComponent } from './buckets.component';
import { BucketsEffects } from '../../../state/buckets/buckets.effects';
+import { PoliciesEffects } from '../../../state/policies/policies.effects';
import { reducers, resourcesFeatureKey } from '../../../state';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
@@ -67,7 +68,7 @@ import { HeaderComponent } from
'../../../ui/header/header.component';
MatDialogModule,
BucketsRoutingModule,
StoreModule.forFeature(resourcesFeatureKey, reducers),
- EffectsModule.forFeature([BucketsEffects]),
+ EffectsModule.forFeature([BucketsEffects, PoliciesEffects]),
HeaderComponent
]
})
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
new file mode 100644
index 0000000000..b4b5831c83
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.html
@@ -0,0 +1,84 @@
+<!--
+~ 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>New Policy</h2>
+<mat-dialog-content class="dialog-content">
+ <div class="add-policy-to-bucket-dialog flex flex-col h-full gap-y-4">
+ <div class="listing-table select-none flex flex-1">
+ <div class="flex-1 relative">
+ <div class="absolute inset-0 overflow-y-auto
overflow-x-hidden">
+ <table
+ mat-table
+ [dataSource]="dataSource"
+ matSort
+ matSortDisableClear
+ (matSortChange)="sortData($event)"
+ [matSortActive]="sort.active"
+ [matSortDirection]="sort.direction">
+ <!-- Type Column -->
+ <ng-container matColumnDef="type">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Type</th>
+ <td mat-cell *matCellDef="let row">
+ {{ row.type }}
+ </td>
+ </ng-container>
+ <!-- Identity Column -->
+ <ng-container matColumnDef="identity">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Identity</th>
+ <td mat-cell *matCellDef="let row">
+ <div class="flex items-center gap-x-2">
+ <div
+ class="overflow-ellipsis
overflow-hidden whitespace-nowrap"
+ [title]="row.identity">
+ {{ row.identity }}
+ </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"
+ [class.even]="even"
+ [class.selected]="isSelected(row)"
+ (click)="selectRow(row)"></tr>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <mat-checkbox [checked]="allChecked"
(change)="toggleAll($event.checked)">
+ <span class="mat-body-1">All</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="readChecked">
+ <span class="mat-body-1">Read</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="writeChecked">
+ <span class="mat-body-1">Write</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="deleteChecked">
+ <span class="mat-body-1">Delete</span>
+ </mat-checkbox>
+ </div>
+ </div>
+</mat-dialog-content>
+
+<mat-dialog-actions align="end">
+ <button mat-button (click)="cancel()">Cancel</button>
+ <button mat-flat-button color="primary" (click)="apply()"
[disabled]="!canApply">Apply</button>
+</mat-dialog-actions>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
new file mode 100644
index 0000000000..4901802b89
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.scss
@@ -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.
+ */
+
+.add-policy-to-bucket-dialog {
+ .listing-table {
+ table {
+ .mat-column-type {
+ width: 20%;
+ }
+ }
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
new file mode 100644
index 0000000000..89c8ca6f78
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.spec.ts
@@ -0,0 +1,213 @@
+/*
+ * 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 { AddPolicyToBucketDialogComponent, AddPolicyToBucketDialogData } from
'./add-policy-to-bucket-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { PolicySubject } from
'apps/nifi-registry/src/app/service/buckets.service';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+
+const bucket: Bucket = {
+ allowBundleRedeploy: false,
+ allowPublicRead: false,
+ createdTimestamp: Date.now(),
+ description: 'Test Bucket',
+ identifier: 'bucket-1',
+ link: { href: '', params: { rel: '' } },
+ name: 'Test Bucket',
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 1 }
+};
+
+const user1: PolicySubject = {
+ identifier: 'user-1',
+ identity: 'alice',
+ type: 'user',
+ configurable: false
+};
+
+const user2: PolicySubject = {
+ identifier: 'user-2',
+ identity: 'bob',
+ type: 'user',
+ configurable: false
+};
+
+const group1: PolicySubject = {
+ identifier: 'group-1',
+ identity: 'admins',
+ type: 'group',
+ configurable: false
+};
+
+describe('AddPolicyToBucketDialogComponent', () => {
+ let component: AddPolicyToBucketDialogComponent;
+ let fixture: ComponentFixture<AddPolicyToBucketDialogComponent>;
+ let dialogRef: jest.Mocked<MatDialogRef<AddPolicyToBucketDialogComponent>>;
+
+ beforeEach(async () => {
+ const mockDialogRef = {
+ close: jest.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [AddPolicyToBucketDialogComponent, NoopAnimationsModule],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ bucket,
+ existingUsers: [],
+ existingGroups: [],
+ availableUsers: [user1, user2],
+ availableGroups: [group1]
+ } as AddPolicyToBucketDialogData
+ },
+ { provide: MatDialogRef, useValue: mockDialogRef }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(AddPolicyToBucketDialogComponent);
+ component = fixture.componentInstance;
+ dialogRef = TestBed.inject(MatDialogRef) as
jest.Mocked<MatDialogRef<AddPolicyToBucketDialogComponent>>;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with available users and groups', () => {
+ expect(component.dataSource.data.length).toBe(3); // 2 users + 1 group
+ expect(component.dataSource.data[0].type).toBe('group'); // Groups
sorted first
+ expect(component.dataSource.data[1].identity).toBe('alice');
+ expect(component.dataSource.data[2].identity).toBe('bob');
+ });
+
+ it('should filter out existing users and groups', () => {
+ // Re-initialize component with different data by updating the data
source
+ component['data'] = {
+ bucket,
+ existingUsers: ['alice'],
+ existingGroups: ['admins'],
+ availableUsers: [user1, user2],
+ availableGroups: [group1]
+ };
+
+ component.ngOnInit();
+
+ expect(component.dataSource.data.length).toBe(1); // Only bob
+ expect(component.dataSource.data[0].identity).toBe('bob');
+ });
+
+ it('should select a row when clicked', () => {
+ const row = component.dataSource.data[1];
+ component.selectRow(row);
+
+ expect(component.selectedRow).toBe(row);
+ expect(row.selected).toBe(true);
+ });
+
+ it('should deselect other rows when selecting a new row', () => {
+ const row1 = component.dataSource.data[0];
+ const row2 = component.dataSource.data[1];
+
+ component.selectRow(row1);
+ expect(row1.selected).toBe(true);
+
+ component.selectRow(row2);
+ expect(row1.selected).toBe(false);
+ expect(row2.selected).toBe(true);
+ expect(component.selectedRow).toBe(row2);
+ });
+
+ it('should toggle all permissions when toggleAll is called', () => {
+ component.toggleAll(true);
+ expect(component.readChecked).toBe(true);
+ expect(component.writeChecked).toBe(true);
+ expect(component.deleteChecked).toBe(true);
+
+ component.toggleAll(false);
+ expect(component.readChecked).toBe(false);
+ expect(component.writeChecked).toBe(false);
+ expect(component.deleteChecked).toBe(false);
+ });
+
+ it('should calculate allChecked getter correctly', () => {
+ component.readChecked = true;
+ component.writeChecked = true;
+ component.deleteChecked = true;
+ expect(component.allChecked).toBe(true);
+
+ component.deleteChecked = false;
+ expect(component.allChecked).toBe(false);
+ });
+
+ it('should disable apply when no row selected', () => {
+ component.readChecked = true;
+ expect(component.canApply).toBe(false);
+ });
+
+ it('should disable apply when no permissions checked', () => {
+ component.selectRow(component.dataSource.data[0]);
+ component.readChecked = false;
+ component.writeChecked = false;
+ component.deleteChecked = false;
+ expect(component.canApply).toBe(false);
+ });
+
+ it('should enable apply when row selected and permission checked', () => {
+ component.selectRow(component.dataSource.data[0]);
+ component.readChecked = true;
+ expect(component.canApply).toBe(true);
+ });
+
+ it('should close dialog with result when apply is clicked', () => {
+ const row = component.dataSource.data[1]; // alice
+ component.selectRow(row);
+ component.readChecked = true;
+ component.writeChecked = true;
+
+ component.apply();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ userOrGroup: {
+ identifier: 'user-1',
+ identity: 'alice',
+ type: 'user'
+ },
+ permissions: ['read', 'write']
+ });
+ });
+
+ it('should not apply when no row selected', () => {
+ component.readChecked = true;
+ component.apply();
+ expect(dialogRef.close).not.toHaveBeenCalled();
+ });
+
+ it('should close dialog without result when cancel is clicked', () => {
+ component.cancel();
+ expect(dialogRef.close).toHaveBeenCalledWith();
+ });
+
+ it('should sort data correctly', () => {
+ component.sortData({ active: 'identity', direction: 'desc' });
+ expect(component.dataSource.data[0].identity).toBe('bob'); //
Descending order
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
new file mode 100644
index 0000000000..1269e1d5b0
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component.ts
@@ -0,0 +1,175 @@
+/*
+ * 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, inject } from '@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from
'@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatSortModule, Sort } from '@angular/material/sort';
+import { FormsModule } from '@angular/forms';
+import { NiFiCommon } from '@nifi/shared';
+import { PolicySubject } from
'apps/nifi-registry/src/app/service/buckets.service';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { PolicySection } from 'apps/nifi-registry/src/app/state/policies';
+
+export interface AddPolicyToBucketDialogData {
+ bucket: Bucket;
+ existingUsers: string[];
+ existingGroups: string[];
+ availableUsers: PolicySubject[];
+ availableGroups: PolicySubject[];
+}
+
+export interface UserOrGroupRow {
+ identity: string;
+ identifier: string;
+ type: 'user' | 'group';
+ selected: boolean;
+}
+
+export interface AddPolicyResult {
+ userOrGroup: PolicySubject;
+ permissions: PolicySection[];
+}
+
+@Component({
+ selector: 'add-policy-to-bucket-dialog',
+ templateUrl: './add-policy-to-bucket-dialog.component.html',
+ styleUrl: './add-policy-to-bucket-dialog.component.scss',
+ standalone: true,
+ imports: [MatDialogModule, MatButtonModule, MatCheckboxModule,
MatTableModule, MatSortModule, FormsModule]
+})
+export class AddPolicyToBucketDialogComponent implements OnInit {
+ protected data = inject<AddPolicyToBucketDialogData>(MAT_DIALOG_DATA);
+ private dialogRef = inject(MatDialogRef<AddPolicyToBucketDialogComponent>);
+ private nifiCommon = inject(NiFiCommon);
+
+ dataSource: MatTableDataSource<UserOrGroupRow> = new
MatTableDataSource<UserOrGroupRow>();
+ displayedColumns: string[] = ['type', 'identity'];
+ sort: Sort = {
+ active: 'identity',
+ direction: 'asc'
+ };
+
+ selectedRow: UserOrGroupRow | null = null;
+
+ // Permissions
+ readChecked = false;
+ writeChecked = false;
+ deleteChecked = false;
+
+ get allChecked(): boolean {
+ return this.readChecked && this.writeChecked && this.deleteChecked;
+ }
+
+ get canApply(): boolean {
+ return !!this.selectedRow && (this.readChecked || this.writeChecked ||
this.deleteChecked);
+ }
+
+ ngOnInit(): void {
+ // Filter out users and groups that already have policies as users can
only create or edit a single policy for a user or group per bucket
+ const availableUsers = this.data.availableUsers
+ .filter((user) => !this.data.existingUsers.includes(user.identity))
+ .map((user) => ({
+ identity: user.identity,
+ identifier: user.identifier,
+ type: 'user' as const,
+ selected: false
+ }));
+
+ const availableGroups = this.data.availableGroups
+ .filter((group) =>
!this.data.existingGroups.includes(group.identity))
+ .map((group) => ({
+ identity: group.identity,
+ identifier: group.identifier,
+ type: 'group' as const,
+ selected: false
+ }));
+
+ const allRows = [...availableGroups, ...availableUsers];
+ this.dataSource.data = this.sortRows(allRows, this.sort);
+ }
+
+ sortData(sort: Sort) {
+ this.sort = sort;
+ this.dataSource.data = this.sortRows(this.dataSource.data, sort);
+ }
+
+ sortRows(data: UserOrGroupRow[], sort: Sort): UserOrGroupRow[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal = 0;
+ switch (sort.active) {
+ case 'identity':
+ retVal = this.nifiCommon.compareString(a.identity,
b.identity);
+ break;
+ case 'type':
+ retVal = this.nifiCommon.compareString(a.type, b.type);
+ break;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ selectRow(row: UserOrGroupRow): void {
+ // Deselect all rows
+ this.dataSource.data.forEach((r) => (r.selected = false));
+ // Select clicked row
+ row.selected = true;
+ this.selectedRow = row;
+ }
+
+ isSelected(row: UserOrGroupRow): boolean {
+ return row.selected;
+ }
+
+ toggleAll(checked: boolean): void {
+ this.readChecked = checked;
+ this.writeChecked = checked;
+ this.deleteChecked = checked;
+ }
+
+ apply(): void {
+ if (!this.selectedRow) {
+ return;
+ }
+
+ const permissions: PolicySection[] = [];
+ if (this.readChecked) permissions.push('read');
+ if (this.writeChecked) permissions.push('write');
+ if (this.deleteChecked) permissions.push('delete');
+
+ const result: AddPolicyResult = {
+ userOrGroup: {
+ identifier: this.selectedRow.identifier,
+ identity: this.selectedRow.identity,
+ type: this.selectedRow.type
+ },
+ permissions
+ };
+
+ this.dialogRef.close(result);
+ }
+
+ cancel(): void {
+ this.dialogRef.close();
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
new file mode 100644
index 0000000000..9c8ad3e767
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.html
@@ -0,0 +1,48 @@
+<!--
+~ 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>Edit Policy</h2>
+<mat-dialog-content>
+ <div class="flex flex-col gap-y-2">
+ <div class="mt-2">
+ <mat-form-field class="w-full">
+ <mat-label>For This {{ data.type === 'user' ? 'User' : 'Group'
}}</mat-label>
+ <input matInput [value]="data.identity" disabled />
+ </mat-form-field>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <mat-checkbox [checked]="allChecked"
(change)="toggleAll($event.checked)">
+ <span class="mat-body-1">All</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="readChecked">
+ <span class="mat-body-1">Read</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="writeChecked">
+ <span class="mat-body-1">Write</span>
+ </mat-checkbox>
+ <mat-checkbox [(ngModel)]="deleteChecked">
+ <span class="mat-body-1">Delete</span>
+ </mat-checkbox>
+ </div>
+ </div>
+</mat-dialog-content>
+
+<mat-dialog-actions align="end">
+ <button mat-button (click)="cancel()">Cancel</button>
+ <button mat-flat-button color="primary" (click)="apply()"
[disabled]="!canApply">Apply</button>
+</mat-dialog-actions>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
new file mode 100644
index 0000000000..3d56d22bb3
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.scss
@@ -0,0 +1,18 @@
+/*!
+ * 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.
+ */
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
new file mode 100644
index 0000000000..64f1161f28
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.spec.ts
@@ -0,0 +1,138 @@
+/*
+ * 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 { EditPolicyDialogComponent, EditPolicyDialogData } from
'./edit-policy-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+describe('EditPolicyDialogComponent', () => {
+ let component: EditPolicyDialogComponent;
+ let fixture: ComponentFixture<EditPolicyDialogComponent>;
+ let dialogRef: jest.Mocked<MatDialogRef<EditPolicyDialogComponent>>;
+
+ beforeEach(async () => {
+ const mockDialogRef = {
+ close: jest.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [EditPolicyDialogComponent, NoopAnimationsModule],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ identity: 'alice',
+ type: 'user',
+ currentPermissions: ['read', 'write']
+ } as EditPolicyDialogData
+ },
+ { provide: MatDialogRef, useValue: mockDialogRef }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(EditPolicyDialogComponent);
+ component = fixture.componentInstance;
+ dialogRef = TestBed.inject(MatDialogRef) as
jest.Mocked<MatDialogRef<EditPolicyDialogComponent>>;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with current permissions checked', () => {
+ expect(component.readChecked).toBe(true);
+ expect(component.writeChecked).toBe(true);
+ expect(component.deleteChecked).toBe(false);
+ });
+
+ it('should calculate allChecked correctly', () => {
+ component.readChecked = true;
+ component.writeChecked = true;
+ component.deleteChecked = true;
+ expect(component.allChecked).toBe(true);
+
+ component.readChecked = false;
+ expect(component.allChecked).toBe(false);
+ });
+
+ it('should toggle all permissions', () => {
+ component.toggleAll(true);
+ expect(component.readChecked).toBe(true);
+ expect(component.writeChecked).toBe(true);
+ expect(component.deleteChecked).toBe(true);
+
+ component.toggleAll(false);
+ expect(component.readChecked).toBe(false);
+ expect(component.writeChecked).toBe(false);
+ expect(component.deleteChecked).toBe(false);
+ });
+
+ it('should disable apply when no permissions checked', () => {
+ component.readChecked = false;
+ component.writeChecked = false;
+ component.deleteChecked = false;
+ expect(component.canApply).toBe(false);
+ });
+
+ it('should enable apply when at least one permission checked', () => {
+ component.readChecked = true;
+ expect(component.canApply).toBe(true);
+ });
+
+ it('should close dialog with updated permissions when apply clicked', ()
=> {
+ component.deleteChecked = true; // Add delete permission
+ component.apply();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ permissions: ['read', 'write', 'delete']
+ });
+ });
+
+ it('should return only checked permissions', () => {
+ component.readChecked = true;
+ component.writeChecked = false;
+ component.deleteChecked = true;
+
+ component.apply();
+
+ expect(dialogRef.close).toHaveBeenCalledWith({
+ permissions: ['read', 'delete']
+ });
+ });
+
+ it('should close dialog without result when cancel clicked', () => {
+ component.cancel();
+ expect(dialogRef.close).toHaveBeenCalledWith();
+ });
+
+ it('should handle group type correctly', () => {
+ component['data'] = {
+ identity: 'admins',
+ type: 'group',
+ currentPermissions: ['delete']
+ };
+
+ component.ngOnInit();
+
+ expect(component['data'].type).toBe('group');
+ expect(component.deleteChecked).toBe(true);
+ expect(component.readChecked).toBe(false);
+ expect(component.writeChecked).toBe(false);
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
new file mode 100644
index 0000000000..92c503780b
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-policy-dialog/edit-policy-dialog.component.ts
@@ -0,0 +1,90 @@
+/*
+ * 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, inject } from '@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from
'@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { FormsModule } from '@angular/forms';
+import { PolicySection } from 'apps/nifi-registry/src/app/state/policies';
+
+export interface EditPolicyDialogData {
+ identity: string;
+ type: 'user' | 'group';
+ currentPermissions: PolicySection[];
+}
+
+export interface EditPolicyResult {
+ permissions: PolicySection[];
+}
+
+@Component({
+ selector: 'edit-policy-dialog',
+ templateUrl: './edit-policy-dialog.component.html',
+ styleUrl: './edit-policy-dialog.component.scss',
+ standalone: true,
+ imports: [MatDialogModule, MatButtonModule, MatCheckboxModule,
MatFormFieldModule, MatInputModule, FormsModule]
+})
+export class EditPolicyDialogComponent implements OnInit {
+ protected data = inject<EditPolicyDialogData>(MAT_DIALOG_DATA);
+ private dialogRef = inject(MatDialogRef<EditPolicyDialogComponent>);
+
+ // Permissions
+ readChecked = false;
+ writeChecked = false;
+ deleteChecked = false;
+
+ get allChecked(): boolean {
+ return this.readChecked && this.writeChecked && this.deleteChecked;
+ }
+
+ get canApply(): boolean {
+ return this.readChecked || this.writeChecked || this.deleteChecked;
+ }
+
+ ngOnInit(): void {
+ // Initialize checkboxes based on current permissions
+ this.readChecked = this.data.currentPermissions.includes('read');
+ this.writeChecked = this.data.currentPermissions.includes('write');
+ this.deleteChecked = this.data.currentPermissions.includes('delete');
+ }
+
+ toggleAll(checked: boolean): void {
+ this.readChecked = checked;
+ this.writeChecked = checked;
+ this.deleteChecked = checked;
+ }
+
+ apply(): void {
+ const permissions: PolicySection[] = [];
+ if (this.readChecked) permissions.push('read');
+ if (this.writeChecked) permissions.push('write');
+ if (this.deleteChecked) permissions.push('delete');
+
+ const result: EditPolicyResult = {
+ permissions
+ };
+
+ this.dialogRef.close(result);
+ }
+
+ cancel(): void {
+ this.dialogRef.close();
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
index 7cf225b7ec..d7d1887d9d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html
@@ -15,16 +15,100 @@
~ limitations under the License.
-->
-<h2 mat-dialog-title>Manage Bucket Policies</h2>
-<mat-dialog-content>
- <div class="flex flex-col gap-y-4">
- <p>
- TODO: Manage policies for bucket: <strong>{{ data.bucket.name
}}</strong>
- </p>
- <!-- TODO: Manage policies for bucket -->
- </div>
+<h2 mat-dialog-title>{{ data.bucket.name }}<span
class="ml-2">Policies</span></h2>
+<mat-dialog-content class="dialog-content">
+ <context-error-banner
[context]="ErrorContextKey.MANAGE_ACCESS"></context-error-banner>
+ @if (!(isPolicyError$ | async)) {
+ <div class="policy-table flex flex-col gap-y-3 h-full">
+ <div class="flex justify-between items-center">
+ <div class="flex justify-end w-full">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ (click)="addPolicy()"
+ [disabled]="(loading$ | async) ||
(isAddPolicyDisabled$ | async)">
+ <i class="fa fa-plus"></i>
+ </button>
+ </div>
+ </div>
+ <div class="listing-table select-none flex flex-1">
+ <div class="flex-1 relative">
+ <div class="absolute inset-0 overflow-y-auto
overflow-x-hidden">
+ <table
+ mat-table
+ [dataSource]="dataSource"
+ matSort
+ matSortDisableClear
+ (matSortChange)="sortData($event)"
+ [matSortActive]="sort.active"
+ [matSortDirection]="sort.direction">
+ <!-- Type Column -->
+ <ng-container matColumnDef="type">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Type</th>
+ <td mat-cell *matCellDef="let row">
+ {{ row.type }}
+ </td>
+ </ng-container>
+ <!-- Identity Column -->
+ <ng-container matColumnDef="identity">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Identity</th>
+ <td mat-cell *matCellDef="let row">
+ <div
+ class="overflow-ellipsis
overflow-hidden whitespace-nowrap"
+ [title]="row.identity">
+ {{ row.identity }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Permissions Column -->
+ <ng-container matColumnDef="permissions">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Permissions</th>
+ <td mat-cell *matCellDef="let row">
+ <div
+ class="overflow-ellipsis
overflow-hidden whitespace-nowrap"
+ [title]="row.permissions">
+ {{ row.permissions }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Actions Column -->
+ <ng-container matColumnDef="actions">
+ <th mat-header-cell *matHeaderCellDef></th>
+ <td mat-cell *matCellDef="let row">
+ <div class="flex items-center justify-end
gap-x-2">
+ <button
+ mat-icon-button
+ type="button"
+ [matMenuTriggerFor]="actionMenu"
+ class="h-16 w-16 flex items-center
justify-center icon global-menu">
+ <i class="fa fa-ellipsis-v"></i>
+ </button>
+ <mat-menu #actionMenu="matMenu"
xPosition="before">
+ <button mat-menu-item
(click)="editPolicy(row)">Edit</button>
+ <button mat-menu-item
(click)="removePolicy(row)">Remove</button>
+ </mat-menu>
+ </div>
+ </td>
+ </ng-container>
+ <tr mat-header-row
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
+ <tr
+ mat-row
+ *matRowDef="let row; let even = even; columns:
displayedColumns"
+ [class.even]="even"></tr>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+ } @else {
+ <div class="flex flex-col gap-y-4 items-center"
data-qa="error-loading-bucket policies">
+ <i class="fa fa-warning"></i>
+ <div class="text-lg font-semibold">Something went wrong</div>
+ <div class="text-center">Unable to retrieve information. Please
try again later.</div>
+ </div>
+ }
</mat-dialog-content>
<mat-dialog-actions align="end">
- <button mat-flat-button mat-dialog-close>Close</button>
+ <button mat-flat-button (click)="close()">Close</button>
</mat-dialog-actions>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
new file mode 100644
index 0000000000..19af4ca79b
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.spec.ts
@@ -0,0 +1,326 @@
+/*
+ * 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 {
+ ManageBucketPoliciesDialogComponent,
+ ManageBucketPoliciesDialogData
+} from './manage-bucket-policies-dialog.component';
+import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from
'@angular/material/dialog';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { Bucket } from '../../../../../state/buckets';
+import { BehaviorSubject, of, Subject } from 'rxjs';
+import { PolicySelection, BucketPolicyOptionsView } from
'../../../../../state/policies';
+import { PolicySubject } from '../../../../../service/buckets.service';
+import { AddPolicyToBucketDialogComponent } from
'../add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component';
+import { EditPolicyDialogComponent } from
'../edit-policy-dialog/edit-policy-dialog.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { errorFeatureKey } from '../../../../../state/error';
+
+const bucket: Bucket = {
+ allowBundleRedeploy: false,
+ allowPublicRead: false,
+ createdTimestamp: Date.now(),
+ description: 'Test Bucket',
+ identifier: 'bucket-1',
+ link: { href: '', params: { rel: '' } },
+ name: 'Test Bucket',
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 1 }
+};
+
+const user1: PolicySubject = {
+ identifier: 'user-1',
+ identity: 'alice',
+ type: 'user',
+ configurable: false
+};
+
+const user2: PolicySubject = {
+ identifier: 'user-2',
+ identity: 'bob',
+ type: 'user',
+ configurable: false
+};
+
+const group1: PolicySubject = {
+ identifier: 'group-1',
+ identity: 'admins',
+ type: 'group',
+ configurable: false
+};
+
+const mockOptions: BucketPolicyOptionsView = {
+ groups: [{ key: 'group-1', label: 'admins', type: 'group', subject: group1
}],
+ users: [
+ { key: 'user-1', label: 'alice', type: 'user', subject: user1 },
+ { key: 'user-2', label: 'bob', type: 'user', subject: user2 }
+ ],
+ all: [
+ { key: 'group-1', label: 'admins', type: 'group', subject: group1 },
+ { key: 'user-1', label: 'alice', type: 'user', subject: user1 },
+ { key: 'user-2', label: 'bob', type: 'user', subject: user2 }
+ ],
+ lookup: {
+ 'group-1': group1,
+ 'user-1': user1,
+ 'user-2': user2
+ }
+};
+
+const mockSelection: Partial<Record<'read' | 'write' | 'delete',
PolicySelection>> = {
+ read: {
+ policyId: 'policy-1',
+ revision: { version: 0 },
+ users: [user1],
+ userGroups: [group1]
+ },
+ write: {
+ policyId: 'policy-2',
+ revision: { version: 0 },
+ users: [user1],
+ userGroups: []
+ }
+};
+
+describe('ManageBucketPoliciesDialogComponent', () => {
+ let component: ManageBucketPoliciesDialogComponent;
+ let fixture: ComponentFixture<ManageBucketPoliciesDialogComponent>;
+ let dialogRef:
jest.Mocked<MatDialogRef<ManageBucketPoliciesDialogComponent>>;
+
+ beforeEach(async () => {
+ const options$ = new
BehaviorSubject<BucketPolicyOptionsView>(mockOptions);
+ const selection$ = new BehaviorSubject(mockSelection);
+ const loading$ = new BehaviorSubject<boolean>(false);
+ const saving$ = new BehaviorSubject<boolean>(false);
+ const isAddPolicyDisabled$ = new BehaviorSubject<boolean>(false);
+ const isPolicyError$ = new BehaviorSubject<boolean>(false);
+
+ const mockDialogRef = {
+ close: jest.fn()
+ };
+
+ const afterOpenedSubject = new Subject();
+ const afterAllClosedSubject = new Subject();
+ const mockDialog = {
+ open: jest.fn().mockReturnValue({
+ afterClosed: () => of(undefined)
+ }),
+ _getAfterAllClosed: jest.fn(),
+ get afterAllClosed() {
+ return afterAllClosedSubject.asObservable();
+ },
+ _openDialogsAtThisLevel: [],
+ _afterAllClosedAtThisLevel: afterAllClosedSubject,
+ _afterOpenedAtThisLevel: afterOpenedSubject,
+ get afterOpened() {
+ return afterOpenedSubject.asObservable();
+ },
+ openDialogs: []
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ManageBucketPoliciesDialogComponent,
NoopAnimationsModule],
+ providers: [
+ provideMockStore({
+ initialState: {
+ [errorFeatureKey]: {
+ bannerErrors: {},
+ dialogErrors: {}
+ }
+ }
+ }),
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ bucket,
+ options$,
+ selection$,
+ loading$,
+ isPolicyError$,
+ isAddPolicyDisabled$,
+ saving$
+ } as ManageBucketPoliciesDialogData
+ },
+ { provide: MatDialogRef, useValue: mockDialogRef },
+ { provide: MatDialog, useValue: mockDialog }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ManageBucketPoliciesDialogComponent);
+ component = fixture.componentInstance;
+ dialogRef = TestBed.inject(MatDialogRef) as
jest.Mocked<MatDialogRef<ManageBucketPoliciesDialogComponent>>;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should build table rows from policy selections', () => {
+ expect(component.dataSource.data.length).toBe(2);
+
+ const aliceRow = component.dataSource.data.find((r) => r.identity ===
'alice');
+ expect(aliceRow).toBeDefined();
+ expect(aliceRow!.permissions).toBe('read, write');
+ expect(aliceRow!.actions).toEqual(['read', 'write']);
+
+ const adminsRow = component.dataSource.data.find((r) => r.identity ===
'admins');
+ expect(adminsRow).toBeDefined();
+ expect(adminsRow!.permissions).toBe('read');
+ expect(adminsRow!.actions).toEqual(['read']);
+ });
+
+ it('should sort table data correctly', () => {
+ component.sortData({ active: 'identity', direction: 'desc' });
+ expect(component.dataSource.data[0].identity).toBe('alice'); //
Descending
+ });
+
+ it('should open add policy dialog when addPolicy is called', () => {
+ const dialogSpy = jest.spyOn(component['dialog'],
'open').mockReturnValue({
+ afterClosed: () => of(undefined)
+ } as any);
+
+ component.addPolicy();
+
+ expect(dialogSpy).toHaveBeenCalledWith(
+ AddPolicyToBucketDialogComponent,
+ expect.objectContaining({
+ autoFocus: false,
+ data: expect.objectContaining({
+ bucket
+ })
+ })
+ );
+ });
+
+ it('should open edit policy dialog when editPolicy is called', () => {
+ const dialogSpy = jest.spyOn(component['dialog'],
'open').mockReturnValue({
+ afterClosed: () => of(undefined)
+ } as any);
+
+ const row = component.dataSource.data[0];
+ component.editPolicy(row);
+
+ expect(dialogSpy).toHaveBeenCalledWith(
+ EditPolicyDialogComponent,
+ expect.objectContaining({
+ data: expect.objectContaining({
+ identity: row.identity,
+ type: row.type,
+ currentPermissions: row.actions
+ })
+ })
+ );
+ });
+
+ it('should emit savePolicies when addPolicy dialog returns result', (done)
=> {
+ const addResult = {
+ userOrGroup: user2,
+ permissions: ['read', 'write']
+ };
+ jest.spyOn(component['dialog'], 'open').mockReturnValue({
+ afterClosed: () => of(addResult)
+ } as any);
+
+ let emitCount = 0;
+ component.savePolicies.subscribe((request) => {
+ expect(request.bucketId).toBe(bucket.identifier);
+ expect(['read', 'write']).toContain(request.action);
+ emitCount++;
+ if (emitCount === 2) {
+ done();
+ }
+ });
+
+ component.addPolicy();
+ });
+
+ it('should emit savePolicies when editPolicy dialog returns result',
(done) => {
+ // Wait for initialization to complete
+ setTimeout(() => {
+ const row = component.dataSource.data.find((r) => r.identity ===
'alice')!; // alice with ['read', 'write']
+ const editResult = {
+ permissions: ['read'] // Removing 'write'
+ };
+ jest.spyOn(component['dialog'], 'open').mockReturnValue({
+ afterClosed: () => of(editResult)
+ } as any);
+
+ let emitCount = 0;
+ component.savePolicies.subscribe((request) => {
+ expect(request.bucketId).toBe(bucket.identifier);
+ emitCount++;
+ // Should emit once for removing 'write'
+ if (emitCount === 1) {
+ expect(request.action).toBe('write');
+ done();
+ }
+ });
+
+ component.editPolicy(row);
+ }, 100);
+ });
+
+ it('should open confirmation dialog and emit savePolicies when
removePolicy is called', (done) => {
+ const yesSubject = new Subject();
+ jest.spyOn(component['dialog'], 'open').mockReturnValue({
+ componentInstance: {
+ yes: yesSubject.asObservable()
+ },
+ afterClosed: () => of(undefined)
+ } as any);
+
+ const row = component.dataSource.data[0];
+
+ let emitCount = 0;
+ const expectedEmissions = row.actions.length;
+ component.savePolicies.subscribe((request) => {
+ expect(request.bucketId).toBe(bucket.identifier);
+ expect(row.actions).toContain(request.action);
+ emitCount++;
+ if (emitCount === expectedEmissions) {
+ done();
+ }
+ });
+
+ component.removePolicy(row);
+ yesSubject.next(true);
+ });
+
+ it('should format permissions correctly', () => {
+ const permissions = component['formatPermissions'](['write', 'read',
'delete']);
+ expect(permissions).toBe('delete, read, write'); // Sorted
alphabetically
+ });
+
+ it('should sort policies by identity', () => {
+ component.sortData({ active: 'identity', direction: 'asc' });
+ const firstRow = component.dataSource.data[0];
+ expect(['admins', 'alice']).toContain(firstRow.identity);
+ });
+
+ it('should sort policies by type', () => {
+ component.sortData({ active: 'type', direction: 'asc' });
+ const data = component.dataSource.data;
+ expect(data[0].type).toBe('group'); // 'group' comes before 'user'
+ });
+
+ it('should close dialog when close is called', () => {
+ component.close();
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
index 1593ce0ae0..b52736ee38 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts
@@ -15,13 +15,56 @@
* limitations under the License.
*/
-import { Component, inject } from '@angular/core';
-import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { Component, DestroyRef, OnInit, inject, Output, EventEmitter } from
'@angular/core';
+import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from
'@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatSortModule, Sort } from '@angular/material/sort';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatDialog } from '@angular/material/dialog';
+import { AsyncPipe } from '@angular/common';
+import { Observable } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { take } from 'rxjs/operators';
+import { NiFiCommon, SMALL_DIALOG, YesNoDialog, MEDIUM_DIALOG, LARGE_DIALOG }
from '@nifi/shared';
+import {
+ AddPolicyToBucketDialogComponent,
+ AddPolicyResult
+} from '../add-policy-to-bucket-dialog/add-policy-to-bucket-dialog.component';
+import { EditPolicyDialogComponent, EditPolicyResult } from
'../edit-policy-dialog/edit-policy-dialog.component';
import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { BucketPolicyOptionsView, PolicySelection, PolicySection } from
'apps/nifi-registry/src/app/state/policies';
+import { PolicySubject, PolicyRevision } from
'apps/nifi-registry/src/app/service/buckets.service';
+import { ContextErrorBanner } from
'../../../../../ui/common/context-error-banner/context-error-banner.component';
+import { ErrorContextKey } from '../../../../../state/error';
+
+export interface PolicyTableRow {
+ identity: string;
+ type: 'user' | 'group';
+ permissions: string;
+ identifier: string;
+ actions: PolicySection[];
+}
+
+export interface SaveBucketPoliciesRequest {
+ bucketId: string;
+ action: PolicySection;
+ policyId?: string;
+ revision?: PolicyRevision;
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+ isLastInBatch?: boolean;
+}
export interface ManageBucketPoliciesDialogData {
bucket: Bucket;
+ options$: Observable<BucketPolicyOptionsView>;
+ selection$: Observable<Partial<Record<PolicySection, PolicySelection>>>;
+ loading$: Observable<boolean>;
+ saving$: Observable<boolean>;
+ isAddPolicyDisabled$: Observable<boolean>;
+ isPolicyError$: Observable<boolean>;
}
@Component({
@@ -29,8 +72,305 @@ export interface ManageBucketPoliciesDialogData {
templateUrl: './manage-bucket-policies-dialog.component.html',
styleUrl: './manage-bucket-policies-dialog.component.scss',
standalone: true,
- imports: [MatDialogModule, MatButtonModule]
+ imports: [
+ MatDialogModule,
+ MatButtonModule,
+ MatProgressSpinnerModule,
+ MatTableModule,
+ MatSortModule,
+ MatMenuModule,
+ AsyncPipe,
+ ContextErrorBanner
+ ]
})
-export class ManageBucketPoliciesDialogComponent {
+export class ManageBucketPoliciesDialogComponent implements OnInit {
protected data = inject<ManageBucketPoliciesDialogData>(MAT_DIALOG_DATA);
+ private dialogRef =
inject(MatDialogRef<ManageBucketPoliciesDialogComponent>);
+ private destroyRef = inject(DestroyRef);
+ private nifiCommon = inject(NiFiCommon);
+ private dialog = inject(MatDialog);
+
+ // Get observables from dialog data
+ options$ = this.data.options$;
+ selection$ = this.data.selection$;
+ loading$ = this.data.loading$;
+ saving$ = this.data.saving$;
+ isPolicyError$ = this.data.isPolicyError$;
+ isAddPolicyDisabled$ = this.data.isAddPolicyDisabled$;
+
+ @Output() savePolicies = new EventEmitter<SaveBucketPoliciesRequest>();
+
+ private latestOptions: BucketPolicyOptionsView | null = null;
+ private latestSelection: Partial<Record<PolicySection, PolicySelection>> =
{};
+
+ dataSource: MatTableDataSource<PolicyTableRow> = new
MatTableDataSource<PolicyTableRow>();
+ displayedColumns: string[] = ['type', 'identity', 'permissions',
'actions'];
+ sort: Sort = {
+ active: 'identity',
+ direction: 'asc'
+ };
+
+ ngOnInit(): void {
+ // Subscribe to options$ to keep latest options
+
this.options$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((options) => {
+ this.latestOptions = options;
+ });
+
+ // Subscribe to selection$ to build table data
+
this.selection$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((selectionMap)
=> {
+ this.latestSelection = selectionMap || {};
+ const rows = this.buildTableRows(selectionMap || {});
+ this.dataSource.data = this.sortPolicies(rows, this.sort);
+ });
+ }
+
+ private buildTableRows(selectionMap: Partial<Record<PolicySection,
PolicySelection>>): PolicyTableRow[] {
+ const rowMap = new Map<string, PolicyTableRow>();
+
+ // Iterate through each policy type (read, write, delete)
+ (['read', 'write', 'delete'] as PolicySection[]).forEach((section) => {
+ const selection = selectionMap[section];
+ if (!selection) return;
+
+ // Process users
+ selection.users.forEach((user) => {
+ const key = `user-${user.identifier}`;
+ const existing = rowMap.get(key);
+ if (existing) {
+ existing.actions.push(section);
+ existing.permissions =
this.formatPermissions(existing.actions);
+ } else {
+ rowMap.set(key, {
+ identity: user.identity,
+ type: 'user',
+ permissions: section,
+ identifier: user.identifier,
+ actions: [section]
+ });
+ }
+ });
+
+ // Process groups
+ selection.userGroups.forEach((group) => {
+ const key = `group-${group.identifier}`;
+ const existing = rowMap.get(key);
+ if (existing) {
+ existing.actions.push(section);
+ existing.permissions =
this.formatPermissions(existing.actions);
+ } else {
+ rowMap.set(key, {
+ identity: group.identity,
+ type: 'group',
+ permissions: section,
+ identifier: group.identifier,
+ actions: [section]
+ });
+ }
+ });
+ });
+
+ return Array.from(rowMap.values());
+ }
+
+ private formatPermissions(actions: PolicySection[]): string {
+ return actions.sort().join(', ');
+ }
+
+ sortData(sort: Sort) {
+ this.sort = sort;
+ this.dataSource.data = this.sortPolicies(this.dataSource.data, sort);
+ }
+
+ sortPolicies(data: PolicyTableRow[], sort: Sort): PolicyTableRow[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal = 0;
+ switch (sort.active) {
+ case 'identity':
+ retVal = this.nifiCommon.compareString(a.identity,
b.identity);
+ break;
+ case 'type':
+ retVal = this.nifiCommon.compareString(a.type, b.type);
+ break;
+ case 'permissions':
+ retVal = this.nifiCommon.compareString(a.permissions,
b.permissions);
+ break;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ addPolicy(): void {
+ if (!this.latestOptions) {
+ return;
+ }
+
+ // Get list of existing users and groups across all policy types
+ const existingUsers = new Set<string>();
+ const existingGroups = new Set<string>();
+
+ Object.values(this.latestSelection).forEach((selection) => {
+ if (selection) {
+ selection.users.forEach((user) =>
existingUsers.add(user.identity));
+ selection.userGroups.forEach((group) =>
existingGroups.add(group.identity));
+ }
+ });
+
+ const dialogRef = this.dialog.open(AddPolicyToBucketDialogComponent, {
+ ...LARGE_DIALOG,
+ autoFocus: false,
+ data: {
+ bucket: this.data.bucket,
+ existingUsers: Array.from(existingUsers),
+ existingGroups: Array.from(existingGroups),
+ availableUsers: this.latestOptions.users.map((u) => u.subject),
+ availableGroups: this.latestOptions.groups.map((g) =>
g.subject)
+ }
+ });
+
+ dialogRef.afterClosed().subscribe((result: AddPolicyResult |
undefined) => {
+ if (result) {
+ // Emit save requests
+ result.permissions.forEach((action, index) => {
+ const currentSelection = this.latestSelection[action];
+
+ // Create copies of arrays (state arrays are immutable)
+ const users = [...(currentSelection?.users || [])];
+ const userGroups = [...(currentSelection?.userGroups ||
[])];
+
+ if (result.userOrGroup.type === 'user') {
+ users.push(result.userOrGroup);
+ } else {
+ userGroups.push(result.userOrGroup);
+ }
+
+ const isLast = index === result.permissions.length - 1;
+
+ this.savePolicies.emit({
+ bucketId: this.data.bucket.identifier,
+ action,
+ policyId: currentSelection?.policyId,
+ revision: currentSelection?.revision,
+ users,
+ userGroups,
+ isLastInBatch: isLast
+ });
+ });
+ }
+ });
+ }
+
+ editPolicy(row: PolicyTableRow): void {
+ const dialogRef = this.dialog.open(EditPolicyDialogComponent, {
+ ...MEDIUM_DIALOG,
+ data: {
+ identity: row.identity,
+ type: row.type,
+ currentPermissions: row.actions
+ }
+ });
+
+ dialogRef.afterClosed().subscribe((result: EditPolicyResult |
undefined) => {
+ if (result) {
+ const previousPermissions = new Set(row.actions);
+ const newPermissions = new Set(result.permissions);
+
+ // Determine which permissions to add and which to remove
+ const toAdd: PolicySection[] = result.permissions.filter((p)
=> !previousPermissions.has(p));
+ const toRemove: PolicySection[] = row.actions.filter((p) =>
!newPermissions.has(p));
+
+ const allActions = [...toAdd, ...toRemove];
+
+ // Process all changes
+ allActions.forEach((action, index) => {
+ const currentSelection = this.latestSelection[action];
+ if (!currentSelection) return;
+
+ // Create copies of arrays
+ let users = [...currentSelection.users];
+ let userGroups = [...currentSelection.userGroups];
+
+ if (newPermissions.has(action)) {
+ // Add to this policy
+ const subject = {
+ identifier: row.identifier,
+ identity: row.identity,
+ type: row.type
+ };
+
+ if (row.type === 'user') {
+ // Check if not already present
+ if (!users.find((u) => u.identifier ===
row.identifier)) {
+ users.push(subject);
+ }
+ } else {
+ // Check if not already present
+ if (!userGroups.find((g) => g.identifier ===
row.identifier)) {
+ userGroups.push(subject);
+ }
+ }
+ } else {
+ // Remove from this policy
+ users = users.filter((u) => u.identifier !==
row.identifier);
+ userGroups = userGroups.filter((g) => g.identifier !==
row.identifier);
+ }
+
+ const isLast = index === allActions.length - 1;
+
+ this.savePolicies.emit({
+ bucketId: this.data.bucket.identifier,
+ action,
+ policyId: currentSelection.policyId,
+ revision: currentSelection.revision,
+ users,
+ userGroups,
+ isLastInBatch: isLast
+ });
+ });
+ }
+ });
+ }
+
+ removePolicy(row: PolicyTableRow): void {
+ const dialogRef = this.dialog.open(YesNoDialog, {
+ ...SMALL_DIALOG,
+ data: {
+ title: 'Delete Policy',
+ message: `All permissions granted by this policy will be
removed for ${row.identity}.`
+ }
+ });
+
+ dialogRef.componentInstance.yes.pipe(take(1)).subscribe(() => {
+ // Remove this user/group from each policy they have
+ row.actions.forEach((action, index) => {
+ const currentSelection = this.latestSelection[action];
+ if (!currentSelection) return;
+
+ // Create copies and filter out the removed user/group
+ const users = [...currentSelection.users].filter((u) =>
u.identifier !== row.identifier);
+ const userGroups = [...currentSelection.userGroups].filter((g)
=> g.identifier !== row.identifier);
+
+ const isLast = index === row.actions.length - 1;
+
+ this.savePolicies.emit({
+ bucketId: this.data.bucket.identifier,
+ action,
+ policyId: currentSelection.policyId,
+ revision: currentSelection.revision,
+ users,
+ userGroups,
+ isLastInBatch: isLast
+ });
+ });
+ });
+ }
+
+ close(): void {
+ this.dialogRef.close();
+ }
+
+ protected readonly ErrorContextKey = ErrorContextKey;
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
index d837ed4ad6..f3fde4ea38 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/login/ui/login-form/login-form.component.spec.ts
@@ -27,8 +27,9 @@ import { MatFormFieldModule } from
'@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { RouterTestingModule } from '@angular/router/testing';
-import { selectLoginFailure, selectLoginPending } from
'../../state/access/access.selectors';
-import { selectLogoutSupported } from
'../../../../state/current-user/current-user.selectors';
+import { selectLoginFailure } from '../../state/access/access.selectors';
+import { initialState as initialCurrentUserState } from
'../../../../state/current-user/current-user.reducer';
+import { currentUserFeatureKey } from '../../../../state/current-user';
describe('LoginFormComponent', () => {
let component: LoginFormComponent;
@@ -36,8 +37,6 @@ describe('LoginFormComponent', () => {
let store: MockStore<NiFiRegistryState>;
let dispatchSpy: jest.SpyInstance;
let loginFailureSelector: any;
- let loginPendingSelector: any;
- let logoutSupportedSelector: any;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -49,15 +48,20 @@ describe('LoginFormComponent', () => {
MatButtonModule,
RouterTestingModule
],
- providers: [provideMockStore(), provideHttpClientTesting()]
+ providers: [
+ provideMockStore({
+ initialState: {
+ [currentUserFeatureKey]: initialCurrentUserState
+ }
+ }),
+ provideHttpClientTesting()
+ ]
}).compileComponents();
fixture = TestBed.createComponent(LoginFormComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
loginFailureSelector = store.overrideSelector(selectLoginFailure,
null);
- loginPendingSelector = store.overrideSelector(selectLoginPending,
false);
- logoutSupportedSelector =
store.overrideSelector(selectLogoutSupported, false);
dispatchSpy = jest.spyOn(store, 'dispatch');
fixture.detectChanges();
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
index 18653f5593..deb75b342b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
@@ -33,7 +33,12 @@
[totalCount]="dataSource.data.length"
(filterChanged)="applyFilter($event)"></droplet-table-filter>
<div class="flex justify-end">
- <button mat-icon-button class="primary-icon-button"
(click)="openImportNewDropletDialog()">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ [disabled]="!canImportNewDroplet"
+ (click)="openImportNewDropletDialog()"
+ matTooltip="Import new resource">
<i class="fa fa-plus"></i>
</button>
</div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
index fe40532b37..bfb1620f90 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
@@ -155,6 +155,14 @@ export class ResourcesComponent implements OnInit {
});
}
+ filterWritableBuckets(buckets: Bucket[]): Bucket[] {
+ return buckets.filter((bucket) => bucket.permissions.canWrite);
+ }
+
+ get canImportNewDroplet(): boolean {
+ return this.buckets.length > 0 &&
this.filterWritableBuckets(this.buckets).length > 0;
+ }
+
openImportNewDropletDialog() {
this.store.dispatch(openImportNewDropletDialog({ request: { buckets:
this.buckets } }));
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
index f821a0e2ac..1d11f88abc 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
@@ -29,6 +29,7 @@ import { MatSortModule } from '@angular/material/sort';
import { MatMenuModule } from '@angular/material/menu';
import { DropletTableFilterComponent } from
'./ui/droplet-table-filter/droplet-table-filter.component';
import { MatButtonModule } from '@angular/material/button';
+import { MatTooltipModule } from '@angular/material/tooltip';
import { DropletTableComponent } from
'./ui/droplet-table/droplet-table.component';
import { ContextErrorBanner } from
'../../../ui/common/context-error-banner/context-error-banner.component';
import { HeaderComponent } from '../../../ui/header/header.component';
@@ -41,15 +42,15 @@ import { HeaderComponent } from
'../../../ui/header/header.component';
MatTableModule,
MatSortModule,
MatMenuModule,
- DropletTableFilterComponent,
- MatButtonModule,
MatButtonModule,
- DropletTableComponent,
- ContextErrorBanner,
+ MatTooltipModule,
ResourcesRoutingModule,
StoreModule.forFeature(resourcesFeatureKey, reducers),
EffectsModule.forFeature([DropletsEffects, BucketsEffects]),
- HeaderComponent
+ HeaderComponent,
+ DropletTableFilterComponent,
+ DropletTableComponent,
+ ContextErrorBanner
]
})
export class ResourcesModule {}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
index 975a858229..c861484b08 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
@@ -102,15 +102,24 @@
<i class="fa fa-list primary-color
mr-2"></i>
See Versions
</button>
- <button mat-menu-item
(click)="openImportNewDropletVersionDialog(item)">
+ <button
+ mat-menu-item
+ [disabled]="!canImportNewVersion(item)"
+
(click)="openImportNewDropletVersionDialog(item)">
<i class="fa fa-upload primary-color
mr-2"></i>
Import new version
</button>
- <button mat-menu-item
(click)="openExportDropletVersionDialog(item)">
+ <button
+ mat-menu-item
+ [disabled]="!canExportVersion(item)"
+
(click)="openExportDropletVersionDialog(item)">
<i class="fa fa-download primary-color
mr-2"></i>
Export version
</button>
- <button mat-menu-item
(click)="openDeleteDialog(item)">
+ <button
+ mat-menu-item
+ [disabled]="!canDeleteResource(item)"
+ (click)="openDeleteDialog(item)">
<i class="fa fa-trash primary-color
mr-2"></i>
Delete resource
</button>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
index 20f39c7292..f1684a872e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
@@ -33,7 +33,7 @@ import {
@Component({
selector: 'droplet-table',
- imports: [MatTableModule, MatSortModule, MatMenuModule, MatButtonModule,
MatButtonModule],
+ imports: [MatTableModule, MatSortModule, MatMenuModule, MatButtonModule],
templateUrl: './droplet-table.component.html',
styleUrl: './droplet-table.component.scss'
})
@@ -131,4 +131,16 @@ export class DropletTableComponent implements OnInit {
openDropletVersionsDialog(droplet: Droplet) {
this.store.dispatch(openDropletVersionsDialog({ request: { droplet }
}));
}
+
+ canImportNewVersion(droplet: Droplet): boolean {
+ return droplet.permissions.canWrite;
+ }
+
+ canExportVersion(droplet: Droplet): boolean {
+ return droplet.permissions.canRead;
+ }
+
+ canDeleteResource(droplet: Droplet): boolean {
+ return droplet.permissions.canDelete;
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
index cea12323b6..a795251e57 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
@@ -16,10 +16,49 @@
*/
import { Injectable, inject } from '@angular/core';
-import { Observable } from 'rxjs';
-import { HttpClient } from '@angular/common/http';
+import { forkJoin, Observable, of, throwError } from 'rxjs';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Bucket } from '../state/buckets';
import { CreateBucketRequest, DeleteBucketRequest } from
'../state/buckets/buckets.actions';
+import { catchError } from 'rxjs/operators';
+
+export type PolicyAction = 'read' | 'write' | 'delete';
+
+export interface PolicySubject {
+ identifier: string;
+ identity: string;
+ type: 'user' | 'group';
+ [key: string]: unknown;
+}
+
+export interface PolicyRevision {
+ version: number;
+ clientId?: string;
+}
+
+export interface Policy {
+ identifier: string;
+ action: PolicyAction;
+ resource: string;
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+ revision: PolicyRevision;
+ [key: string]: unknown;
+}
+
+export interface SaveBucketPolicyRequest {
+ bucketId: string;
+ action: PolicyAction;
+ policyId?: string;
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+ revision?: PolicyRevision;
+}
+
+export interface BucketPolicyTenants {
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+}
@Injectable({ providedIn: 'root' })
export class BucketsService {
@@ -96,4 +135,78 @@ export class BucketsService {
`${BucketsService.API}/buckets/${request.bucket.identifier}?version=${request.version}`
);
}
+
+ getPolicies(): Observable<Policy[]> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 404,
+ // statusText: 'Bad Gateway',
+ // url: `${BucketsService.API}/policies`,
+ // error: {
+ // message: 'Mock error: unable to get policies.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.get<Policy[]>(`${BucketsService.API}/policies`);
+ }
+
+ getBucketPolicyTenants(): Observable<BucketPolicyTenants> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 404,
+ // statusText: 'Bad Gateway',
+ // url: `${BucketsService.API}/tenants/users`,
+ // error: {
+ // message: 'Mock error: unable to get bucket policy tenents.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return forkJoin({
+ users: this.httpClient
+ .get<PolicySubject[]>(`${BucketsService.API}/tenants/users`)
+ .pipe(catchError((error: HttpErrorResponse) =>
this.handleTenantError(error))),
+ userGroups: this.httpClient
+
.get<PolicySubject[]>(`${BucketsService.API}/tenants/user-groups`)
+ .pipe(catchError((error: HttpErrorResponse) =>
this.handleTenantError(error)))
+ });
+ }
+
+ saveBucketPolicy(request: SaveBucketPolicyRequest): Observable<Policy> {
+ const resource = this.buildResourcePath(request.bucketId);
+ const payload = this.buildPolicyPayload(request, resource);
+
+ if (request.policyId) {
+ return this.httpClient
+
.put<Policy>(`${BucketsService.API}/policies/${request.policyId}`, payload)
+ .pipe(catchError((error: HttpErrorResponse) => throwError(()
=> error)));
+ }
+
+ return this.httpClient
+ .post<Policy>(`${BucketsService.API}/policies`, payload)
+ .pipe(catchError((error: HttpErrorResponse) => throwError(() =>
error)));
+ }
+
+ private buildPolicyPayload(request: SaveBucketPolicyRequest, resource:
string) {
+ return {
+ identifier: request.policyId ?? null,
+ action: request.action,
+ resource,
+ users: request.users,
+ userGroups: request.userGroups,
+ revision: request.policyId ? (request.revision ?? { version: 0 })
: { version: 0 }
+ };
+ }
+
+ private buildResourcePath(bucketId: string): string {
+ return `/buckets/${bucketId}`;
+ }
+
+ private handleTenantError(error: HttpErrorResponse):
Observable<PolicySubject[]> {
+ if (error.status === 404) {
+ return of([]);
+ }
+ return throwError(() => error);
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
index c612dc3f3e..97d14213aa 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
@@ -76,5 +76,3 @@ export const openManageBucketPoliciesDialog = createAction(
'[Buckets] Open Manage Bucket Policies Dialog',
props<{ request: { bucket: Bucket } }>()
);
-
-export const bucketNoOp = createAction('[Buckets] No Op');
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
index 107b1bef37..55dc950b00 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
@@ -18,12 +18,13 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Action } from '@ngrx/store';
-import { Observable, of, throwError } from 'rxjs';
+import { Observable, of, throwError, Subject } from 'rxjs';
import { BucketsEffects } from './buckets.effects';
import { BucketsService } from '../../service/buckets.service';
import { ErrorHelper } from '../../service/error-helper.service';
import { MatDialog } from '@angular/material/dialog';
import * as BucketsActions from './buckets.actions';
+import * as PoliciesActions from '../policies/policies.actions';
import * as ErrorActions from '../error/error.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorContextKey } from '../error';
@@ -331,21 +332,52 @@ describe('BucketsEffects', () => {
});
describe('openManageBucketPoliciesDialog$', () => {
- it('should open manage bucket policies dialog', (done) => {
+ it('should dispatch load actions and open dialog when both succeed',
(done) => {
const bucket = createBucket();
+ const mockDialogRef = {
+ componentInstance: {
+ savePolicies: {
+ pipe: jest.fn().mockReturnValue({ subscribe: jest.fn()
})
+ }
+ },
+ afterClosed: jest.fn().mockReturnValue(of(undefined))
+ };
- actions$ = of(BucketsActions.openManageBucketPoliciesDialog({
request: { bucket } }));
+ dialog.open.mockReturnValue(mockDialogRef as any);
+
+ // Use a Subject to control when actions are emitted
+ const actionsSubject = new Subject<Action>();
+ actions$ = actionsSubject.asObservable();
+ // Subscribe to the effect
effects.openManageBucketPoliciesDialog$.subscribe(() => {
expect(dialog.open).toHaveBeenCalledWith(
ManageBucketPoliciesDialogComponent,
expect.objectContaining({
autoFocus: false,
- data: { bucket }
+ data: expect.objectContaining({
+ bucket
+ })
})
);
+ expect(store.dispatch).toHaveBeenCalledTimes(2); //
loadPolicyTenants + loadPolicies
done();
});
+
+ // Emit the initial action to trigger the effect
+
actionsSubject.next(BucketsActions.openManageBucketPoliciesDialog({ request: {
bucket } }));
+
+ // Allow the effect to set up listeners, then emit success actions
+ setTimeout(() => {
+ actionsSubject.next(
+ PoliciesActions.loadPolicyTenantsSuccess({ response: {
users: [], userGroups: [] } })
+ );
+ actionsSubject.next(
+ PoliciesActions.loadPoliciesSuccess({
+ response: { bucketId: bucket.identifier, policies: [] }
+ })
+ );
+ }, 10);
});
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
index 39bedcb889..8787fb6539 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
@@ -19,9 +19,10 @@ import { inject, Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { MatDialog } from '@angular/material/dialog';
import { Actions, createEffect, ofType } from '@ngrx/effects';
-import { from, of, take } from 'rxjs';
+import { from, of, take, takeUntil, forkJoin, race } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import * as BucketsActions from './buckets.actions';
+import { deleteBucket } from './buckets.actions';
import { BucketsService } from '../../service/buckets.service';
import { ErrorHelper } from '../../service/error-helper.service';
import { ErrorContextKey } from '../error';
@@ -29,10 +30,17 @@ import * as ErrorActions from '../error/error.actions';
import { CreateBucketDialogComponent } from
'../../pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component';
import { EditBucketDialogComponent } from
'../../pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component';
import { ManageBucketPoliciesDialogComponent } from
'../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component';
-import { LARGE_DIALOG, MEDIUM_DIALOG, SMALL_DIALOG, YesNoDialog } from
'@nifi/shared';
-import { deleteBucket } from './buckets.actions';
+import { MEDIUM_DIALOG, XL_DIALOG, YesNoDialog } from '@nifi/shared';
import { Store } from '@ngrx/store';
-import { NiFiState } from '../../../../../nifi/src/app/state';
+import {
+ selectPoliciesLoading,
+ selectPoliciesSaving,
+ selectPolicyOptions,
+ selectPolicySelection
+} from '../policies/policies.selectors';
+import { selectCurrentUser } from '../current-user/current-user.selectors';
+import * as PoliciesActions from '../policies/policies.actions';
+import { selectBannerErrors } from '../error/error.selectors';
@Injectable()
export class BucketsEffects {
@@ -40,7 +48,7 @@ export class BucketsEffects {
private errorHelper = inject(ErrorHelper);
private dialog = inject(MatDialog);
private actions$ = inject(Actions);
- private store = inject<Store<NiFiState>>(Store);
+ private store = inject(Store);
loadBuckets$ = createEffect(() =>
this.actions$.pipe(
@@ -151,7 +159,7 @@ export class BucketsEffects {
ofType(BucketsActions.openDeleteBucketDialog),
tap(({ request }) => {
const dialogRef = this.dialog.open(YesNoDialog, {
- ...SMALL_DIALOG,
+ ...MEDIUM_DIALOG,
data: {
title: 'Delete Bucket',
message: `All items stored in this bucket will be
deleted as well.`
@@ -202,12 +210,97 @@ export class BucketsEffects {
() =>
this.actions$.pipe(
ofType(BucketsActions.openManageBucketPoliciesDialog),
- tap(({ request }) => {
- this.dialog.open(ManageBucketPoliciesDialogComponent, {
- ...LARGE_DIALOG,
- autoFocus: false,
- data: { bucket: request.bucket }
- });
+ switchMap(({ request }) => {
+ // Dispatch both load actions
+ this.store.dispatch(
+ PoliciesActions.loadPolicyTenants({ request: {
context: ErrorContextKey.GLOBAL } })
+ );
+ this.store.dispatch(
+ PoliciesActions.loadPolicies({
+ request: { bucketId: request.bucket.identifier,
context: ErrorContextKey.GLOBAL }
+ })
+ );
+
+ // Wait for both success actions or either failure action
+ const tenantsSuccess$ = this.actions$.pipe(
+ ofType(PoliciesActions.loadPolicyTenantsSuccess),
+ take(1)
+ );
+
+ const policiesSuccess$ =
this.actions$.pipe(ofType(PoliciesActions.loadPoliciesSuccess), take(1));
+
+ const anyFailure$ = this.actions$.pipe(
+ ofType(PoliciesActions.loadPolicyTenantsFailure,
PoliciesActions.loadPoliciesFailure),
+ take(1),
+ map(() => null) // Return null to indicate failure
+ );
+
+ // Race between both succeeding or any failing
+ return race(forkJoin([tenantsSuccess$, policiesSuccess$]),
anyFailure$).pipe(
+ tap((result) => {
+ // Only open dialog if both succeeded (result is
not null)
+ if (result) {
+ const dialogRef =
this.dialog.open(ManageBucketPoliciesDialogComponent, {
+ ...XL_DIALOG,
+ autoFocus: false,
+ data: {
+ bucket: request.bucket,
+ options$:
this.store.select(selectPolicyOptions),
+ selection$:
this.store.select(selectPolicySelection),
+ loading$:
this.store.select(selectPoliciesLoading),
+ saving$:
this.store.select(selectPoliciesSaving),
+ isPolicyError$: this.store
+
.select(selectBannerErrors(ErrorContextKey.MANAGE_ACCESS))
+ .pipe(
+ map((bannerErrors) => {
+ if (bannerErrors.length >
0) {
+ return true;
+ }
+ return false;
+ })
+ ),
+ isAddPolicyDisabled$:
this.store.select(selectCurrentUser).pipe(
+ map((currentUser) => {
+ // Disable if anonymous
+ if (currentUser.anonymous) {
+ return true;
+ }
+ // Disable if user can't write
policies
+ if
(!currentUser.resourcePermissions.policies.canWrite) {
+ return true;
+ }
+ // Disable if user can't read
tenants
+ if
(!currentUser.resourcePermissions.tenants.canRead) {
+ return true;
+ }
+ return false;
+ })
+ )
+ }
+ });
+
+ // Subscribe to output and handle multiple
emissions until dialog closes
+ dialogRef.componentInstance.savePolicies
+ .pipe(takeUntil(dialogRef.afterClosed()))
+ .subscribe((saveRequest) => {
+ this.store.dispatch(
+ PoliciesActions.saveBucketPolicy({
+ request: {
+ bucketId:
saveRequest.bucketId,
+ action: saveRequest.action,
+ policyId:
saveRequest.policyId,
+ revision:
saveRequest.revision,
+ users: saveRequest.users,
+ userGroups:
saveRequest.userGroups,
+ isLastInBatch:
saveRequest.isLastInBatch
+ }
+ })
+ );
+ });
+ }
+ // If result is null, errors are already handled
by the individual effects
+ })
+ );
})
),
{ dispatch: false }
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
index 9ee3476afb..4a63212f05 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
@@ -17,7 +17,6 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { bucketsFeatureKey, BucketsState } from './index';
-
import { resourcesFeatureKey, ResourcesState } from '..';
import { selectCurrentRoute } from '@nifi/shared';
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
index 317adf1392..36b848beff 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/current-user/current-user.effects.spec.ts
@@ -26,7 +26,6 @@ import { RegistryAuthService } from
'../../service/registry-auth.service';
import * as CurrentUserActions from './current-user.actions';
import { HttpErrorResponse } from '@angular/common/http';
import { Store } from '@ngrx/store';
-import { selectLoginFailure } from
'../../pages/login/state/access/access.selectors';
import { resetLoginFailure } from
'../../pages/login/state/access/access.actions';
import * as ErrorActions from '../error/error.actions';
@@ -36,7 +35,6 @@ describe('CurrentUserEffects', () => {
let authService: jest.Mocked<RegistryAuthService>;
let errorHelper: jest.Mocked<ErrorHelper>;
let store: Store<any>;
- let dispatchSpy: jest.SpyInstance;
beforeEach(() => {
const registryApiServiceMock = {
@@ -58,7 +56,6 @@ describe('CurrentUserEffects', () => {
dispatch: jest.fn(),
select: jest.fn().mockReturnValue(of(null))
} as unknown as Store<any>;
- dispatchSpy = jest.spyOn(store as any, 'dispatch');
TestBed.configureTestingModule({
providers: [
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
index 8e270e0249..0088db74e4 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
@@ -27,6 +27,7 @@ export enum ErrorContextKey {
CREATE_DROPLET = 'create droplet',
IMPORT_DROPLET_VERSION = 'import droplet version',
CREATE_BUCKET = 'create bucket',
+ MANAGE_ACCESS = 'manage access',
UPDATE_BUCKET = 'update bucket',
GLOBAL = 'global'
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
index 347581c1da..6bb7fb18ae 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
@@ -21,6 +21,8 @@ import { dropletsFeatureKey, DropletsState } from
'./droplets';
import { dropletsReducer } from './droplets/droplets.reducer';
import { bucketsFeatureKey, BucketsState } from './buckets';
import { bucketsReducer } from './buckets/buckets.reducer';
+import { policiesFeatureKey, PoliciesState } from './policies';
+import { policiesReducer } from './policies/policies.reducer';
import { errorReducer } from './error/error.reducer';
import { errorFeatureKey, ErrorState } from './error';
import { aboutFeatureKey, AboutState } from './about';
@@ -52,12 +54,14 @@ export interface Permissions {
export interface ResourcesState {
[dropletsFeatureKey]: DropletsState;
[bucketsFeatureKey]: BucketsState;
+ [policiesFeatureKey]: PoliciesState;
}
export function reducers(state: ResourcesState | undefined, action: Action) {
return combineReducers({
[dropletsFeatureKey]: dropletsReducer,
- [bucketsFeatureKey]: bucketsReducer
+ [bucketsFeatureKey]: bucketsReducer,
+ [policiesFeatureKey]: policiesReducer
})(state, action);
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
new file mode 100644
index 0000000000..5ce7492335
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/index.ts
@@ -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.
+ */
+
+import { PolicySubject } from '../../service/buckets.service';
+
+export const policiesFeatureKey = 'policies';
+
+export type PolicySection = 'read' | 'write' | 'delete';
+
+export interface PolicySelection {
+ policyId?: string;
+ revision?: { version: number; clientId?: string };
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+}
+
+export interface BucketPolicyOptionsView {
+ groups: { key: string; label: string; type: 'group'; subject:
PolicySubject }[];
+ users: { key: string; label: string; type: 'user'; subject: PolicySubject
}[];
+ all: { key: string; label: string; type: 'user' | 'group'; subject:
PolicySubject }[];
+ lookup: Record<string, PolicySubject>;
+}
+
+export interface PoliciesState {
+ bucketId: string | null;
+ tenants: {
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+ };
+ loadingPolicies: boolean;
+ loadingTenants: boolean;
+ saving: boolean;
+ error: string | null;
+ policySelection: Partial<Record<PolicySection, PolicySelection>>;
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
new file mode 100644
index 0000000000..f770b11402
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.actions.ts
@@ -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 { createAction, props } from '@ngrx/store';
+import { PolicySection } from '.';
+import { Policy, PolicyRevision, PolicySubject } from
'../../service/buckets.service';
+import { ErrorContextKey } from '../error';
+
+export const loadPolicies = createAction(
+ '[Policies] Load Policies',
+ props<{ request: { bucketId: string; context: ErrorContextKey } }>()
+);
+
+export const loadPoliciesSuccess = createAction(
+ '[Policies] Load Policies Success',
+ props<{ response: { bucketId: string; policies: Policy[] } }>()
+);
+
+export const loadPoliciesFailure = createAction('[Policies] Load Policies
Failure');
+
+export const loadPolicyTenants = createAction(
+ '[Policies] Load Policy Tenants',
+ props<{ request: { context: ErrorContextKey } }>()
+);
+
+export const loadPolicyTenantsSuccess = createAction(
+ '[Policies] Load Policy Tenants Success',
+ props<{ response: { users: PolicySubject[]; userGroups: PolicySubject[] }
}>()
+);
+
+export const loadPolicyTenantsFailure = createAction('[Policies] Load Policy
Tenants Failure');
+
+export const saveBucketPolicy = createAction(
+ '[Policies] Save Bucket Policy',
+ props<{
+ request: {
+ bucketId: string;
+ action: PolicySection;
+ policyId?: string;
+ users: PolicySubject[];
+ userGroups: PolicySubject[];
+ revision?: PolicyRevision;
+ isLastInBatch?: boolean;
+ };
+ }>()
+);
+
+export const saveBucketPolicyFailure = createAction('[Policies] Save Bucket
Policy Failure');
+
+export const policyChangeSuccessToast = createAction(
+ '[Policies] Policy Change Success Toast',
+ props<{ message: string }>()
+);
+
+export const policiesNoOp = createAction('[Policies] No Op');
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
new file mode 100644
index 0000000000..82d656c62b
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.spec.ts
@@ -0,0 +1,276 @@
+/*
+ * 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 { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { Action } from '@ngrx/store';
+import { Observable, of, throwError } from 'rxjs';
+import { PoliciesEffects } from './policies.effects';
+import { BucketsService, Policy, PolicySubject } from
'../../service/buckets.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import * as PoliciesActions from './policies.actions';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ErrorContextKey } from '../error';
+
+const createPolicy = (overrides = {}): Policy => ({
+ identifier: 'policy-1',
+ action: 'read',
+ resource: '/buckets/bucket-1',
+ users: [],
+ userGroups: [],
+ revision: { version: 0 },
+ configurable: true,
+ ...overrides
+});
+
+const createPolicySubject = (overrides = {}): PolicySubject => ({
+ identifier: 'user-1',
+ identity: 'test-user',
+ type: 'user',
+ configurable: false,
+ ...overrides
+});
+
+describe('PoliciesEffects', () => {
+ let actions$: Observable<Action>;
+ let effects: PoliciesEffects;
+ let bucketsService: jest.Mocked<BucketsService>;
+ let errorHelper: jest.Mocked<ErrorHelper>;
+ let snackBar: jest.Mocked<MatSnackBar>;
+
+ beforeEach(() => {
+ const mockBucketsService = {
+ getPolicies: jest.fn(),
+ getBucketPolicyTenants: jest.fn(),
+ saveBucketPolicy: jest.fn()
+ };
+
+ const mockErrorHelper = {
+ getErrorString: jest.fn()
+ };
+
+ const mockSnackBar = {
+ open: jest.fn()
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ PoliciesEffects,
+ provideMockActions(() => actions$),
+ { provide: BucketsService, useValue: mockBucketsService },
+ { provide: ErrorHelper, useValue: mockErrorHelper },
+ { provide: MatSnackBar, useValue: mockSnackBar }
+ ]
+ });
+
+ effects = TestBed.inject(PoliciesEffects);
+ bucketsService = TestBed.inject(BucketsService) as
jest.Mocked<BucketsService>;
+ errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ snackBar = TestBed.inject(MatSnackBar) as jest.Mocked<MatSnackBar>;
+ });
+
+ describe('loadPolicies$', () => {
+ it('should return loadPoliciesSuccess with policies on success',
(done) => {
+ const policies = [createPolicy(), createPolicy({ identifier:
'policy-2', action: 'write' })];
+ bucketsService.getPolicies.mockReturnValue(of(policies));
+
+ actions$ = of(
+ PoliciesActions.loadPolicies({
+ request: { bucketId: 'bucket-1', context:
ErrorContextKey.MANAGE_ACCESS }
+ })
+ );
+
+ effects.loadPolicies$.subscribe((action) => {
+ expect(action).toEqual(
+ PoliciesActions.loadPoliciesSuccess({
+ response: {
+ bucketId: 'bucket-1',
+ policies
+ }
+ })
+ );
+ done();
+ });
+ });
+
+ it('should return error actions on failure', (done) => {
+ const error = new HttpErrorResponse({ status: 500, statusText:
'Server Error' });
+ bucketsService.getPolicies.mockReturnValue(throwError(() =>
error));
+ errorHelper.getErrorString.mockReturnValue('Error loading
policies');
+
+ actions$ = of(
+ PoliciesActions.loadPolicies({
+ request: { bucketId: 'bucket-1', context:
ErrorContextKey.MANAGE_ACCESS }
+ })
+ );
+
+ effects.loadPolicies$.subscribe((action) => {
+ if (action.type === PoliciesActions.loadPoliciesFailure.type) {
+
expect(action).toEqual(PoliciesActions.loadPoliciesFailure());
+ done();
+ }
+ });
+ });
+ });
+
+ describe('loadPolicyTenants$', () => {
+ it('should return loadPolicyTenantsSuccess with tenants on success',
(done) => {
+ const users = [createPolicySubject()];
+ const userGroups = [createPolicySubject({ identifier: 'group-1',
identity: 'test-group', type: 'group' })];
+ bucketsService.getBucketPolicyTenants.mockReturnValue(of({ users,
userGroups }));
+
+ actions$ = of(PoliciesActions.loadPolicyTenants({ request: {
context: ErrorContextKey.MANAGE_ACCESS } }));
+
+ effects.loadPolicyTenants$.subscribe((action) => {
+ expect(action).toEqual(
+ PoliciesActions.loadPolicyTenantsSuccess({
+ response: { users, userGroups }
+ })
+ );
+ done();
+ });
+ });
+
+ it('should return error actions on failure', (done) => {
+ const error = new HttpErrorResponse({ status: 403, statusText:
'Forbidden' });
+
bucketsService.getBucketPolicyTenants.mockReturnValue(throwError(() => error));
+ errorHelper.getErrorString.mockReturnValue('Error loading
tenants');
+
+ actions$ = of(PoliciesActions.loadPolicyTenants({ request: {
context: ErrorContextKey.MANAGE_ACCESS } }));
+
+ effects.loadPolicyTenants$.subscribe((action) => {
+ if (action.type ===
PoliciesActions.loadPolicyTenantsFailure.type) {
+
expect(action).toEqual(PoliciesActions.loadPolicyTenantsFailure());
+ done();
+ }
+ });
+ });
+ });
+
+ describe('saveBucketPolicy$', () => {
+ it('should save policy and reload when isLastInBatch is true', (done)
=> {
+ const savedPolicy = createPolicy();
+ const allPolicies = [savedPolicy];
+ bucketsService.saveBucketPolicy.mockReturnValue(of(savedPolicy));
+ bucketsService.getPolicies.mockReturnValue(of(allPolicies));
+
+ actions$ = of(
+ PoliciesActions.saveBucketPolicy({
+ request: {
+ bucketId: 'bucket-1',
+ action: 'read',
+ users: [createPolicySubject()],
+ userGroups: [],
+ isLastInBatch: true
+ }
+ })
+ );
+
+ let actionCount = 0;
+ effects.saveBucketPolicy$.subscribe((action) => {
+ actionCount++;
+ if (action.type === PoliciesActions.loadPoliciesSuccess.type) {
+ expect(action).toEqual(
+ PoliciesActions.loadPoliciesSuccess({
+ response: {
+ bucketId: 'bucket-1',
+ policies: allPolicies
+ }
+ })
+ );
+ }
+ if (action.type ===
PoliciesActions.policyChangeSuccessToast.type) {
+ expect(action).toEqual(
+ PoliciesActions.policyChangeSuccessToast({
+ message: 'Bucket policies saved'
+ })
+ );
+ expect(actionCount).toBe(2);
+ done();
+ }
+ });
+ });
+
+ it('should save policy without reload when isLastInBatch is false',
(done) => {
+ const savedPolicy = createPolicy();
+ bucketsService.saveBucketPolicy.mockReturnValue(of(savedPolicy));
+
+ actions$ = of(
+ PoliciesActions.saveBucketPolicy({
+ request: {
+ bucketId: 'bucket-1',
+ action: 'read',
+ users: [createPolicySubject()],
+ userGroups: [],
+ isLastInBatch: false
+ }
+ })
+ );
+
+ effects.saveBucketPolicy$.subscribe((action) => {
+ expect(action).toEqual(PoliciesActions.policiesNoOp());
+ expect(bucketsService.getPolicies).not.toHaveBeenCalled();
+ done();
+ });
+ });
+
+ it('should return error actions on save failure', (done) => {
+ const error = new HttpErrorResponse({ status: 409, statusText:
'Conflict' });
+ bucketsService.saveBucketPolicy.mockReturnValue(throwError(() =>
error));
+ errorHelper.getErrorString.mockReturnValue('Error saving policy');
+
+ actions$ = of(
+ PoliciesActions.saveBucketPolicy({
+ request: {
+ bucketId: 'bucket-1',
+ action: 'read',
+ users: [],
+ userGroups: []
+ }
+ })
+ );
+
+ effects.saveBucketPolicy$.subscribe((action) => {
+ if (action.type ===
PoliciesActions.saveBucketPolicyFailure.type) {
+
expect(action).toEqual(PoliciesActions.saveBucketPolicyFailure());
+ done();
+ }
+ });
+ });
+ });
+
+ describe('policyChangeSuccessToast$', () => {
+ it('should open snackbar with message', (done) => {
+ actions$ = of(PoliciesActions.policyChangeSuccessToast({ message:
'Success!' }));
+
+ effects.policyChangeSuccessToast$.subscribe(() => {
+ expect(snackBar.open).toHaveBeenCalledWith('Success!',
'Dismiss', { duration: 3000 });
+ done();
+ });
+ });
+
+ it('should use default message if none provided', (done) => {
+ actions$ = of(PoliciesActions.policyChangeSuccessToast({ message:
'' }));
+
+ effects.policyChangeSuccessToast$.subscribe(() => {
+ expect(snackBar.open).toHaveBeenCalledWith('Policy updated',
'Dismiss', { duration: 3000 });
+ done();
+ });
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
new file mode 100644
index 0000000000..f679928e94
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.effects.ts
@@ -0,0 +1,146 @@
+/*
+ * 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 { inject, Injectable } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { from, of } from 'rxjs';
+import { catchError, map, switchMap, tap, mergeMap } from 'rxjs/operators';
+import * as PoliciesActions from './policies.actions';
+import { BucketsService } from '../../service/buckets.service';
+import { ErrorHelper } from '../../service/error-helper.service';
+import * as ErrorActions from '../error/error.actions';
+
+@Injectable()
+export class PoliciesEffects {
+ private bucketsService = inject(BucketsService);
+ private errorHelper = inject(ErrorHelper);
+ private actions$ = inject(Actions);
+ private snackBar = inject(MatSnackBar);
+
+ loadPolicies$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(PoliciesActions.loadPolicies),
+ switchMap(({ request }) =>
+ from(this.bucketsService.getPolicies()).pipe(
+ map((policies) =>
+ PoliciesActions.loadPoliciesSuccess({
+ response: {
+ bucketId: request.bucketId,
+ policies
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ PoliciesActions.loadPoliciesFailure(),
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors:
[this.errorHelper.getErrorString(errorResponse)],
+ context: request.context
+ }
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ loadPolicyTenants$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(PoliciesActions.loadPolicyTenants),
+ switchMap(({ request }) =>
+ from(this.bucketsService.getBucketPolicyTenants()).pipe(
+ map((response) =>
PoliciesActions.loadPolicyTenantsSuccess({ response })),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ PoliciesActions.loadPolicyTenantsFailure(),
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors:
[this.errorHelper.getErrorString(errorResponse)],
+ context: request.context
+ }
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ saveBucketPolicy$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(PoliciesActions.saveBucketPolicy),
+ mergeMap(({ request }) =>
+ from(
+ this.bucketsService.saveBucketPolicy({
+ bucketId: request.bucketId,
+ action: request.action,
+ policyId: request.policyId,
+ users: request.users,
+ userGroups: request.userGroups,
+ revision: request.revision
+ })
+ ).pipe(
+ mergeMap(() => {
+ // Only reload policies and show toast if this is the
last in a batch
+ if (request.isLastInBatch !== false) {
+ return
from(this.bucketsService.getPolicies()).pipe(
+ mergeMap((policies) =>
+ of(
+ PoliciesActions.loadPoliciesSuccess({
+ response: {
+ bucketId: request.bucketId,
+ policies
+ }
+ }),
+
PoliciesActions.policyChangeSuccessToast({
+ message: 'Bucket policies saved'
+ })
+ )
+ )
+ );
+ }
+ // For non-last saves, just complete without
dispatching success
+ return of(PoliciesActions.policiesNoOp());
+ }),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ PoliciesActions.saveBucketPolicyFailure(),
+ ErrorActions.snackBarError({ error:
this.errorHelper.getErrorString(errorResponse) })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ policyChangeSuccessToast$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(PoliciesActions.policyChangeSuccessToast),
+ tap(({ message }) => {
+ this.snackBar.open(message || 'Policy updated', 'Dismiss',
{
+ duration: 3000
+ });
+ })
+ ),
+ { dispatch: false }
+ );
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
new file mode 100644
index 0000000000..26fc451145
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.reducer.ts
@@ -0,0 +1,131 @@
+/*
+ * 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 { createReducer, on } from '@ngrx/store';
+import {
+ loadPolicies,
+ loadPoliciesFailure,
+ loadPoliciesSuccess,
+ loadPolicyTenants,
+ loadPolicyTenantsFailure,
+ loadPolicyTenantsSuccess,
+ saveBucketPolicy,
+ saveBucketPolicyFailure
+} from './policies.actions';
+import { PoliciesState } from '.';
+import { produce } from 'immer';
+
+export const initialState: PoliciesState = {
+ bucketId: null,
+ tenants: {
+ users: [],
+ userGroups: []
+ },
+ loadingPolicies: false,
+ loadingTenants: false,
+ saving: false,
+ error: null,
+ policySelection: {
+ read: undefined,
+ write: undefined,
+ delete: undefined
+ }
+};
+
+export const policiesReducer = createReducer(
+ initialState,
+ on(loadPolicies, (state) =>
+ produce(state, (draft) => {
+ draft.loadingPolicies = true;
+ draft.error = null;
+ })
+ ),
+ on(loadPoliciesSuccess, (state, { response }) =>
+ produce(state, (draft) => {
+ draft.bucketId = response.bucketId;
+ draft.loadingPolicies = false;
+ draft.saving = false; // Also clear saving state when policies
reload
+
+ // Filter policies to only those matching this bucket
+ const bucketResource = `/buckets/${response.bucketId}`;
+ const bucketPolicies = response.policies.filter((policy) =>
policy.resource === bucketResource);
+
+ // Extract read, write, delete policies
+ const readPolicy = bucketPolicies.find((p) => p.action === 'read');
+ const writePolicy = bucketPolicies.find((p) => p.action ===
'write');
+ const deletePolicy = bucketPolicies.find((p) => p.action ===
'delete');
+
+ draft.policySelection = {
+ read: readPolicy
+ ? {
+ policyId: readPolicy.identifier,
+ revision: readPolicy.revision,
+ users: readPolicy.users,
+ userGroups: readPolicy.userGroups
+ }
+ : undefined,
+ write: writePolicy
+ ? {
+ policyId: writePolicy.identifier,
+ revision: writePolicy.revision,
+ users: writePolicy.users,
+ userGroups: writePolicy.userGroups
+ }
+ : undefined,
+ delete: deletePolicy
+ ? {
+ policyId: deletePolicy.identifier,
+ revision: deletePolicy.revision,
+ users: deletePolicy.users,
+ userGroups: deletePolicy.userGroups
+ }
+ : undefined
+ };
+ })
+ ),
+ on(loadPoliciesFailure, (state) =>
+ produce(state, (draft) => {
+ draft.loadingPolicies = false;
+ })
+ ),
+ on(loadPolicyTenants, (state) =>
+ produce(state, (draft) => {
+ draft.loadingTenants = true;
+ })
+ ),
+ on(loadPolicyTenantsSuccess, (state, { response }) =>
+ produce(state, (draft) => {
+ draft.tenants = response;
+ draft.loadingTenants = false;
+ })
+ ),
+ on(loadPolicyTenantsFailure, (state) =>
+ produce(state, (draft) => {
+ draft.loadingTenants = false;
+ })
+ ),
+ on(saveBucketPolicy, (state) =>
+ produce(state, (draft) => {
+ draft.saving = true;
+ })
+ ),
+ on(saveBucketPolicyFailure, (state) =>
+ produce(state, (draft) => {
+ draft.saving = false;
+ })
+ )
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
new file mode 100644
index 0000000000..947dcbf9b5
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/policies/policies.selectors.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { createFeatureSelector, createSelector } from '@ngrx/store';
+import { policiesFeatureKey, BucketPolicyOptionsView, PoliciesState } from
'./index';
+import { PolicySubject } from '../../service/buckets.service';
+import { resourcesFeatureKey, ResourcesState } from '..';
+
+export const selectResourcesState =
createFeatureSelector<ResourcesState>(resourcesFeatureKey);
+
+export const selectPoliciesState = createSelector(selectResourcesState,
(state) => state[policiesFeatureKey]);
+
+export const selectPolicyTenants = createSelector(selectPoliciesState, (state:
PoliciesState) => state.tenants);
+
+export const selectPolicyOptions = createSelector(
+ selectPolicyTenants,
+ ({ users, userGroups }): BucketPolicyOptionsView => {
+ const sortedGroups = [...userGroups].sort((a, b) =>
a.identity.localeCompare(b.identity));
+ const sortedUsers = [...users].sort((a, b) =>
a.identity.localeCompare(b.identity));
+
+ const groups = sortedGroups.map((group) => ({
+ key: `group-${group.identifier}`,
+ label: group.identity,
+ type: 'group' as const,
+ subject: group
+ }));
+
+ const userOptions = sortedUsers.map((user) => ({
+ key: `user-${user.identifier}`,
+ label: user.identity,
+ type: 'user' as const,
+ subject: user
+ }));
+
+ const lookup: Record<string, PolicySubject> = {};
+ [...groups, ...userOptions].forEach((option) => {
+ lookup[option.subject.identifier] = option.subject;
+ });
+
+ return {
+ groups,
+ users: userOptions,
+ all: [...groups, ...userOptions],
+ lookup
+ };
+ }
+);
+
+export const selectPolicySelection = createSelector(
+ selectPoliciesState,
+ (state: PoliciesState) => state.policySelection
+);
+
+export const selectPoliciesLoading = createSelector(
+ selectPoliciesState,
+ (state: PoliciesState) => state.loadingPolicies || state.loadingTenants
+);
+
+export const selectPoliciesSaving = createSelector(selectPoliciesState,
(state: PoliciesState) => state.saving);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
index 3bc32df12d..3dac6537f2 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/documentation/ui/flow-registry-client-definition/flow-registry-client-definition.component.html
@@ -22,7 +22,9 @@
@if (flowRegistryClientDefinitionState.flowRegistryClientDefinition;
as flowRegistryClientDefinition) {
<div class="flex flex-col gap-y-4 p-4">
<configurable-extension-definition
-
[configurableExtensionDefinition]="flowRegistryClientDefinition"></configurable-extension-definition>
+ [configurableExtensionDefinition]="
+ flowRegistryClientDefinition
+ "></configurable-extension-definition>
</div>
} @else if (flowRegistryClientDefinitionState.error) {
<div class="p-4">
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
index 6d38b44c7d..f42b94314f 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/local-changes-dialog/local-changes-table/local-changes-table.ts
@@ -125,7 +125,7 @@ export class LocalChangesTable implements AfterViewInit {
}
canGoTo(item: LocalChange): boolean {
- return (item.differenceType !== 'Component Removed') &&
(item.differenceType !== 'Component Bundle Changed');
+ return item.differenceType !== 'Component Removed' &&
item.differenceType !== 'Component Bundle Changed';
}
formatDifference(item: LocalChange): string {