From 859b40c8c7f425ea9bb7761fa3bb8cecf4785644 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Fri, 7 Sep 2018 11:14:38 +0200 Subject: [PATCH] mgr/dashboard: Increase usability of role management by enabling the user to check/uncheck all rows/columns of permissions Fixes: https://tracker.ceph.com/issues/35695 Signed-off-by: Volker Theile --- .../role-details/role-details.component.html | 37 +--- .../role-details.component.spec.ts | 47 ++++ .../role-details/role-details.component.ts | 62 +++++- .../auth/role-form/role-form.component.html | 82 ++++--- .../auth/role-form/role-form.component.scss | 7 +- .../role-form/role-form.component.spec.ts | 64 +++++- .../auth/role-form/role-form.component.ts | 203 ++++++++++++++++-- 7 files changed, 410 insertions(+), 92 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html index a91ff0061be..7389a48f830 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html @@ -1,32 +1,13 @@ - - - - - - - - - - - - - - - - -
ReadCreateUpdateDelete
- {{ scope }} - - - - - - - -
+ +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts index f0234b33b47..3d34a3af5b2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts @@ -5,6 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ToastModule } from 'ng2-toastr'; import { TabsModule } from 'ngx-bootstrap'; +import { CdTableSelection } from '../../../shared/models/cd-table-selection'; import { SharedModule } from '../../../shared/shared.module'; import { RoleDetailsComponent } from './role-details.component'; @@ -34,4 +35,50 @@ describe('RoleDetailsComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should create scopes permissions [1/2]', () => { + component.scopes = ['log', 'rgw']; + component.selection = new CdTableSelection(); + component.selection.selected = [ + { + description: 'RGW Manager', + name: 'rgw-manager', + scopes_permissions: { + rgw: ['read', 'create', 'update', 'delete'] + }, + system: true + } + ]; + component.selection.update(); + expect(component.scopes_permissions.length).toBe(0); + component.ngOnChanges(); + expect(component.scopes_permissions).toEqual([ + { scope: 'log', read: false, create: false, update: false, delete: false }, + { scope: 'rgw', read: true, create: true, update: true, delete: true } + ]); + }); + + it('should create scopes permissions [2/2]', () => { + component.scopes = ['cephfs', 'log', 'rgw']; + component.selection = new CdTableSelection(); + component.selection.selected = [ + { + description: 'Test', + name: 'test', + scopes_permissions: { + log: ['read', 'update'], + rgw: ['read', 'create', 'update'] + }, + system: false + } + ]; + component.selection.update(); + expect(component.scopes_permissions.length).toBe(0); + component.ngOnChanges(); + expect(component.scopes_permissions).toEqual([ + { scope: 'cephfs', read: false, create: false, update: false, delete: false }, + { scope: 'log', read: true, create: false, update: true, delete: false }, + { scope: 'rgw', read: true, create: true, update: true, delete: false } + ]); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts index 5f5b27ce072..a81f4cbda1c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts @@ -1,5 +1,9 @@ -import { Component, Input, OnChanges } from '@angular/core'; +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import * as _ from 'lodash'; + +import { CellTemplate } from '../../../shared/enum/cell-template.enum'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; import { CdTableSelection } from '../../../shared/models/cd-table-selection'; @Component({ @@ -7,18 +11,72 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection'; templateUrl: './role-details.component.html', styleUrls: ['./role-details.component.scss'] }) -export class RoleDetailsComponent implements OnChanges { +export class RoleDetailsComponent implements OnChanges, OnInit { @Input() selection: CdTableSelection; @Input() scopes: Array; selectedItem: any; + columns: CdTableColumn[]; + scopes_permissions: Array = []; + constructor() {} + ngOnInit() { + this.columns = [ + { + prop: 'scope', + name: 'Scope', + flexGrow: 2 + }, + { + prop: 'read', + name: 'Read', + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'create', + name: 'Create', + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'update', + name: 'Update', + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'delete', + name: 'Delete', + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + } + ]; + } + ngOnChanges() { if (this.selection.hasSelection) { this.selectedItem = this.selection.first(); + // Build the scopes/permissions data used by the data table. + const scopes_permissions = []; + _.each(this.scopes, (scope) => { + const scope_permission = { read: false, create: false, update: false, delete: false }; + scope_permission['scope'] = scope; + if (scope in this.selectedItem['scopes_permissions']) { + _.each(this.selectedItem['scopes_permissions'][scope], (permission) => { + scope_permission[permission] = true; + }); + } + scopes_permissions.push(scope_permission); + }); + this.scopes_permissions = scopes_permissions; } } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html index 2366aec3237..a35a361d78b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html @@ -48,7 +48,7 @@ [ngClass]="{'has-error': roleForm.showError('description', formDir)}">
- - - - - - - - - - - - - - - - -
ReadCreateUpdateDelete
- {{ scope }} - -
- - -
-
+ +
@@ -110,7 +90,7 @@
@@ -118,3 +98,41 @@ + + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss index 564487d0e76..3caafa2ee62 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss @@ -1,5 +1,4 @@ -@import '../../../../defaults'; - -thead { - background-color: $color-table-header-bg; +.datatable-permissions-header-cell-label, +.datatable-permissions-scope-cell-label { + font-weight: bold; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts index 6da5dee4361..e5d1c039998 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts @@ -101,6 +101,63 @@ describe('RoleFormComponent', () => { roleReq.flush({}); expect(router.navigate).toHaveBeenCalledWith(['/user-management/roles']); }); + + it('should check all perms for a scope', () => { + form.get('scopes_permissions').setValue({ cephfs: ['read'] }); + component.onClickCellCheckbox('grafana', 'scope'); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(Object.keys(scopes_permissions)).toContain('grafana'); + expect(scopes_permissions['grafana']).toEqual(['create', 'delete', 'read', 'update']); + }); + + it('should uncheck all perms for a scope', () => { + form.get('scopes_permissions').setValue({ cephfs: ['read', 'create', 'update', 'delete'] }); + component.onClickCellCheckbox('cephfs', 'scope'); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(Object.keys(scopes_permissions)).not.toContain('cephfs'); + }); + + it('should uncheck all scopes and perms', () => { + component.scopes = ['cephfs', 'grafana']; + form.get('scopes_permissions').setValue({ cephfs: ['read', 'delete'], grafana: ['update'] }); + component.onClickHeaderCheckbox('scope', { target: { checked: false } }); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(scopes_permissions).toEqual({}); + }); + + it('should check all scopes and perms', () => { + component.scopes = ['cephfs', 'grafana']; + form + .get('scopes_permissions') + .setValue({ cephfs: ['create', 'update'], grafana: ['delete'] }); + component.onClickHeaderCheckbox('scope', { target: { checked: true } }); + const scopes_permissions = form.getValue('scopes_permissions'); + const keys = Object.keys(scopes_permissions); + expect(keys).toEqual(['cephfs', 'grafana']); + keys.forEach((key) => { + expect(scopes_permissions[key].sort()).toEqual(['create', 'delete', 'read', 'update']); + }); + }); + + it('should check if column is checked', () => { + component.scopes_permissions = [ + { scope: 'a', read: true, create: true, update: true, delete: true }, + { scope: 'b', read: false, create: true, update: false, delete: true } + ]; + expect(component.isRowChecked('a')).toBeTruthy(); + expect(component.isRowChecked('b')).toBeFalsy(); + expect(component.isRowChecked('c')).toBeFalsy(); + }); + + it('should check if header is checked', () => { + component.scopes_permissions = [ + { scope: 'a', read: true, create: true, update: false, delete: true }, + { scope: 'b', read: false, create: true, update: false, delete: true } + ]; + expect(component.isHeaderChecked('read')).toBeFalsy(); + expect(component.isHeaderChecked('create')).toBeTruthy(); + expect(component.isHeaderChecked('update')).toBeFalsy(); + }); }); describe('edit mode', () => { @@ -117,7 +174,6 @@ describe('RoleFormComponent', () => { component.ngOnInit(); const reqScopes = httpTesting.expectOne('ui-api/scope'); expect(reqScopes.request.method).toBe('GET'); - reqScopes.flush(scopes); }); afterEach(() => { @@ -142,10 +198,10 @@ describe('RoleFormComponent', () => { }); it('should submit', () => { + component.onClickCellCheckbox('osd', 'update'); + component.onClickCellCheckbox('osd', 'create'); + component.onClickCellCheckbox('user', 'read'); component.submit(); - component.hadlePermissionClick('osd', 'update'); - component.hadlePermissionClick('osd', 'create'); - component.hadlePermissionClick('user', 'read'); const roleReq = httpTesting.expectOne(`api/role/${role.name}`); expect(roleReq.request.method).toBe('PUT'); expect(roleReq.request.body).toEqual({ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts index a3e544f8b78..a25502bdef1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts @@ -1,14 +1,17 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; +import * as _ from 'lodash'; import { BsModalRef } from 'ngx-bootstrap'; +import { forkJoin as observableForkJoin } from 'rxjs'; import { RoleService } from '../../../shared/api/role.service'; import { ScopeService } from '../../../shared/api/scope.service'; import { NotificationType } from '../../../shared/enum/notification-type.enum'; import { CdFormGroup } from '../../../shared/forms/cd-form-group'; import { CdValidators } from '../../../shared/forms/cd-validators'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; import { NotificationService } from '../../../shared/services/notification.service'; import { RoleFormMode } from './role-form-mode.enum'; import { RoleFormModel } from './role-form.model'; @@ -19,11 +22,21 @@ import { RoleFormModel } from './role-form.model'; styleUrls: ['./role-form.component.scss'] }) export class RoleFormComponent implements OnInit { + @ViewChild('headerPermissionCheckboxTpl') + headerPermissionCheckboxTpl: TemplateRef; + @ViewChild('cellScopeCheckboxTpl') + cellScopeCheckboxTpl: TemplateRef; + @ViewChild('cellPermissionCheckboxTpl') + cellPermissionCheckboxTpl: TemplateRef; + modalRef: BsModalRef; roleForm: CdFormGroup; response: RoleFormModel; - scopes: Array; + + columns: CdTableColumn[]; + scopes: Array = []; + scopes_permissions: Array = []; roleFormMode = RoleFormMode; mode: RoleFormMode; @@ -36,6 +49,7 @@ export class RoleFormComponent implements OnInit { private notificationService: NotificationService ) { this.createForm(); + this.listenToChanges(); } createForm() { @@ -50,48 +64,193 @@ export class RoleFormComponent implements OnInit { } ngOnInit() { + this.columns = [ + { + prop: 'scope', + name: 'All', + flexGrow: 2, + cellTemplate: this.cellScopeCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'read', + name: 'Read', + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'create', + name: 'Create', + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'update', + name: 'Update', + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'delete', + name: 'Delete', + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + } + ]; if (this.router.url.startsWith('/user-management/roles/edit')) { this.mode = this.roleFormMode.editing; } - this.scopeService.list().subscribe((scopes: Array) => { - this.scopes = scopes; - }); if (this.mode === this.roleFormMode.editing) { this.initEdit(); + } else { + this.initCreate(); } } + initCreate() { + // Load the scopes and initialize the default scopes/permissions data. + this.scopeService.list().subscribe((scopes: Array) => { + this.scopes = scopes; + this.roleForm.get('scopes_permissions').setValue({}); + }); + } + initEdit() { - this.disableForEdit(); + // Disable the 'Name' input field. + this.roleForm.get('name').disable(); + // Load the scopes and the role data. this.route.params.subscribe((params: { name: string }) => { - const name = params.name; - this.roleService.get(name).subscribe((roleFormModel: RoleFormModel) => { - this.setResponse(roleFormModel); + const observables = []; + observables.push(this.scopeService.list()); + observables.push(this.roleService.get(params.name)); + observableForkJoin(observables).subscribe((resp: any[]) => { + this.scopes = resp[0]; + ['name', 'description', 'scopes_permissions'].forEach((key) => + this.roleForm.get(key).setValue(resp[1][key]) + ); }); }); } - disableForEdit() { - this.roleForm.get('name').disable(); + listenToChanges() { + // Create/Update the data which is used by the data table to display the + // scopes/permissions every time the form field value has been changed. + this.roleForm.get('scopes_permissions').valueChanges.subscribe((value) => { + const scopes_permissions = []; + _.each(this.scopes, (scope) => { + // Set the defaults values. + const scope_permission = { read: false, create: false, update: false, delete: false }; + scope_permission['scope'] = scope; + // Apply settings from the given value if they exist. + if (scope in value) { + _.each(value[scope], (permission) => { + scope_permission[permission] = true; + }); + } + scopes_permissions.push(scope_permission); + }); + this.scopes_permissions = scopes_permissions; + }); } - setResponse(response: RoleFormModel) { - ['name', 'description', 'scopes_permissions'].forEach((key) => - this.roleForm.get(key).setValue(response[key]) + /** + * Checks if the specified row checkbox needs to be rendered as checked. + * @param {string} scope The scope to be checked, e.g. 'cephfs', 'grafana', + * 'osd', 'pool' ... + * @return Returns true if all permissions (read, create, update, delete) + * are checked for the specified scope, otherwise false. + */ + isRowChecked(scope: string) { + const scope_permission = _.find(this.scopes_permissions, (o) => { + return o['scope'] === scope; + }); + if (_.isUndefined(scope_permission)) { + return false; + } + return ( + scope_permission['read'] && + scope_permission['create'] && + scope_permission['update'] && + scope_permission['delete'] ); } - hadlePermissionClick(scope: string, permission: string) { - const permissions = this.roleForm.getValue('scopes_permissions'); - if (!permissions[scope]) { - permissions[scope] = []; + /** + * Checks if the specified header checkbox needs to be rendered as checked. + * @param {string} property The property/permission (read, create, + * update, delete) to be checked. If 'scope' is given, all permissions + * are checked. + * @return Returns true if specified property/permission is selected + * for all scopes, otherwise false. + */ + isHeaderChecked(property: string) { + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; } - const index = permissions[scope].indexOf(permission); - if (index === -1) { - permissions[scope].push(permission); + return permissions.every((permission) => { + return this.scopes_permissions.every((scope_permission) => { + return scope_permission[permission]; + }); + }); + } + + onClickCellCheckbox(scope: string, property: string, event: Event = null) { + // Use a copy of the form field data to do not trigger the redrawing of the + // data table with every change. + const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions')); + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; + } + if (!(scope in scopes_permissions)) { + scopes_permissions[scope] = []; + } + // Add or remove the given permission(s) depending on the click event or if no + // click event is given then add/remove them if they are absent/exist. + if ( + (event && event.target['checked']) || + !_.isEqual(permissions.sort(), _.intersection(scopes_permissions[scope], permissions).sort()) + ) { + scopes_permissions[scope] = _.union(scopes_permissions[scope], permissions); } else { - permissions[scope].splice(index, 1); + scopes_permissions[scope] = _.difference(scopes_permissions[scope], permissions); + if (_.isEmpty(scopes_permissions[scope])) { + _.unset(scopes_permissions, scope); + } + } + this.roleForm.get('scopes_permissions').setValue(scopes_permissions); + } + + onClickHeaderCheckbox(property: 'scope' | 'read' | 'create' | 'update' | 'delete', event: Event) { + // Use a copy of the form field data to do not trigger the redrawing of the + // data table with every change. + const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions')); + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; } + _.each(permissions, (permission) => { + _.each(this.scopes, (scope) => { + if (event.target['checked']) { + scopes_permissions[scope] = _.union(scopes_permissions[scope], [permission]); + } else { + scopes_permissions[scope] = _.difference(scopes_permissions[scope], [permission]); + if (_.isEmpty(scopes_permissions[scope])) { + _.unset(scopes_permissions, scope); + } + } + }); + }); + this.roleForm.get('scopes_permissions').setValue(scopes_permissions); } getRequest(): RoleFormModel { -- 2.39.5