]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Not able to restrict bucket creation for new user 34692/head
authorVolker Theile <vtheile@suse.com>
Thu, 27 Feb 2020 14:18:39 +0000 (15:18 +0100)
committerVolker Theile <vtheile@suse.com>
Thu, 23 Apr 2020 11:36:28 +0000 (13:36 +0200)
Hide the different meanings of max_buckets from the user by improving the UI.

Fixes: https://tracker.ceph.com/issues/44322
Signed-off-by: Volker Theile <vtheile@suse.com>
(cherry picked from commit ba9047eded7ce56c7d97e1911060cffbee1b99f0)

Conflicts:
    Removed the following files because the corresponding e2e test does not exist in Nautilus.
src/pybind/mgr/dashboard/frontend/e2e/page-helper.po.ts
src/pybind/mgr/dashboard/frontend/e2e/rgw/users.po.ts

src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts

Modified:
    Need to enhance the model because this change was not included in the origin PR.
    src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts

14 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts

index 697cf62a0d7312f7903140e61c97d3d2841b7b8e..7ae1c0884c0323d388f8c21a3864311e8664da82 100644 (file)
@@ -32,7 +32,7 @@
           <tr>
             <td i18n
                 class="bold col-sm-1">Maximum buckets</td>
-            <td class="col-sm-3">{{ user.max_buckets }}</td>
+            <td class="col-sm-3">{{ user.max_buckets| map:maxBucketsMap }}</td>
           </tr>
           <tr *ngIf="user.subusers && user.subusers.length">
             <td i18n
index a55dd13d65829deacadbbb5c914fd820faf577a8..b7ff61195ceed3d99488fbfdf4a56eeb1b5b9121 100644 (file)
@@ -28,6 +28,7 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
 
   // Details tab
   user: any;
+  maxBucketsMap: {};
 
   // Keys tab
   keys: any = [];
@@ -53,6 +54,10 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
         flexGrow: 1
       }
     ];
+    this.maxBucketsMap = {
+      '-1': this.i18n('Disabled'),
+      0: this.i18n('Unlimited')
+    };
   }
 
   ngOnChanges() {
index 8feb271497d367bdce39d3b302500247b33cbb08..75abab51f3d07837ca5c15ef7a838c05d72d0165 100644 (file)
         </div>
 
         <!-- Max. buckets -->
-        <div class="form-group"
-             [ngClass]="{'has-error': userForm.showError('max_buckets', frm)}">
+        <div class="form-group">
           <label class="control-label col-sm-3"
-                 for="max_buckets">
-            <ng-container i18n>Max. buckets</ng-container>
-            <span class="required"></span>
-          </label>
+                 for="max_buckets_mode"
+                 i18n>Max. buckets</label>
+          <div class="col-sm-9">
+            <select class="form-control"
+                    formControlName="max_buckets_mode"
+                    name="max_buckets_mode"
+                    id="max_buckets_mode"
+                    (change)="onMaxBucketsModeChange($event.target.value)">
+              <option i18n
+                      value="-1">Disabled</option>
+              <option i18n
+                      value="0">Unlimited</option>
+              <option i18n
+                      value="1">Custom</option>
+            </select>
+          </div>
+        </div>
+        <div *ngIf="1 == userForm.get('max_buckets_mode').value"
+             class="form-group"
+             [ngClass]="{'has-error': userForm.showError('max_buckets', frm)}">
+          <label class="control-label col-sm-3"></label>
           <div class="col-sm-9">
             <input id="max_buckets"
                    class="form-control"
                    type="number"
-                   formControlName="max_buckets">
+                   formControlName="max_buckets"
+                   min="1">
             <span class="help-block"
                   *ngIf="userForm.showError('max_buckets', frm, 'required')"
                   i18n>This field is required.</span>
             <span class="help-block"
                   *ngIf="userForm.showError('max_buckets', frm, 'min')"
-                  i18n>The entered value must be >= 0.</span>
+                  i18n>The entered value must be >= 1.</span>
           </div>
         </div>
 
index e73319c4ec0367cd821a7c499f0cbd3fb278e166..85632651ff4d59d8230791328addde42cc0ccb97 100644 (file)
@@ -176,6 +176,97 @@ describe('RgwUserFormComponent', () => {
     }));
   });
 
