]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Not able to restrict bucket creation for new user 33612/head
authorVolker Theile <vtheile@suse.com>
Thu, 27 Feb 2020 14:18:39 +0000 (15:18 +0100)
committerVolker Theile <vtheile@suse.com>
Mon, 2 Mar 2020 12:39:35 +0000 (13:39 +0100)
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>
15 files changed:
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-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/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 0a33340a431e8c33cc3489e47e45d1e193150e0f..55b84eaed49e15aa9d5fb48874c8fac71080416f 100644 (file)
@@ -154,6 +154,7 @@ export abstract class PageHelper {
   /**
    * Helper method to select an option inside a select element.
    * This method will also expect that the option was set.
+   * @param option The option text (not value) to be selected.
    */
   async selectOption(selectionName: string, option: string) {
     await element(by.cssContainingText(`select[name=${selectionName}] option`, option)).click();
@@ -162,6 +163,8 @@ export abstract class PageHelper {
 
   /**
    * Helper method to expect a set option inside a select element.
+   * @param option The selected option text (not value) that is to
+   *   be expected.
    */
   async expectSelectOption(selectionName: string, option: string) {
     return expect(
index 96f9c537a2fe5d5768d7d5338431efe9719b2464..200d60fe5f90ff582177fbb1de0f50ab16fcc6d8 100644 (file)
@@ -24,6 +24,7 @@ export class UsersPageHelper extends PageHelper {
     await element(by.id('email')).sendKeys(email);
 
     // Enter max buckets
+    await this.selectOption('max_buckets_mode', 'Custom');
     await element(by.id('max_buckets')).click();
     await element(by.id('max_buckets')).clear();
     await element(by.id('max_buckets')).sendKeys(maxbuckets);
@@ -51,6 +52,7 @@ export class UsersPageHelper extends PageHelper {
     await element(by.id('email')).sendKeys(new_email);
 
     // Change the max buckets field
+    await this.selectOption('max_buckets_mode', 'Custom');
     await element(by.id('max_buckets')).click();
     await element(by.id('max_buckets')).clear();
     await element(by.id('max_buckets')).sendKeys(new_maxbuckets);
@@ -87,6 +89,7 @@ export class UsersPageHelper extends PageHelper {
     );
 
     // check that username field is marked invalid if username has been cleared off
+    await username_field.click();
     for (let i = 0; i < uname.length; i++) {
       await username_field.sendKeys(protractor.Key.BACK_SPACE);
     }
@@ -119,13 +122,14 @@ export class UsersPageHelper extends PageHelper {
     );
 
     // put negative max buckets to make field invalid
+    await this.expectSelectOption('max_buckets_mode', 'Custom');
     await element(by.id('max_buckets')).click();
     await element(by.id('max_buckets')).clear();
     await element(by.id('max_buckets')).sendKeys('-5');
     await expect(element(by.id('max_buckets')).getAttribute('class')).toContain('ng-invalid');
     await username_field.click(); // trigger validation check
     await expect(element(by.css('#max_buckets + .invalid-feedback')).getText()).toMatch(
-      'The entered value must be >= 0.'
+      'The entered value must be >= 1.'
     );
 
     await this.navigateTo();
@@ -171,13 +175,14 @@ export class UsersPageHelper extends PageHelper {
     );
 
     // put negative max buckets to make field invalid
+    await this.expectSelectOption('max_buckets_mode', 'Custom');
     await element(by.id('max_buckets')).click();
     await element(by.id('max_buckets')).clear();
     await element(by.id('max_buckets')).sendKeys('-5');
     await expect(element(by.id('max_buckets')).getAttribute('class')).toContain('ng-invalid');
     await element(by.id('email')).click(); // trigger validation check
     await expect(element(by.css('#max_buckets + .invalid-feedback')).getText()).toMatch(
-      'The entered value must be >= 0.'
+      'The entered value must be >= 1.'
     );
 
     await this.navigateTo();
index 54fff66f5a796f759a4d0c453506a55fecd2b0b1..a22b1c14dea36917914f2fa91ab337b9efb257e9 100644 (file)
@@ -32,7 +32,7 @@
           <tr>
             <td i18n
                 class="bold">Maximum buckets</td>
-            <td>{{ user.max_buckets }}</td>
+            <td>{{ user.max_buckets | map:maxBucketsMap }}</td>
           </tr>
           <tr *ngIf="user.subusers && user.subusers.length">
             <td i18n
index 1379e462e399ef77b69c06ca0b5034542c16c3ad..57d66362e4b93a160f2cd3fc68a0db66d47961d5 100644 (file)
@@ -29,6 +29,7 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
 
   // Details tab
   user: any;
+  maxBucketsMap: {};
 
   // Keys tab
   keys: any = [];
@@ -56,6 +57,10 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
         flexGrow: 1
       }
     ];
+    this.maxBucketsMap = {
+      '-1': this.i18n('Disabled'),
+      0: this.i18n('Unlimited')
+    };
   }
 
   ngOnChanges() {
index b800a1c3cc2c102f1eb6ca380288dc27b25ec6f7..ce638cceb962424905383bc3f1bb680a1874cacd 100644 (file)
 
         <!-- Max. buckets -->
         <div class="form-group row">
-          <label class="cd-col-form-label required"
-                 for="max_buckets"
+          <label class="cd-col-form-label"
+                 for="max_buckets_mode"
                  i18n>Max. buckets</label>
+          <div class="cd-col-form-input">
+            <select class="form-control custom-select"
+                    formControlName="max_buckets_mode"
+                    name="max_buckets_mode"
+                    id="max_buckets_mode">
+              <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 row">
+          <label class="cd-col-form-label"></label>
           <div class="cd-col-form-input">
             <input id="max_buckets"
                    class="form-control"
                    type="number"
-                   formControlName="max_buckets">
+                   formControlName="max_buckets"
+                   min="1">
             <span class="invalid-feedback"
                   *ngIf="userForm.showError('max_buckets', frm, 'required')"
                   i18n>This field is required.</span>
             <span class="invalid-feedback"
                   *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 12ce4da8c87e829ad528f613e351a0f8a341714e..77321d6578b8ba572ba31b78941c9695a7fb781b 100644 (file)
@@ -80,7 +80,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],
@@ -162,6 +170,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'];
@@ -520,9 +542,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;
+      }
+    );
   }
 
   /**
@@ -584,6 +608,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;
   }
 
@@ -592,11 +622,17 @@ export class RgwUserFormComponent implements OnInit {
    * configuration has been modified.
    */
   private _getUpdateArgs() {
-    const result: Record<string, string> = {};
+    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;
   }
 
index 5b388049dd29387627b399c07f53680c5e325b4e..46c402019635b7b7e5448987346caf0753f30752 100644 (file)
@@ -71,7 +71,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 b58c15dffb4551ab21452d2cc1e714677e774f7e..aff3d803ce8775875913f4d70a8c34bf04a8d214 100644 (file)
@@ -56,7 +56,7 @@ export class RgwUserService {
     return this.http.get(`${this.url}/${uid}/quota`);
   }
 
-  create(args: Record<string, string>) {
+  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: Record<string, string>) {
+  update(uid: string, args: Record<string, any>) {
     let params = new HttpParams();
     _.keys(args).forEach((key) => {
       params = params.append(key, args[key]);
index 9330ae85fab1d3f0013b069300483746afec8109..c985616d3a699ef335147c383439432e72635dfa 100644 (file)
     <span *ngIf="!last">&nbsp;</span>
   </span>
 </ng-template>
+
+<ng-template #mapTpl
+             let-column="column"
+             let-value="value">
+  <span>{{ value | map:column?.customTemplateConfig }}</span>
+</ng-template>
index 371893692dd2ecbffd49f7ec65f5948622ba04b6..4433326cebbe2cccd09755e4444e0e2c1cbb77cd 100644 (file)
@@ -59,6 +59,8 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
   classAddingTpl: TemplateRef<any>;
   @ViewChild('badgeTpl', { static: true })
   badgeTpl: TemplateRef<any>;
+  @ViewChild('mapTpl', { static: true })
+  mapTpl: TemplateRef<any>;
 
   // This is the array with the items to be shown.
   @Input()
@@ -510,6 +512,7 @@ export class TableComponent implements AfterContentChecked, OnInit, OnChanges, O
     this.cellTemplates.executing = this.executingTpl;
     this.cellTemplates.classAdding = this.classAddingTpl;
     this.cellTemplates.badge = this.badgeTpl;
+    this.cellTemplates.map = this.mapTpl;
   }
 
   useCustomClass(value: any): string {
index 76746b3bd030a8e297cc0973e0847348b446c6fc..47f180bc5a90109b0c80fbb967b3c9187f7e4870 100644 (file)
@@ -10,6 +10,7 @@ export enum CellTemplate {
   // supports an optional custom configuration:
   // {
   //   ...
+  //   cellTransformation: CellTemplate.badge,
   //   customTemplateConfig: {
   //     class?: string; // Additional class name.
   //     prefix?: any;   // Prefix of the value to be displayed.
@@ -19,5 +20,14 @@ export enum CellTemplate {
   //     }
   //   }
   // }
-  badge = 'badge'
+  badge = 'badge',
+  // Maps the value using the given dictionary.
+  // {
+  //   ...
+  //   cellTransformation: CellTemplate.map,
+  //   customTemplateConfig: {
+  //     [key: any]: any
+  //   }
+  // }
+  map = 'map'
 }
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 9b42296f00130b57a547fbd0440c840fa2947d2a..5a70d512ff6f8492c68b2288d3b7c99ff21383fc 100755 (executable)
@@ -19,6 +19,7 @@ import { IopsPipe } from './iops.pipe';
 import { IscsiBackstorePipe } from './iscsi-backstore.pipe';
 import { JoinPipe } from './join.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';
@@ -54,7 +55,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     UpperFirstPipe,
     RbdConfigurationSourcePipe,
-    DurationPipe
+    DurationPipe,
+    MapPipe
   ],
   exports: [
     ArrayPipe,
@@ -81,7 +83,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     UpperFirstPipe,
     RbdConfigurationSourcePipe,
-    DurationPipe
+    DurationPipe,
+    MapPipe
   ],
   providers: [
     ArrayPipe,
@@ -104,7 +107,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     IopsPipe,
     MillisecondsPipe,
     NotAvailablePipe,
-    UpperFirstPipe
+    UpperFirstPipe,
+    MapPipe
   ]
 })
 export class PipesModule {}