+  describe('max buckets', () => {
+    it('disable creation (create)', () => {
+      spyOn(rgwUserService, 'create');
+      formHelper.setValue('max_buckets_mode', -1, true);
+      component.onSubmit();
+      expect(rgwUserService.create).toHaveBeenCalledWith({
+        access_key: '',
+        display_name: null,
+        email: '',
+        generate_key: true,
+        max_buckets: -1,
+        secret_key: '',
+        suspended: false,
+        uid: null
+      });
+    });
+
+    it('disable creation (edit)', () => {
+      spyOn(rgwUserService, 'update');
+      component.editing = true;
+      formHelper.setValue('max_buckets_mode', -1, true);
+      component.onSubmit();
+      expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+        display_name: null,
+        email: null,
+        max_buckets: -1,
+        suspended: false
+      });
+    });
+
+    it('unlimited buckets (create)', () => {
+      spyOn(rgwUserService, 'create');
+      formHelper.setValue('max_buckets_mode', 0, true);
+      component.onSubmit();
+      expect(rgwUserService.create).toHaveBeenCalledWith({
+        access_key: '',
+        display_name: null,
+        email: '',
+        generate_key: true,
+        max_buckets: 0,
+        secret_key: '',
+        suspended: false,
+        uid: null
+      });
+    });
+
+    it('unlimited buckets (edit)', () => {
+      spyOn(rgwUserService, 'update');
+      component.editing = true;
+      formHelper.setValue('max_buckets_mode', 0, true);
+      component.onSubmit();
+      expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+        display_name: null,
+        email: null,
+        max_buckets: 0,
+        suspended: false
+      });
+    });
+
+    it('custom (create)', () => {
+      spyOn(rgwUserService, 'create');
+      formHelper.setValue('max_buckets_mode', 1, true);
+      formHelper.setValue('max_buckets', 100, true);
+      component.onSubmit();
+      expect(rgwUserService.create).toHaveBeenCalledWith({
+        access_key: '',
+        display_name: null,
+        email: '',
+        generate_key: true,
+        max_buckets: 100,
+        secret_key: '',
+        suspended: false,
+        uid: null
+      });
+    });
+
+    it('custom (edit)', () => {
+      spyOn(rgwUserService, 'update');
+      component.editing = true;
+      formHelper.setValue('max_buckets_mode', 1, true);
+      formHelper.setValue('max_buckets', 100, true);
+      component.onSubmit();
+      expect(rgwUserService.update).toHaveBeenCalledWith(null, {
+        display_name: null,
+        email: null,
+        max_buckets: 100,
+        suspended: false
+      });
+    });
+  });
+
   describe('submit form', () => {
     let notificationService: NotificationService;
 
index aa22c55cc5bec2d45399c684fb01c628df83ae57..cd58b593fc40f99728d358a7a79928ead2612237 100644 (file)
@@ -79,7 +79,15 @@ export class RgwUserFormComponent implements OnInit {
         [CdValidators.email],
         [CdValidators.unique(this.rgwUserService.emailExists, this.rgwUserService)]
       ],
-      max_buckets: [1000, [Validators.required, Validators.min(0)]],
+      max_buckets_mode: ['1'],
+      max_buckets: [
+        1000,
+        [
+          CdValidators.requiredIf({ max_buckets_mode: '1' }),
+          CdValidators.number(false),
+          Validators.min(1)
+        ]
+      ],
       suspended: [false],
       // S3 key
       generate_key: [true],
@@ -157,6 +165,20 @@ export class RgwUserFormComponent implements OnInit {
           const defaults = _.clone(this.userForm.value);
           // Extract the values displayed in the form.
           let value = _.pick(resp[0], _.keys(this.userForm.value));
+          // Map the max. buckets values.
+          switch (value['max_buckets']) {
+            case -1:
+              value['max_buckets_mode'] = '-1';
+              value['max_buckets'] = '';
+              break;
+            case 0:
+              value['max_buckets_mode'] = '0';
+              value['max_buckets'] = '';
+              break;
+            default:
+              value['max_buckets_mode'] = '1';
+              break;
+          }
           // Map the quota values.
           ['user', 'bucket'].forEach((type) => {
             const quota = resp[1][type + '_quota'];
@@ -515,9 +537,11 @@ export class RgwUserFormComponent implements OnInit {
    * @return {Boolean} Returns TRUE if the general user settings have been modified.
    */
   private _isGeneralDirty(): boolean {
-    return ['display_name', 'email', 'max_buckets', 'suspended'].some((path) => {
-      return this.userForm.get(path).dirty;
-    });
+    return ['display_name', 'email', 'max_buckets_mode', 'max_buckets', 'suspended'].some(
+      (path) => {
+        return this.userForm.get(path).dirty;
+      }
+    );
   }
 
   /**
@@ -579,6 +603,12 @@ export class RgwUserFormComponent implements OnInit {
         secret_key: this.userForm.getValue('secret_key')
       });
     }
+    const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
+    if (_.includes([-1, 0], maxBucketsMode)) {
+      // -1 => Disable bucket creation.
+      //  0 => Unlimited bucket creation.
+      _.merge(result, { max_buckets: maxBucketsMode });
+    }
     return result;
   }
 
@@ -587,11 +617,17 @@ export class RgwUserFormComponent implements OnInit {
    * configuration has been modified.
    */
   private _getUpdateArgs() {
-    const result = {};
+    const result: Record<string, any> = {};
     const keys = ['display_name', 'email', 'max_buckets', 'suspended'];
     for (const key of keys) {
       result[key] = this.userForm.getValue(key);
     }
+    const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
+    if (_.includes([-1, 0], maxBucketsMode)) {
+      // -1 => Disable bucket creation.
+      //  0 => Unlimited bucket creation.
+      result['max_buckets'] = maxBucketsMode;
+    }
     return result;
   }
 
@@ -664,4 +700,14 @@ export class RgwUserFormComponent implements OnInit {
     result = _.uniq(result);
     return result;
   }
+
+  onMaxBucketsModeChange(mode: string) {
+    if (mode === '1') {
+      // If 'Custom' mode is selected, then ensure that the form field
+      // 'Max. buckets' contains a valid value. Set it to default if
+      // necessary.
+      const maxBuckets = this.userForm.getValue('max_buckets');
+      this.userForm.patchValue({ max_buckets: _.isEmpty(maxBuckets) ? 1000 : maxBuckets });
+    }
+  }
 }
index eb3b98d5de69fb94c2ed4da11b56ec2e0d92ca76..6808fc78f8e9ce5b9b48bd9e6c26853483d51023 100644 (file)
@@ -70,7 +70,12 @@ export class RgwUserListComponent {
       {
         name: this.i18n('Max. buckets'),
         prop: 'max_buckets',
-        flexGrow: 1
+        flexGrow: 1,
+        cellTransformation: CellTemplate.map,
+        customTemplateConfig: {
+          '-1': this.i18n('Disabled'),
+          0: this.i18n('Unlimited')
+        }
       }
     ];
     const getUserUri = () =>
index 25891d2649af94073c7738586b177962abdd5925..6b0ebab77ace1392097382c1e8594144740d2f67 100644 (file)
@@ -56,7 +56,7 @@ export class RgwUserService {
     return this.http.get(`${this.url}/${uid}/quota`);
   }
 
-  create(args: object) {
+  create(args: Record<string, any>) {
     let params = new HttpParams();
     _.keys(args).forEach((key) => {
       params = params.append(key, args[key]);
@@ -64,7 +64,7 @@ export class RgwUserService {
     return this.http.post(this.url, null, { params: params });
   }
 
-  update(uid: string, args: object) {
+  update(uid: string, args: Record<string, any>) {
     let params = new HttpParams();
     _.keys(args).forEach((key) => {
       params = params.append(key, args[key]);
index 88c72b34a3f25b479071c5c2f8ae6ec3a41b8ed1..077bab8d1d872b9235b704f1d7edbdb8c3b19142 100644 (file)
              let-value="value">
   <span class="{{useCustomClass(value)}}">{{ value }}</span>
 </ng-template>
+
+<ng-template #mapTpl
+             let-column="column"
+             let-value="value">
+  <span>{{ value | map:column?.customTemplateConfig }}</span>
+</ng-template>
index fa29c91094a36831b741d87c901a4c7813e61d9c..aaf846fbd4dde9ec0a450d6203c8adc99cc0f1f8 100644 (file)
@@ -52,6 +52,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   executingTpl: TemplateRef<any>;
   @ViewChild('classAddingTpl')
   classAddingTpl: TemplateRef<any>;
+  @ViewChild('mapTpl')
+  mapTpl: TemplateRef<any>;
 
   // This is the array with the items to be shown.
   @Input()
@@ -335,6 +337,7 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
     this.cellTemplates.perSecond = this.perSecondTpl;
     this.cellTemplates.executing = this.executingTpl;
     this.cellTemplates.classAdding = this.classAddingTpl;
+    this.cellTemplates.map = this.mapTpl;
   }
 
   useCustomClass(value: any): string {
index cf3e11a7ecfb9db7cdb20bcb4c4c3e7b2b678fe1..9b5a1bfad549d0a08619bd4c89fa86e181efb180 100644 (file)
@@ -5,5 +5,14 @@ export enum CellTemplate {
   checkIcon = 'checkIcon',
   routerLink = 'routerLink',
   executing = 'executing',
-  classAdding = 'classAdding'
+  classAdding = 'classAdding',
+  // Maps the value using the given dictionary.
+  // {
+  //   ...
+  //   cellTransformation: CellTemplate.map,
+  //   customTemplateConfig: {
+  //     [key: any]: any
+  //   }
+  // }
+  map = 'map'
 }
index 69194e8b88d77f49fd386f5823fd682cc630db12..64cd7db402ef6d44a1283ee9940f0cba553d1c71 100644 (file)
@@ -6,4 +6,5 @@ export interface CdTableColumn extends TableColumn {
   cellTransformation?: CellTemplate;
   isHidden?: boolean;
   prop: TableColumnProp; // Enforces properties to get sortable columns
+  customTemplateConfig?: any; // Custom configuration used by cell templates.
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts
new file mode 100644 (file)
index 0000000..337d5c3
--- /dev/null
@@ -0,0 +1,25 @@
+import { MapPipe } from './map.pipe';
+
+describe('MapPipe', () => {
+  const pipe = new MapPipe();
+
+  it('create an instance', () => {
+    expect(pipe).toBeTruthy();
+  });
+
+  it('map value [1]', () => {
+    expect(pipe.transform('foo')).toBe('foo');
+  });
+
+  it('map value [2]', () => {
+    expect(pipe.transform('foo', { '-1': 'disabled', 0: 'unlimited' })).toBe('foo');
+  });
+
+  it('map value [3]', () => {
+    expect(pipe.transform(-1, { '-1': 'disabled', 0: 'unlimited' })).toBe('disabled');
+  });
+
+  it('map value [4]', () => {
+    expect(pipe.transform(0, { '-1': 'disabled', 0: 'unlimited' })).toBe('unlimited');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts
new file mode 100644 (file)
index 0000000..9242bb4
--- /dev/null
@@ -0,0 +1,15 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import * as _ from 'lodash';
+
+@Pipe({
+  name: 'map'
+})
+export class MapPipe implements PipeTransform {
+  transform(value: string | number, map?: object): any {
+    if (!_.isPlainObject(map)) {
+      return value;
+    }
+    return _.get(map, value, value);
+  }
+}
index 6e96dd1f30a296b2f3c8c83ae6221c8a1a284a2e..2076cc801a84c6eac406a64661d08241ade0faa0 100644 (file)
@@ -17,6 +17,7 @@ import { IopsPipe } from './iops.pipe';
 import { IscsiBackstorePipe } from './iscsi-backstore.pipe';
 import { ListPipe } from './list.pipe';
 import { LogPriorityPipe } from './log-priority.pipe';
+import { MapPipe } from './map.pipe';
 import { MillisecondsPipe } from './milliseconds.pipe';
 import { NotAvailablePipe } from './not-available.pipe';
 import { OrdinalPipe } from './ordinal.pipe';
@@ -50,7 +51,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     UpperFirstPipe,
     RbdConfigurationSourcePipe,
-    DurationPipe
+    DurationPipe,
+    MapPipe
   ],
   exports: [
     BooleanTextPipe,
@@ -75,7 +77,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     UpperFirstPipe,
     RbdConfigurationSourcePipe,
-    DurationPipe
+    DurationPipe,
+    MapPipe
   ],
   providers: [
     BooleanTextPipe,
@@ -96,7 +99,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     MillisecondsPipe,
     NotAvailablePipe,
-    UpperFirstPipe
+    UpperFirstPipe,
+    MapPipe
   ]
 })
 export class PipesModule {}