]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add RGW management features
authorVolker Theile <vtheile@suse.com>
Fri, 27 Apr 2018 13:50:21 +0000 (15:50 +0200)
committerVolker Theile <vtheile@suse.com>
Fri, 27 Apr 2018 13:59:57 +0000 (15:59 +0200)
Signed-off-by: Volker Theile <vtheile@suse.com>
44 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-daemon-details/rgw-daemon-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts [new file with mode: 0644]
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.spec.ts
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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.spec.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/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts

index e7cdaa3939073b7443232af01423cabb008f0002..05420473980ae04f0197f1f79e9f6ebfa00c2da4 100644 (file)
@@ -66,6 +66,7 @@ class RgwDaemon(RESTController):
 @ApiController('rgw/proxy/{path:.*}')
 @AuthRequired()
 class RgwProxy(BaseController):
+
     @cherrypy.expose
     def __call__(self, path, **params):
         try:
@@ -85,3 +86,12 @@ class RgwProxy(BaseController):
             cherrypy.response.headers['Content-Type'] = 'application/json'
             cherrypy.response.status = 500
             return json.dumps({'detail': str(e)}).encode('utf-8')
+
+
+@ApiController('rgw/bucket')
+@AuthRequired()
+class RgwBucket(RESTController):
+
+    def create(self, bucket, uid):
+        rgw_client = RgwClient.instance(uid)
+        return rgw_client.create_bucket(bucket)
index 77d5e5e68b5db69fd2f78aee53152f543b6bd739..a4fb77be41f101b34c774feb84020c11fd00115e 100644 (file)
@@ -16,8 +16,10 @@ import {
   PerformanceCounterComponent
 } from './ceph/performance-counter/performance-counter/performance-counter.component';
 import { PoolListComponent } from './ceph/pool/pool-list/pool-list.component';
+import { RgwBucketFormComponent } from './ceph/rgw/rgw-bucket-form/rgw-bucket-form.component';
 import { RgwBucketListComponent } from './ceph/rgw/rgw-bucket-list/rgw-bucket-list.component';
 import { RgwDaemonListComponent } from './ceph/rgw/rgw-daemon-list/rgw-daemon-list.component';
+import { RgwUserFormComponent } from './ceph/rgw/rgw-user-form/rgw-user-form.component';
 import { RgwUserListComponent } from './ceph/rgw/rgw-user-list/rgw-user-list.component';
 import { LoginComponent } from './core/auth/login/login.component';
 import { NotFoundComponent } from './core/not-found/not-found.component';
@@ -39,11 +41,31 @@ const routes: Routes = [
     component: RgwUserListComponent,
     canActivate: [AuthGuardService]
   },
+  {
+    path: 'rgw/user/add',
+    component: RgwUserFormComponent,
+    canActivate: [AuthGuardService]
+  },
+  {
+    path: 'rgw/user/edit/:uid',
+    component: RgwUserFormComponent,
+    canActivate: [AuthGuardService]
+  },
   {
     path: 'rgw/bucket',
     component: RgwBucketListComponent,
     canActivate: [AuthGuardService]
   },
+  {
+    path: 'rgw/bucket/add',
+    component: RgwBucketFormComponent,
+    canActivate: [AuthGuardService]
+  },
+  {
+    path: 'rgw/bucket/edit/:bucket',
+    component: RgwBucketFormComponent,
+    canActivate: [AuthGuardService]
+  },
   { path: 'block/iscsi', component: IscsiComponent, canActivate: [AuthGuardService] },
   { path: 'block/rbd', component: RbdListComponent, canActivate: [AuthGuardService] },
   { path: 'rbd/add', component: RbdFormComponent, canActivate: [AuthGuardService] },
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-capability.ts
new file mode 100644 (file)
index 0000000..ee10088
--- /dev/null
@@ -0,0 +1,4 @@
+export class RgwUserCapability {
+  type: string;
+  perm: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-s3-key.ts
new file mode 100644 (file)
index 0000000..bcb9531
--- /dev/null
@@ -0,0 +1,6 @@
+export class RgwUserS3Key {
+  user: string;
+  generate_key?: boolean;
+  access_key: string;
+  secret_key: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-subuser.ts
new file mode 100644 (file)
index 0000000..788b6a2
--- /dev/null
@@ -0,0 +1,6 @@
+export class RgwUserSubuser {
+  id: string;
+  permissions: string;
+  generate_secret?: boolean;
+  secret_key?: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-swift-key.ts
new file mode 100644 (file)
index 0000000..26abd2a
--- /dev/null
@@ -0,0 +1,4 @@
+export class RgwUserSwiftKey {
+  user: string;
+  secret_key: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
new file mode 100644 (file)
index 0000000..cc14db9
--- /dev/null
@@ -0,0 +1,146 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item"
+        i18n>Object Gateway</li>
+    <li class="breadcrumb-item">
+      <a routerLink="/rgw/bucket"
+         i18n>Buckets</a>
+    </li>
+    <li class="breadcrumb-item active"
+        aria-current="page"
+        i18n>
+      {editing, select, 1 {Edit} other {Add}}
+    </li>
+  </ol>
+</nav>
+
+<cd-loading-panel *ngIf="editing && loading && !error"
+                  i18n>
+  Loading bucket data...
+</cd-loading-panel>
+<cd-error-panel *ngIf="editing && error"
+                (backAction)="goToListView()"
+                i18n>
+  The bucket data could not be loaded.
+</cd-error-panel>
+
+<div class="col-sm-12 col-lg-6"
+     *ngIf="!loading && !error">
+  <form name="bucketForm"
+        class="form-horizontal"
+        #frm="ngForm"
+        [formGroup]="bucketForm"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title"
+            i18n>
+          {editing, select, 1 {Edit} other {Add}} bucket
+        </h3>
+      </div>
+      <div class="panel-body">
+
+        <!-- Id -->
+        <div class="form-group"
+             *ngIf="editing">
+          <label i18n
+                 class="col-sm-3 control-label"
+                 for="id">Id</label>
+          <div class="col-sm-9">
+            <input id="id"
+                   name="id"
+                   class="form-control"
+                   type="text"
+                   formControlName="id"
+                   readonly>
+          </div>
+        </div>
+
+        <!-- Name -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || bucketForm.controls.bucket.dirty) && bucketForm.controls.bucket.invalid}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="bucket">Name
+            <span class="required"
+                  *ngIf="!editing"></span>
+          </label>
+          <div class="col-sm-9">
+            <input id="bucket"
+                   name="bucket"
+                   class="form-control"
+                   type="text"
+                   i18n-placeholder
+                   placeholder="Name..."
+                   formControlName="bucket"
+                   [readonly]="editing"
+                   autofocus>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(frm.submitted || bucketForm.controls.bucket.dirty) && bucketForm.controls.bucket.hasError('required')">
+              This field is required.
+            </span>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(frm.submitted || bucketForm.controls.bucket.dirty) && bucketForm.controls.bucket.hasError('bucketNameInvalid')">
+              The value is not valid.
+            </span>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(frm.submitted || bucketForm.controls.bucket.dirty) && bucketForm.controls.bucket.hasError('bucketNameExists')">
+              The chosen name is already in use.
+            </span>
+          </div>
+        </div>
+
+        <!-- Owner -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || bucketForm.controls.owner.dirty) && bucketForm.controls.owner.invalid}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="owner">Owner
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <select id="owner"
+                    name="owner"
+                    class="form-control"
+                    formControlName="owner">
+              <option i18n
+                      *ngIf="owners === null"
+                      [ngValue]="null">Loading...
+              </option>
+              <option i18n
+                      *ngIf="owners !== null"
+                      [ngValue]="null">-- Select a user --
+              </option>
+              <option *ngFor="let owner of owners"
+                      [value]="owner">{{ owner }}</option>
+            </select>
+            <span i18n
+                  class="help-block"
+                  *ngIf="(frm.submitted || bucketForm.controls.owner.dirty) && bucketForm.controls.owner.hasError('required')">
+              This field is required.
+            </span>
+          </div>
+        </div>
+
+      </div>
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button (submitAction)="submit()"
+                            [form]="bucketForm"
+                            i18n>
+            {editing, select, 1 {Update} other {Add}}
+          </cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  routerLink="/rgw/bucket">
+            Back
+          </button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
new file mode 100644 (file)
index 0000000..8500ed2
--- /dev/null
@@ -0,0 +1,106 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import 'rxjs/add/observable/of';
+import { Observable } from 'rxjs/Observable';
+
+import { RgwBucketService } from '../../../shared/api/rgw-bucket.service';
+import { RgwUserService } from '../../../shared/api/rgw-user.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RgwBucketFormComponent } from './rgw-bucket-form.component';
+
+describe('RgwBucketFormComponent', () => {
+  let component: RgwBucketFormComponent;
+  let fixture: ComponentFixture<RgwBucketFormComponent>;
+  let queryResult: Array<string> = [];
+
+  class MockRgwBucketService extends RgwBucketService {
+    enumerate() {
+      return Observable.of(queryResult);
+    }
+  }
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwBucketFormComponent ],
+      imports: [
+        HttpClientTestingModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule
+      ],
+      providers: [
+        RgwUserService,
+        { provide: RgwBucketService, useClass: MockRgwBucketService }
+      ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwBucketFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('bucketNameValidator', () => {
+    it('should validate name (1/4)', () => {
+      const validatorFn = component.bucketNameValidator();
+      const ctrl = new FormControl('');
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp).toBe(null);
+        });
+      }
+    });
+
+    it('should validate name (2/4)', () => {
+      const validatorFn = component.bucketNameValidator();
+      const ctrl = new FormControl('ab');
+      ctrl.markAsDirty();
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp.bucketNameInvalid).toBeTruthy();
+        });
+      }
+    });
+
+    it('should validate name (3/4)', () => {
+      const validatorFn = component.bucketNameValidator();
+      const ctrl = new FormControl('abc');
+      ctrl.markAsDirty();
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp).toBe(null);
+        });
+      }
+    });
+
+    it('should validate name (4/4)', () => {
+      queryResult = ['abcd'];
+      const validatorFn = component.bucketNameValidator();
+      const ctrl = new FormControl('abcd');
+      ctrl.markAsDirty();
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp instanceof Object).toBeTruthy();
+          expect(resp.bucketNameExists).toBeTruthy();
+        });
+      }
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
new file mode 100644 (file)
index 0000000..0e9d7f5
--- /dev/null
@@ -0,0 +1,151 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import {
+  AbstractControl,
+  AsyncValidatorFn,
+  FormBuilder,
+  FormGroup,
+  ValidationErrors,
+  Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import * as _ from 'lodash';
+
+import { RgwBucketService } from '../../../shared/api/rgw-bucket.service';
+import { RgwUserService } from '../../../shared/api/rgw-user.service';
+
+@Component({
+  selector: 'cd-rgw-bucket-form',
+  templateUrl: './rgw-bucket-form.component.html',
+  styleUrls: ['./rgw-bucket-form.component.scss']
+})
+export class RgwBucketFormComponent implements OnInit, OnDestroy {
+
+  bucketForm: FormGroup;
+  routeParamsSubscribe: any;
+  editing = false;
+  error = false;
+  loading = false;
+  owners = null;
+
+  constructor(private formBuilder: FormBuilder,
+              private route: ActivatedRoute,
+              private router: Router,
+              private rgwBucketService: RgwBucketService,
+              private rgwUserService: RgwUserService) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.bucketForm = this.formBuilder.group({
+      'id': [
+        null
+      ],
+      'bucket': [
+        null,
+        [ Validators.required ],
+        [ this.bucketNameValidator() ]
+      ],
+      'owner': [
+        null,
+        [ Validators.required ]
+      ]
+    });
+  }
+
+  ngOnInit() {
+    // Get the list of possible owners.
+    this.rgwUserService.enumerate()
+      .subscribe((resp: string[]) => {
+        this.owners = resp.sort();
+      });
+
+    // Process route parameters.
+    this.routeParamsSubscribe = this.route.params
+      .subscribe((params: { bucket: string }) => {
+        if (!params.hasOwnProperty('bucket')) {
+          return;
+        }
+        this.loading = true;
+        // Load the bucket data in 'edit' mode.
+        this.editing = true;
+        this.rgwBucketService.get(params.bucket)
+          .subscribe((resp: object) => {
+            this.loading = false;
+            // Get the default values.
+            const defaults = _.clone(this.bucketForm.value);
+            // Extract the values displayed in the form.
+            let value = _.pick(resp, _.keys(this.bucketForm.value));
+            // Append default values.
+            value = _.merge(defaults, value);
+            // Update the form.
+            this.bucketForm.setValue(value);
+          });
+      }, (error) => {
+        this.error = error;
+      });
+  }
+
+  ngOnDestroy() {
+    this.routeParamsSubscribe.unsubscribe();
+  }
+
+  goToListView() {
+    this.router.navigate(['/rgw/bucket']);
+  }
+
+  submit() {
+    // Exit immediately if the form isn't dirty.
+    if (this.bucketForm.pristine) {
+      this.goToListView();
+    }
+    const bucketCtl = this.bucketForm.get('bucket');
+    const ownerCtl = this.bucketForm.get('owner');
+    if (this.editing) { // Edit
+      const idCtl = this.bucketForm.get('id');
+      this.rgwBucketService.update(idCtl.value, bucketCtl.value, ownerCtl.value)
+        .subscribe(() => {
+          this.goToListView();
+        }, () => {
+          // Reset the 'Submit' button.
+          this.bucketForm.setErrors({'cdSubmitButton': true});
+        });
+    } else { // Add
+      this.rgwBucketService.create(bucketCtl.value, ownerCtl.value)
+        .subscribe(() => {
+          this.goToListView();
+        }, () => {
+          // Reset the 'Submit' button.
+          this.bucketForm.setErrors({'cdSubmitButton': true});
+        });
+    }
+  }
+
+  bucketNameValidator(): AsyncValidatorFn {
+    const rgwBucketService = this.rgwBucketService;
+    return (control: AbstractControl): Promise<ValidationErrors | null> => {
+      return new Promise((resolve) => {
+        // Exit immediately if user has not interacted with the control yet
+        // or the control value is empty.
+        if (control.pristine || control.value === '') {
+          resolve(null);
+          return;
+        }
+        // Validate the bucket name.
+        const nameRe = /^[0-9A-Za-z][\w-\.]{2,254}$/;
+        if (!nameRe.test(control.value)) {
+          resolve({bucketNameInvalid: true});
+          return;
+        }
+        // Does any bucket with the given name already exist?
+        rgwBucketService.exists(control.value)
+          .subscribe((resp: boolean) => {
+            if (!resp) {
+              resolve(null);
+            } else {
+              resolve({bucketNameExists: true});
+            }
+          });
+      });
+    };
+  }
+}
index b7b5f3d45358f6da6e11f1409ef3a0c39eb9f673..965884e7503742c5301218286208758f6b806573 100644 (file)
@@ -7,14 +7,76 @@
         aria-current="page">Buckets</li>
   </ol>
 </nav>
-<cd-table [data]="buckets"
+<cd-table #table
+          [autoReload]="false"
+          [data]="buckets"
           [columns]="columns"
           columnMode="flex"
-          selectionType="single"
+          selectionType="multi"
           (updateSelection)="updateSelection($event)"
           identifier="bucket"
-          (fetchData)="getBucketList()"
-          #table>
+          (fetchData)="getBucketList()">
+  <div class="table-actions">
+    <div class="btn-group" dropdown>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="!selection.hasSelection"
+              routerLink="/rgw/bucket/add">
+        <i class="fa fa-fw fa-plus"></i>
+        <ng-container i18n>Add</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasSingleSelection"
+              routerLink="/rgw/bucket/edit/{{ selection.first()?.bucket }}">
+        <i class="fa fa-fw fa-pencil"></i>
+        <ng-container i18n>Edit</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasMultiSelection"
+              (click)="deleteAction()">
+        <i class="fa fa-fw fa-trash-o"></i>
+        <ng-container i18n>Delete</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split"
+              dropdownToggle>
+        <span class="caret"></span>
+        <span class="sr-only"></span>
+      </button>
+      <ul class="dropdown-menu"
+          *dropdownMenu
+          role="menu">
+        <li role="menuitem">
+          <a class="dropdown-item"
+             routerLink="/rgw/bucket/add"
+             i18n>
+            <i class="fa fa-fw fa-plus"></i>
+            Add
+          </a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection}">
+          <a class="dropdown-item"
+             routerLink="/rgw/bucket/edit/{{ selection.first()?.bucket }}"
+             i18n>
+            <i class="fa fa-fw fa-pencil"></i>
+            Edit
+          </a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSelection}">
+          <a class="dropdown-item"
+             (click)="deleteAction()"
+             i18n>
+            <i class="fa fa-fw fa-trash-o"></i>
+            Delete
+          </a>
+        </li>
+      </ul>
+    </div>
+  </div>
   <cd-rgw-bucket-details cdTableDetail
                          [selection]="selection">
   </cd-rgw-bucket-details>
index 53d1f853de28f56186cf4e7cdd00317a9b8e16b7..4df24b8d4b8faff117e897e2920a20924f187770 100644 (file)
@@ -15,10 +15,10 @@ describe('RgwBucketListComponent', () => {
   let component: RgwBucketListComponent;
   let fixture: ComponentFixture<RgwBucketListComponent>;
 
-  const fakeService = {
+  const fakeRgwBucketService = {
     list: () => {
-      return new Promise(function(resolve, reject) {
-        return [];
+      return new Promise(function(resolve) {
+        resolve([]);
       });
     }
   };
@@ -37,7 +37,7 @@ describe('RgwBucketListComponent', () => {
         DataTableModule,
         SharedModule
       ],
-      providers: [{ provide: RgwBucketService, useValue: fakeService }]
+      providers: [{ provide: RgwBucketService, useValue: fakeRgwBucketService }]
     })
     .compileComponents();
   }));
index 85d88da8912b95d1fabab5a0ad02deabae2b682d..1042a1a336ff3796594651def9fdae3f23f3c6b4 100644 (file)
@@ -1,6 +1,15 @@
 import { Component, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { BsModalService } from 'ngx-bootstrap';
+import 'rxjs/add/observable/forkJoin';
+import { Observable } from 'rxjs/Observable';
+import { Subscriber } from 'rxjs/Subscriber';
 
 import { RgwBucketService } from '../../../shared/api/rgw-bucket.service';
+import {
+  DeletionModalComponent
+} from '../../../shared/components/deletion-modal/deletion-modal.component';
 import { TableComponent } from '../../../shared/datatable/table/table.component';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
@@ -11,13 +20,16 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection';
   styleUrls: ['./rgw-bucket-list.component.scss']
 })
 export class RgwBucketListComponent {
-  @ViewChild('table') table: TableComponent;
+
+  @ViewChild(TableComponent) table: TableComponent;
 
   columns: CdTableColumn[] = [];
   buckets: object[] = [];
   selection: CdTableSelection = new CdTableSelection();
 
-  constructor(private rgwBucketService: RgwBucketService) {
+  constructor(private router: Router,
+              private rgwBucketService: RgwBucketService,
+              private bsModalService: BsModalService) {
     this.columns = [
       {
         name: 'Name',
@@ -46,4 +58,26 @@ export class RgwBucketListComponent {
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
+
+  deleteAction() {
+    const modalRef = this.bsModalService.show(DeletionModalComponent);
+    modalRef.content.setUp({
+      metaType: this.selection.hasSingleSelection ? 'bucket' : 'buckets',
+      deletionObserver: (): Observable<any> => {
+        return new Observable((observer: Subscriber<any>) => {
+          Observable.forkJoin(
+            this.selection.selected.map((bucket: any) => {
+              return this.rgwBucketService.delete(bucket.bucket);
+            }))
+            .subscribe(null, null, () => {
+              observer.complete();
+              // Finally reload the data table content.
+              this.table.refreshBtn();
+            });
+        });
+      },
+      modalRef: modalRef
+    });
+  }
+
 }
index 13fd3f0a88bf9140cd8ffffa2ca347fa9a8b7433..c271666a98af21c230e199cc8c8fe1092c8fc19f 100644 (file)
@@ -12,10 +12,10 @@ describe('RgwDaemonDetailsComponent', () => {
   let component: RgwDaemonDetailsComponent;
   let fixture: ComponentFixture<RgwDaemonDetailsComponent>;
 
-  const fakeService = {
+  const fakeRgwDaemonService = {
     get: (id: string) => {
-      return new Promise(function(resolve, reject) {
-        return [];
+      return new Promise(function(resolve) {
+        resolve([]);
       });
     }
   };
@@ -28,7 +28,7 @@ describe('RgwDaemonDetailsComponent', () => {
         PerformanceCounterModule,
         TabsModule.forRoot()
       ],
-      providers: [{ provide: RgwDaemonService, useValue: fakeService }]
+      providers: [{ provide: RgwDaemonService, useValue: fakeRgwDaemonService }]
     });
   }));
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.html
new file mode 100644 (file)
index 0000000..6877089
--- /dev/null
@@ -0,0 +1,96 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>Capability
+  </h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<form class="form-horizontal"
+      #frm="ngForm"
+      [formGroup]="formGroup"
+      novalidate>
+  <div class="modal-body">
+
+    <!-- Type -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.type.dirty) && formGroup.controls.type.invalid}">
+      <label class="control-label col-sm-3"
+             for="type"
+             i18n>Type
+        <span class="required"
+              *ngIf="!editing">
+        </span>
+      </label>
+      <div class="col-sm-9">
+        <input id="type"
+               class="form-control"
+               type="text"
+               *ngIf="editing"
+               [readonly]="true"
+               formControlName="type">
+        <select id="type"
+                class="form-control"
+                formControlName="type"
+                *ngIf="!editing"
+                autofocus>
+          <option i18n
+                  *ngIf="types !== null"
+                  [ngValue]="null">-- Select a type --
+          </option>
+          <option *ngFor="let type of types"
+                  [value]="type">{{ type }}</option>
+        </select>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.type.dirty) && formGroup.controls.type.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+    <!-- Permission -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.perm.dirty) && formGroup.controls.perm.invalid}">
+      <label class="control-label col-sm-3"
+             for="perm"
+             i18n>Permission
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <select id="perm"
+                class="form-control"
+                formControlName="perm">
+          <option i18n
+                  [ngValue]="null">-- Select a permission --
+          </option>
+          <option *ngFor="let perm of ['read', 'write', '*']"
+                  [value]="perm">
+            {{ perm }}
+          </option>
+        </select>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.perm.dirty) && formGroup.controls.perm.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+  </div>
+  <div class="modal-footer">
+    <cd-submit-button (submitAction)="onSubmit()"
+                      [form]="formGroup"
+                      i18n>
+      {editing, select, 1 {Update} other {Add}}
+    </cd-submit-button>
+    <button class="btn btn-sm btn-default"
+            type="button"
+            (click)="bsModalRef.hide()"
+            i18n>Close
+    </button>
+  </div>
+</form>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..313f667
--- /dev/null
@@ -0,0 +1,34 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { RgwUserCapabilityModalComponent } from './rgw-user-capability-modal.component';
+
+describe('RgwUserCapabilityModalComponent', () => {
+  let component: RgwUserCapabilityModalComponent;
+  let fixture: ComponentFixture<RgwUserCapabilityModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwUserCapabilityModalComponent ],
+      imports: [
+        ReactiveFormsModule,
+        SharedModule
+      ],
+      providers: [ BsModalRef ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwUserCapabilityModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-capability-modal/rgw-user-capability-modal.component.ts
new file mode 100644 (file)
index 0000000..dca0efc
--- /dev/null
@@ -0,0 +1,87 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service';
+
+import { RgwUserCapability } from '../models/rgw-user-capability';
+
+@Component({
+  selector: 'cd-rgw-user-capability-modal',
+  templateUrl: './rgw-user-capability-modal.component.html',
+  styleUrls: ['./rgw-user-capability-modal.component.scss']
+})
+export class RgwUserCapabilityModalComponent {
+
+  /**
+   * The event that is triggered when the 'Add' or 'Update' button
+   * has been pressed.
+   */
+  @Output() submitAction = new EventEmitter();
+
+  formGroup: FormGroup;
+  editing = true;
+  types: string[] = [];
+
+  constructor(private formBuilder: FormBuilder,
+              public bsModalRef: BsModalRef) {
+    this.createForm();
+  }
+
+  createForm() {
+    this.formGroup = this.formBuilder.group({
+      'type': [
+        null,
+        [Validators.required]
+      ],
+      'perm': [
+        null,
+        [Validators.required]
+      ]
+    });
+  }
+
+  /**
+   * Set the 'editing' flag. If set to TRUE, the modal dialog is in 'Edit' mode,
+   * otherwise in 'Add' mode. According to the mode the dialog and its controls
+   * behave different.
+   * @param {boolean} viewing
+   */
+  setEditing(editing: boolean = true) {
+    this.editing = editing;
+  }
+
+  /**
+   * Set the values displayed in the dialog.
+   */
+  setValues(type: string, perm: string) {
+    this.formGroup.setValue({
+      'type': type,
+      'perm': perm
+    });
+  }
+
+  /**
+   * Set the current capabilities of the user.
+   */
+  setCapabilities(capabilities: RgwUserCapability[]) {
+    // Parse the configured capabilities to get a list of types that
+    // should be displayed.
+    const usedTypes = [];
+    capabilities.forEach((capability) => {
+      usedTypes.push(capability.type);
+    });
+    this.types = [];
+    ['users', 'buckets', 'metadata', 'usage', 'zone'].forEach((type) => {
+      if (_.indexOf(usedTypes, type) === -1) {
+        this.types.push(type);
+      }
+    });
+  }
+
+  onSubmit() {
+    const capability: RgwUserCapability = this.formGroup.value;
+    this.submitAction.emit(capability);
+    this.bsModalRef.hide();
+  }
+}
index f05ff79497685394fda8df4e4b45d9ef08d534e5..ca51f56970b2d9e3cdd76861827d94e63719d69f 100644 (file)
       </div>
     </div>
   </tab>
+
+  <tab i18n-heading heading="Keys">
+    <form class="form-horizontal">
+      <cd-table [data]="keys"
+                [columns]="keysColumns"
+                columnMode="flex"
+                selectionType="multi"
+                forceIdentifier="true"
+                (updateSelection)="updateKeysSelection($event)">
+        <div class="table-actions">
+          <div class="btn-group" dropdown>
+            <button type="button"
+                    class="btn btn-sm btn-primary"
+                    [disabled]="!keysSelection.hasSingleSelection"
+                    (click)="showKeyModal()">
+              <i class="fa fa-eye"></i>
+              <ng-container i18n>Show</ng-container>
+            </button>
+          </div>
+        </div>
+      </cd-table>
+    </form>
+  </tab>
 </tabset>
index 938f0f419f7d623b4136de2ebf9b7f72dc8540cd..015dfc581e556239e04e2965f53d1dae890c181d 100644 (file)
@@ -2,6 +2,7 @@ import { HttpClientModule } from '@angular/common/http';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
 
+import { BsModalService } from 'ngx-bootstrap/modal';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
@@ -20,6 +21,9 @@ describe('RgwUserDetailsComponent', () => {
         HttpClientModule,
         SharedModule,
         TabsModule.forRoot()
+      ],
+      providers: [
+        BsModalService
       ]
     })
     .compileComponents();
index 1c21ce94176b8245525e7708dc16957cb9d56a7c..35cbec9902d91fb3d705287bb7885e5b72fb1124 100644 (file)
@@ -1,21 +1,57 @@
-import { Component, Input, OnChanges } from '@angular/core';
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
 
 import * as _ from 'lodash';
+import { BsModalService } from 'ngx-bootstrap';
 
 import { RgwUserService } from '../../../shared/api/rgw-user.service';
+import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserSwiftKey } from '../models/rgw-user-swift-key';
+import {
+  RgwUserS3KeyModalComponent
+} from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import {
+  RgwUserSwiftKeyModalComponent
+} from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
 
 @Component({
   selector: 'cd-rgw-user-details',
   templateUrl: './rgw-user-details.component.html',
   styleUrls: ['./rgw-user-details.component.scss']
 })
-export class RgwUserDetailsComponent implements OnChanges {
-  user: any;
+export class RgwUserDetailsComponent implements OnChanges, OnInit {
+
+  @ViewChild('accessKeyTpl') public accessKeyTpl: TemplateRef<any>;
+  @ViewChild('secretKeyTpl') public secretKeyTpl: TemplateRef<any>;
 
   @Input() selection: CdTableSelection;
 
-  constructor(private rgwUserService: RgwUserService) {}
+  // Details tab
+  user: any;
+
+  // Keys tab
+  keys: any = [];
+  keysColumns: CdTableColumn[] = [];
+  keysSelection: CdTableSelection = new CdTableSelection();
+
+  constructor(private rgwUserService: RgwUserService,
+              private bsModalService: BsModalService) {}
+
+  ngOnInit() {
+    this.keysColumns = [
+      {
+        name: 'Username',
+        prop: 'username',
+        flexGrow: 1
+      },
+      {
+        name: 'Type',
+        prop: 'type',
+        flexGrow: 1
+      }
+    ];
+  }
 
   ngOnChanges() {
     if (this.selection.hasSelection) {
@@ -32,6 +68,46 @@ export class RgwUserDetailsComponent implements OnChanges {
             _.extend(this.user, resp);
           });
       }
+
+      // Process the keys.
+      this.keys = [];
+      this.user.keys.forEach((key: RgwUserS3Key) => {
+        this.keys.push({
+          id: this.keys.length + 1, // Create an unique identifier
+          type: 'S3',
+          username: key.user,
+          ref: key
+        });
+      });
+      this.user.swift_keys.forEach((key: RgwUserSwiftKey) => {
+        this.keys.push({
+          id: this.keys.length + 1, // Create an unique identifier
+          type: 'Swift',
+          username: key.user,
+          ref: key
+        });
+      });
+      this.keys = _.sortBy(this.keys, 'user');
+    }
+  }
+
+  updateKeysSelection(selection: CdTableSelection) {
+    this.keysSelection = selection;
+  }
+
+  showKeyModal() {
+    const key = this.keysSelection.first();
+    const modalRef = this.bsModalService.show(key.type === 'S3' ?
+      RgwUserS3KeyModalComponent : RgwUserSwiftKeyModalComponent);
+    switch (key.type) {
+      case 'S3':
+        modalRef.content.setViewing();
+        modalRef.content.setValues(key.ref.user, key.ref.access_key,
+          key.ref.secret_key);
+        break;
+      case 'Swift':
+        modalRef.content.setValues(key.ref.user, key.ref.secret_key);
+        break;
     }
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
new file mode 100644 (file)
index 0000000..0a93303
--- /dev/null
@@ -0,0 +1,663 @@
+<nav aria-label="breadcrumb">
+  <ol class="breadcrumb">
+    <li class="breadcrumb-item"
+        i18n>Object Gateway</li>
+    <li class="breadcrumb-item">
+      <a routerLink="/rgw/user"
+         i18n>Users</a>
+    </li>
+    <li class="breadcrumb-item active"
+        aria-current="page"
+        i18n>
+      {editing, select, 1 {Edit} other {Add}}
+    </li>
+  </ol>
+</nav>
+
+<cd-loading-panel *ngIf="editing && loading && !error"
+                  i18n>
+  Loading user data...
+</cd-loading-panel>
+<cd-error-panel *ngIf="editing && error"
+                (backAction)="goToListView()"
+                i18n>
+  The user data could not be loaded.
+</cd-error-panel>
+
+<div class="col-sm-12 col-lg-6"
+     *ngIf="!loading && !error">
+  <form class="form-horizontal"
+        #frm="ngForm"
+        [formGroup]="userForm"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          {editing, select, 1 {Edit} other {Add}} user
+        </h3>
+      </div>
+      <div class="panel-body">
+
+        <!-- Username -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || userForm.controls.user_id.dirty) && userForm.controls.user_id.invalid}">
+          <label class="control-label col-sm-3"
+                 for="user_id"
+                 i18n>Username
+            <span class="required"
+                  *ngIf="!editing">
+            </span>
+          </label>
+          <div class="col-sm-9">
+            <input id="user_id"
+                   class="form-control"
+                   type="text"
+                   formControlName="user_id"
+                   [readonly]="editing"
+                   autofocus>
+            <span class="help-block"
+                  *ngIf="(frm.submitted || userForm.controls.user_id.dirty) && userForm.controls.user_id.hasError('required')"
+                  i18n>
+              This field is required.
+            </span>
+            <span class="help-block"
+                  *ngIf="(frm.submitted || userForm.controls.user_id.dirty) && userForm.controls.user_id.hasError('userIdExists')"
+                  i18n>
+              The chosen user ID is already in use.
+            </span>
+          </div>
+        </div>
+
+        <!-- Full name -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || userForm.controls.display_name.dirty) && userForm.controls.display_name.invalid}">
+          <label class="control-label col-sm-3"
+                 for="display_name"
+                 i18n>Full name
+            <span class="required"
+                  *ngIf="!editing">
+            </span>
+          </label>
+          <div class="col-sm-9">
+            <input id="display_name"
+                   class="form-control"
+                   type="text"
+                   formControlName="display_name">
+            <span class="help-block"
+                  *ngIf="(frm.submitted || userForm.controls.display_name.dirty) && userForm.controls.display_name.hasError('required')"
+                  i18n>
+              This field is required.
+            </span>
+          </div>
+        </div>
+
+        <!-- Email address -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || userForm.controls.email.dirty) && userForm.controls.email.invalid}">
+          <label class="control-label col-sm-3"
+                 for="email"
+                 i18n>Email address
+          </label>
+          <div class="col-sm-9">
+            <input id="email"
+                   class="form-control"
+                   type="text"
+                   formControlName="email">
+            <span class="help-block"
+                  *ngIf="(frm.submitted || userForm.controls.email.dirty) && userForm.controls.email.hasError('email')"
+                  i18n>
+              This is not a valid email address.
+            </span>
+          </div>
+        </div>
+
+        <!-- Max. buckets -->
+        <div class="form-group"
+             [ngClass]="{'has-error': (frm.submitted || userForm.controls.max_buckets.dirty) && userForm.controls.max_buckets.invalid}">
+          <label class="control-label col-sm-3"
+                 for="max_buckets"
+                 i18n>Max. buckets
+          </label>
+          <div class="col-sm-9">
+            <input id="max_buckets"
+                   class="form-control"
+                   type="number"
+                   formControlName="max_buckets">
+            <span class="help-block"
+                  *ngIf="(frm.submitted || userForm.controls.max_buckets.dirty) && userForm.controls.max_buckets.hasError('min')"
+                  i18n>
+              The entered value must be >= 0.
+            </span>
+          </div>
+        </div>
+
+        <!-- Suspended -->
+        <div class="form-group">
+          <div class="col-sm-offset-3 col-sm-9">
+            <div class="checkbox checkbox-primary">
+              <input id="suspended"
+                     type="checkbox"
+                     formControlName="suspended">
+              <label for="suspended"
+                     i18n>Suspended
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <!-- S3 key -->
+        <fieldset *ngIf="!editing">
+          <legend i18n>S3 key</legend>
+
+          <!-- Auto-generate key -->
+          <div class="form-group">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="generate_key"
+                       type="checkbox"
+                       formControlName="generate_key">
+                <label for="generate_key"
+                       i18n>Auto-generate key
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Access key -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.access_key.dirty) && userForm.controls.access_key.invalid}"
+               *ngIf="!editing && !userForm.controls.generate_key.value">
+            <label class="control-label col-sm-3"
+                   for="access_key"
+                   i18n>Access key
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <div class="input-group">
+                <input id="access_key"
+                       class="form-control"
+                       type="password"
+                       formControlName="access_key">
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default"
+                          cdPasswordButton="access_key">
+                  </button>
+                  <button type="button"
+                          class="btn btn-default"
+                          cdCopy2ClipboardButton="access_key">
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.access_key.dirty) && userForm.controls.access_key.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+            </div>
+          </div>
+
+          <!-- Secret key -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.secret_key.dirty) && userForm.controls.secret_key.invalid}"
+               *ngIf="!editing && !userForm.controls.generate_key.value">
+            <label class="control-label col-sm-3"
+                   for="secret_key"
+                   i18n>Secret key
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <div class="input-group">
+                <input id="secret_key"
+                       class="form-control"
+                       type="password"
+                       formControlName="secret_key">
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default"
+                          cdPasswordButton="secret_key">
+                  </button>
+                  <button type="button"
+                          class="btn btn-default"
+                          cdCopy2ClipboardButton="secret_key">
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.secret_key.dirty) && userForm.controls.secret_key.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+            </div>
+          </div>
+        </fieldset>
+
+        <!-- Subusers -->
+        <fieldset *ngIf="editing">
+          <legend i18n>Subusers</legend>
+
+          <div class="col-sm-offset-3 col-sm-9">
+            <span *ngIf="subusers.length === 0"
+                  class="form-control no-border">
+              <span class="text-muted"
+                    i18n>Empty
+              </span>
+            </span>
+
+            <span *ngFor="let subuser of subusers; let i=index;">
+              <div class="input-group">
+                <span class="input-group-addon">
+                  <i class="icon-prepend fa fa-user"></i>
+                </span>
+                <input type="text"
+                       class="form-control"
+                       value="{{ subuser.id }}"
+                       readonly>
+                <span class="input-group-addon"
+                      style="border-left: 0; border-right: 0;">
+                  <i class="icon-prepend fa fa-share-alt"></i>
+                </span>
+                <input type="text"
+                       class="form-control"
+                       value="{{ ('full-control' === subuser.permissions) ? 'full' : subuser.permissions }}"
+                       readonly>
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default tc_showSubuserButton"
+                          i18n-tooltip
+                          tooltip="Edit"
+                          (click)="showSubuserModal(i)">
+                    <i class="fa fa-cogs"></i>
+                  </button>
+                  <button type="button"
+                          class="btn btn-default tc_deleteSubuserButton"
+                          i18n-tooltip
+                          tooltip="Delete"
+                          (click)="deleteSubuser(i)">
+                    <i class="fa fa-trash-o"></i>
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"></span>
+            </span>
+
+            <span class="form-control no-border">
+              <button type="button"
+                      class="btn btn-sm btn-default btn-label pull-right tc_addSubuserButton"
+                      (click)="showSubuserModal()">
+                <i class="fa fa-fw fa-plus"></i>
+                <ng-container i18n>Add subuser</ng-container>
+              </button>
+            </span>
+          </div>
+        </fieldset>
+
+        <!-- Keys -->
+        <fieldset *ngIf="editing">
+          <legend i18n>Keys</legend>
+
+          <!-- S3 keys -->
+          <label class="col-sm-3 control-label"
+                 i18n>S3
+          </label>
+          <div class="col-sm-9">
+            <span *ngIf="s3Keys.length === 0"
+                  class="form-control no-border">
+              <span class="text-muted"
+                    i18n>Empty
+              </span>
+            </span>
+
+            <span *ngFor="let key of s3Keys; let i=index;">
+              <div class="input-group">
+                <span class="input-group-addon">
+                  <i class="icon-prepend fa fa-key"></i>
+                </span>
+                <input type="text"
+                       class="form-control"
+                       value="{{ key.user }}"
+                       readonly>
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default tc_showS3KeyButton"
+                          i18n-tooltip
+                          tooltip="Show"
+                          (click)="showS3KeyModal(i)">
+                    <i class="fa fa-eye"></i>
+                  </button>
+                  <button type="button"
+                          class="btn btn-default tc_deleteS3KeyButton"
+                          i18n-tooltip
+                          tooltip="Delete"
+                          (click)="deleteS3Key(i)">
+                    <i class="fa fa-trash-o"></i>
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"></span>
+            </span>
+
+            <span class="form-control no-border">
+              <button type="button"
+                      class="btn btn-sm btn-default btn-label pull-right tc_addS3KeyButton"
+                      (click)="showS3KeyModal()">
+                <i class="fa fa-fw fa-plus"></i>
+                <ng-container i18n>Add S3 key</ng-container>
+              </button>
+            </span>
+            <hr>
+          </div>
+
+          <!-- Swift keys -->
+          <label class="col-sm-3 control-label"
+                 i18n>Swift
+          </label>
+          <div class="col-sm-9">
+            <span *ngIf="swiftKeys.length === 0"
+                  class="form-control no-border">
+              <span class="text-muted"
+                    i18n>Empty
+              </span>
+            </span>
+
+            <span *ngFor="let key of swiftKeys; let i=index;">
+              <div class="input-group">
+                <span class="input-group-addon">
+                  <i class="icon-prepend fa fa-key"></i>
+                </span>
+                <input type="text"
+                       class="form-control"
+                       value="{{ key.user }}"
+                       readonly>
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default tc_showSwiftKeyButton"
+                          i18n-tooltip
+                          tooltip="Show"
+                          (click)="showSwiftKeyModal(i)">
+                    <i class="fa fa-eye"></i>
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"></span>
+            </span>
+          </div>
+        </fieldset>
+
+        <!-- Capabilities -->
+        <fieldset *ngIf="editing">
+          <legend i18n>Capabilities</legend>
+
+          <div class="col-sm-offset-3 col-sm-9">
+            <span *ngIf="capabilities.length === 0"
+                  class="form-control no-border">
+              <span class="text-muted"
+                    i18n>Empty
+              </span>
+            </span>
+
+            <span *ngFor="let cap of capabilities; let i=index;">
+              <div class="input-group">
+                <span class="input-group-addon">
+                  <i class="icon-prepend fa fa-share-alt"></i>
+                </span>
+                <input type="text"
+                       class="form-control"
+                       value="{{ cap.type }}:{{ cap.perm }}"
+                       readonly>
+                <span class="input-group-btn">
+                  <button type="button"
+                          class="btn btn-default tc_editCapButton"
+                          i18n-tooltip
+                          tooltip="Edit"
+                          (click)="showCapabilityModal(i)">
+                    <i class="fa fa-cogs"></i>
+                  </button>
+                  <button type="button"
+                          class="btn btn-default tc_deleteCapButton"
+                          i18n-tooltip
+                          tooltip="Delete"
+                          (click)="deleteCapability(i)">
+                    <i class="fa fa-trash-o"></i>
+                  </button>
+                </span>
+              </div>
+              <span class="help-block"></span>
+            </span>
+
+            <span class="form-control no-border">
+              <button type="button"
+                      class="btn btn-sm btn-default btn-label pull-right tc_addCapButton"
+                      (click)="showCapabilityModal()">
+                <i class="fa fa-fw fa-plus"></i>
+                <ng-container i18n>Add capability</ng-container>
+              </button>
+            </span>
+          </div>
+        </fieldset>
+
+        <!-- User quota -->
+        <fieldset>
+          <legend i18n>User quota</legend>
+
+          <!-- Enabled -->
+          <div class="form-group">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="user_quota_enabled"
+                       type="checkbox"
+                       formControlName="user_quota_enabled">
+                <label for="user_quota_enabled"
+                       i18n>Enabled
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Unlimited size -->
+          <div class="form-group"
+               *ngIf="userForm.controls.user_quota_enabled.value">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="user_quota_max_size_unlimited"
+                       type="checkbox"
+                       formControlName="user_quota_max_size_unlimited">
+                <label for="user_quota_max_size_unlimited"
+                       i18n>Unlimited size
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Maximum size -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.user_quota_max_size.dirty) && userForm.controls.user_quota_max_size.invalid}"
+               *ngIf="!userForm.controls.user_quota_max_size_unlimited.value">
+            <label class="control-label col-sm-3"
+                   for="user_quota_max_size"
+                   i18n>Max. size
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input id="user_quota_max_size"
+                     class="form-control"
+                     type="text"
+                     formControlName="user_quota_max_size">
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.user_quota_max_size.dirty) && userForm.controls.user_quota_max_size.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.user_quota_max_size.dirty) && userForm.controls.user_quota_max_size.hasError('quotaMaxSize')"
+                    i18n>
+                The value is not valid.
+              </span>
+            </div>
+          </div>
+
+          <!-- Unlimited objects -->
+          <div class="form-group"
+               *ngIf="userForm.controls.user_quota_enabled.value">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="user_quota_max_objects_unlimited"
+                       type="checkbox"
+                       formControlName="user_quota_max_objects_unlimited">
+                <label for="user_quota_max_objects_unlimited"
+                       i18n>Unlimited objects
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Maximum objects -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.user_quota_max_objects.dirty) && userForm.controls.user_quota_max_objects.invalid}"
+               *ngIf="!userForm.controls.user_quota_max_objects_unlimited.value">
+            <label class="control-label col-sm-3"
+                   for="user_quota_max_objects"
+                   i18n>Max. objects
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input id="user_quota_max_objects"
+                     class="form-control"
+                     type="number"
+                     formControlName="user_quota_max_objects">
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.user_quota_max_objects.dirty) && userForm.controls.user_quota_max_objects.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.user_quota_max_objects.dirty) && userForm.controls.user_quota_max_objects.hasError('min')"
+                    i18n>
+              The entered value must be >= 0.
+            </span>
+            </div>
+          </div>
+        </fieldset>
+
+        <!-- Bucket quota -->
+        <fieldset>
+          <legend i18n>Bucket quota</legend>
+
+          <!-- Enabled -->
+          <div class="form-group">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="bucket_quota_enabled"
+                       type="checkbox"
+                       formControlName="bucket_quota_enabled">
+                <label for="bucket_quota_enabled"
+                       i18n>Enabled
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Unlimited size -->
+          <div class="form-group"
+               *ngIf="userForm.controls.bucket_quota_enabled.value">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="bucket_quota_max_size_unlimited"
+                       type="checkbox"
+                       formControlName="bucket_quota_max_size_unlimited">
+                <label for="bucket_quota_max_size_unlimited"
+                       i18n>Unlimited size
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Maximum size -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.bucket_quota_max_size.dirty) && userForm.controls.bucket_quota_max_size.invalid}"
+               *ngIf="!userForm.controls.bucket_quota_max_size_unlimited.value">
+            <label class="control-label col-sm-3"
+                   for="bucket_quota_max_size"
+                   i18n>Max. size
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input id="bucket_quota_max_size"
+                     class="form-control"
+                     type="text"
+                     formControlName="bucket_quota_max_size">
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.bucket_quota_max_size.dirty) && userForm.controls.bucket_quota_max_size.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.bucket_quota_max_size.dirty) && userForm.controls.bucket_quota_max_size.hasError('quotaMaxSize')"
+                    i18n>
+                The value is not valid.
+              </span>
+            </div>
+          </div>
+
+          <!-- Unlimited objects -->
+          <div class="form-group"
+               *ngIf="userForm.controls.bucket_quota_enabled.value">
+            <div class="col-sm-offset-3 col-sm-9">
+              <div class="checkbox checkbox-primary">
+                <input id="bucket_quota_max_objects_unlimited"
+                       type="checkbox"
+                       formControlName="bucket_quota_max_objects_unlimited">
+                <label for="bucket_quota_max_objects_unlimited"
+                       i18n>Unlimited objects
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <!-- Maximum objects -->
+          <div class="form-group"
+               [ngClass]="{'has-error': (frm.submitted || userForm.controls.bucket_quota_max_objects.dirty) && userForm.controls.bucket_quota_max_objects.invalid}"
+               *ngIf="!userForm.controls.bucket_quota_max_objects_unlimited.value">
+            <label class="control-label col-sm-3"
+                   for="bucket_quota_max_objects"
+                   i18n>Max. objects
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input id="bucket_quota_max_objects"
+                     class="form-control"
+                     type="number"
+                     formControlName="bucket_quota_max_objects">
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.bucket_quota_max_objects.dirty) && userForm.controls.bucket_quota_max_objects.hasError('required')"
+                    i18n>
+                This field is required.
+              </span>
+              <span class="help-block"
+                    *ngIf="(frm.submitted || userForm.controls.bucket_quota_max_objects.dirty) && userForm.controls.bucket_quota_max_objects.hasError('min')"
+                    i18n>
+              The entered value must be >= 0.
+            </span>
+            </div>
+          </div>
+        </fieldset>
+      </div>
+
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button (submitAction)="onSubmit()"
+                            [form]="userForm"
+                            i18n>
+            {editing, select, 1 {Update} other {Add}}
+          </cd-submit-button>
+          <button class="btn btn-sm btn-default"
+                  type="button"
+                  routerLink="/rgw/user"
+                  i18n>
+            Back
+          </button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
new file mode 100644 (file)
index 0000000..573a86d
--- /dev/null
@@ -0,0 +1,130 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { BsModalService } from 'ngx-bootstrap/modal';
+import 'rxjs/add/observable/of';
+import { Observable } from 'rxjs/Observable';
+
+import { RgwUserService } from '../../../shared/api/rgw-user.service';
+import { SharedModule } from '../../../shared/shared.module';
+import { RgwUserFormComponent } from './rgw-user-form.component';
+
+describe('RgwUserFormComponent', () => {
+  let component: RgwUserFormComponent;
+  let fixture: ComponentFixture<RgwUserFormComponent>;
+  let queryResult: Array<string> = [];
+
+  class MockRgwUserService extends RgwUserService {
+    enumerate() {
+      return Observable.of(queryResult);
+    }
+  }
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwUserFormComponent ],
+      imports: [
+        HttpClientTestingModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule
+      ],
+      providers: [
+        BsModalService,
+        { provide: RgwUserService, useClass: MockRgwUserService }
+      ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwUserFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('quotaMaxSizeValidator', () => {
+    it('should validate max size (1/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl(''));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate max size (2/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('xxxx'));
+      expect(resp.quotaMaxSize).toBeTruthy();
+    });
+
+    it('should validate max size (3/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('1023'));
+      expect(resp.quotaMaxSize).toBeTruthy();
+    });
+
+    it('should validate max size (4/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('1024'));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate max size (5/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('1M'));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate max size (6/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('1024 gib'));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate max size (7/7)', () => {
+      const resp = component.quotaMaxSizeValidator(new FormControl('10 X'));
+      expect(resp.quotaMaxSize).toBeTruthy();
+    });
+  });
+
+  describe('userIdValidator', () => {
+    it('should validate user id (1/3)', () => {
+      const validatorFn = component.userIdValidator();
+      const ctrl = new FormControl('');
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp).toBe(null);
+        });
+      }
+    });
+
+    it('should validate user id (2/3)', () => {
+      const validatorFn = component.userIdValidator();
+      const ctrl = new FormControl('ab');
+      ctrl.markAsDirty();
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp).toBe(null);
+        });
+      }
+    });
+
+    it('should validate user id (3/3)', () => {
+      queryResult = ['abc'];
+      const validatorFn = component.userIdValidator();
+      const ctrl = new FormControl('abc');
+      ctrl.markAsDirty();
+      const validatorPromise = validatorFn(ctrl);
+      expect(validatorPromise instanceof Promise).toBeTruthy();
+      if (validatorPromise instanceof Promise) {
+        validatorPromise.then((resp) => {
+          expect(resp instanceof Object).toBeTruthy();
+          expect(resp.userIdExists).toBeTruthy();
+        });
+      }
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.ts
new file mode 100644 (file)
index 0000000..984906c
--- /dev/null
@@ -0,0 +1,722 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import {
+  AbstractControl,
+  AsyncValidatorFn,
+  FormBuilder,
+  FormGroup,
+  ValidationErrors,
+  Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import * as _ from 'lodash';
+import { BsModalService } from 'ngx-bootstrap';
+import 'rxjs/add/observable/forkJoin';
+import { Observable } from 'rxjs/Observable';
+
+import { RgwUserService } from '../../../shared/api/rgw-user.service';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { CdValidators, isEmptyInputValue } from '../../../shared/validators/cd-validators';
+import { RgwUserCapability } from '../models/rgw-user-capability';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+import { RgwUserSubuser } from '../models/rgw-user-subuser';
+import { RgwUserSwiftKey } from '../models/rgw-user-swift-key';
+import {
+  RgwUserCapabilityModalComponent
+} from '../rgw-user-capability-modal/rgw-user-capability-modal.component';
+import {
+  RgwUserS3KeyModalComponent
+} from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import {
+  RgwUserSubuserModalComponent
+} from '../rgw-user-subuser-modal/rgw-user-subuser-modal.component';
+import {
+  RgwUserSwiftKeyModalComponent
+} from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
+
+@Component({
+  selector: 'cd-rgw-user-form',
+  templateUrl: './rgw-user-form.component.html',
+  styleUrls: ['./rgw-user-form.component.scss']
+})
+export class RgwUserFormComponent implements OnInit, OnDestroy {
+
+  userForm: FormGroup;
+  routeParamsSubscribe: any;
+  editing = false;
+  error = false;
+  loading = false;
+  submitObservables: Observable<Object>[] = [];
+
+  subusers: RgwUserSubuser[] = [];
+  s3Keys: RgwUserS3Key[] = [];
+  swiftKeys: RgwUserSwiftKey[] = [];
+  capabilities: RgwUserCapability[] = [];
+
+  constructor(private formBuilder: FormBuilder,
+              private route: ActivatedRoute,
+              private router: Router,
+              private rgwUserService: RgwUserService,
+              private bsModalService: BsModalService) {
+    this.createForm();
+    this.listenToChanges();
+  }
+
+  createForm() {
+    this.userForm = this.formBuilder.group({
+      // General
+      'user_id': [
+        null,
+        [Validators.required],
+        [this.userIdValidator()]
+      ],
+      'display_name': [
+        null,
+        [Validators.required]
+      ],
+      'email': [
+        null,
+        [CdValidators.email]
+      ],
+      'max_buckets': [
+        null,
+        [Validators.min(0)]
+      ],
+      'suspended': [
+        false
+      ],
+      // S3 key
+      'generate_key': [
+        true
+      ],
+      'access_key': [
+        null,
+        [CdValidators.requiredIf({'generate_key': false})]
+      ],
+      'secret_key': [
+        null,
+        [CdValidators.requiredIf({'generate_key': false})]
+      ],
+      // User quota
+      'user_quota_enabled': [
+        false
+      ],
+      'user_quota_max_size_unlimited': [
+        true
+      ],
+      'user_quota_max_size': [
+        null,
+        [
+          CdValidators.requiredIf({
+            'user_quota_enabled': true,
+            'user_quota_max_size_unlimited': false
+          }),
+          this.quotaMaxSizeValidator
+        ]
+      ],
+      'user_quota_max_objects_unlimited': [
+        true
+      ],
+      'user_quota_max_objects': [
+        null,
+        [
+          Validators.min(0),
+          CdValidators.requiredIf({
+            'user_quota_enabled': true,
+            'user_quota_max_objects_unlimited': false
+          })
+        ]
+      ],
+      // Bucket quota
+      'bucket_quota_enabled': [
+        false
+      ],
+      'bucket_quota_max_size_unlimited': [
+        true
+      ],
+      'bucket_quota_max_size': [
+        null,
+        [
+          CdValidators.requiredIf({
+            'bucket_quota_enabled': true,
+            'bucket_quota_max_size_unlimited': false
+          }),
+          this.quotaMaxSizeValidator
+        ]
+      ],
+      'bucket_quota_max_objects_unlimited': [
+        true
+      ],
+      'bucket_quota_max_objects': [
+        null,
+        [
+          Validators.min(0),
+          CdValidators.requiredIf({
+            'bucket_quota_enabled': true,
+            'bucket_quota_max_objects_unlimited': false
+          })
+        ]
+      ]
+    });
+  }
+
+  listenToChanges() {
+    // Reset the validation status of various controls, especially those that are using
+    // the 'requiredIf' validator. This is necessary because the controls itself are not
+    // validated again if the status of their prerequisites have been changed.
+    this.userForm.get('generate_key').valueChanges.subscribe(() => {
+      ['access_key', 'secret_key'].forEach((path) => {
+        this.userForm.get(path).updateValueAndValidity({onlySelf: true});
+      });
+    });
+    this.userForm.get('user_quota_enabled').valueChanges.subscribe(() => {
+      ['user_quota_max_size', 'user_quota_max_objects'].forEach((path) => {
+        this.userForm.get(path).updateValueAndValidity({onlySelf: true});
+      });
+    });
+    this.userForm.get('user_quota_max_size_unlimited').valueChanges.subscribe(() => {
+      this.userForm.get('user_quota_max_size').updateValueAndValidity({onlySelf: true});
+    });
+    this.userForm.get('user_quota_max_objects_unlimited').valueChanges.subscribe(() => {
+      this.userForm.get('user_quota_max_objects').updateValueAndValidity({onlySelf: true});
+    });
+    this.userForm.get('bucket_quota_enabled').valueChanges.subscribe(() => {
+      ['bucket_quota_max_size', 'bucket_quota_max_objects'].forEach((path) => {
+        this.userForm.get(path).updateValueAndValidity({onlySelf: true});
+      });
+    });
+    this.userForm.get('bucket_quota_max_size_unlimited').valueChanges.subscribe(() => {
+      this.userForm.get('bucket_quota_max_size').updateValueAndValidity({onlySelf: true});
+    });
+    this.userForm.get('bucket_quota_max_objects_unlimited').valueChanges.subscribe(() => {
+      this.userForm.get('bucket_quota_max_objects').updateValueAndValidity({onlySelf: true});
+    });
+  }
+
+  ngOnInit() {
+    // Process route parameters.
+    this.routeParamsSubscribe = this.route.params
+      .subscribe((params: {uid: string}) => {
+        if (!params.hasOwnProperty('uid')) {
+          return;
+        }
+        this.loading = true;
+        // Load the user data in 'edit' mode.
+        this.editing = true;
+        // Load the user and quota information.
+        const observables = [];
+        observables.push(this.rgwUserService.get(params.uid));
+        observables.push(this.rgwUserService.getQuota(params.uid));
+        Observable.forkJoin(observables)
+          .subscribe((resp: any[]) => {
+            this.loading = false;
+            // Get the default values.
+            const defaults = _.clone(this.userForm.value);
+            // Extract the values displayed in the form.
+            let value = _.pick(resp[0], _.keys(this.userForm.value));
+            // Map the quota values.
+            ['user', 'bucket'].forEach((type) => {
+              const quota = resp[1][type + '_quota'];
+              value[type + '_quota_enabled'] = quota.enabled;
+              if (quota.max_size < 0) {
+                value[type + '_quota_max_size_unlimited'] = true;
+                value[type + '_quota_max_size'] = null;
+              } else {
+                value[type + '_quota_max_size_unlimited'] = false;
+                value[type + '_quota_max_size'] = quota.max_size;
+              }
+              if (quota.max_objects < 0) {
+                value[type + '_quota_max_size_unlimited'] = true;
+                value[type + '_quota_max_size'] = null;
+              } else {
+                value[type + '_quota_max_objects_unlimited'] = false;
+                value[type + '_quota_max_objects'] = quota.max_objects;
+              }
+            });
+            // Merge with default values.
+            value = _.merge(defaults, value);
+            // Update the form.
+            this.userForm.setValue(value);
+
+            // Get the sub users.
+            this.subusers = resp[0].subusers;
+
+            // Get the keys.
+            this.s3Keys = resp[0].keys;
+            this.swiftKeys = resp[0].swift_keys;
+
+            // Process the capabilities.
+            const mapPerm = {'read, write': '*'};
+            resp[0].caps.forEach((cap) => {
+              if (cap.perm in mapPerm) {
+                cap.perm = mapPerm[cap.perm];
+              }
+            });
+            this.capabilities = resp[0].caps;
+          }, (error) => {
+            this.error = error;
+          });
+      });
+  }
+
+  ngOnDestroy() {
+    this.routeParamsSubscribe.unsubscribe();
+  }
+
+  goToListView() {
+    this.router.navigate(['/rgw/user']);
+  }
+
+  onSubmit() {
+    // Exit immediately if the form isn't dirty.
+    if (this.userForm.pristine) {
+      this.goToListView();
+    }
+    if (this.editing) { // Edit
+      if (this._isGeneralDirty()) {
+        const args = this._getApiPostArgs();
+        this.submitObservables.push(this.rgwUserService.post(args));
+      }
+    } else { // Add
+      const args = this._getApiPutArgs();
+      this.submitObservables.push(this.rgwUserService.put(args));
+    }
+    // Check if user quota has been modified.
+    if (this._isUserQuotaDirty()) {
+      const userQuotaArgs = this._getApiUserQuotaArgs();
+      this.submitObservables.push(this.rgwUserService.putQuota(userQuotaArgs));
+    }
+    // Check if bucket quota has been modified.
+    if (this._isBucketQuotaDirty()) {
+      const bucketQuotaArgs = this._getApiBucketQuotaArgs();
+      this.submitObservables.push(this.rgwUserService.putQuota(bucketQuotaArgs));
+    }
+    // Finally execute all observables.
+    Observable.forkJoin(this.submitObservables)
+      .subscribe(() => {
+        this.goToListView();
+      }, () => {
+        // Reset the 'Submit' button.
+        this.userForm.setErrors({'cdSubmitButton': true});
+      });
+  }
+
+  /**
+   * Validate the quota maximum size, e.g. 1096, 1K, 30M. Only integer numbers are valid,
+   * something like 1.9M is not recognized as valid.
+   */
+  quotaMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+    if (isEmptyInputValue(control.value)) {
+      return null;
+    }
+    const m = RegExp('^(\\d+)\\s*(B|K(B|iB)?|M(B|iB)?|G(B|iB)?|T(B|iB)?)?$',
+      'i').exec(control.value);
+    if (m === null) {
+      return {'quotaMaxSize': true};
+    }
+    const bytes = new FormatterService().toBytes(control.value);
+    return (bytes < 1024) ? {'quotaMaxSize': true} : null;
+  }
+
+  /**
+   * Validate the username.
+   */
+  userIdValidator(): AsyncValidatorFn {
+    const rgwUserService = this.rgwUserService;
+    return (control: AbstractControl): Promise<ValidationErrors | null> => {
+      return new Promise((resolve) => {
+        // Exit immediately if user has not interacted with the control yet
+        // or the control value is empty.
+        if (control.pristine || control.value === '') {
+          resolve(null);
+          return;
+        }
+        rgwUserService.exists(control.value)
+          .subscribe((resp: boolean) => {
+            if (!resp) {
+              resolve(null);
+            } else {
+              resolve({'userIdExists': true});
+            }
+          });
+      });
+    };
+  }
+
+  /**
+   * Add/Update a subuser.
+   */
+  setSubuser(subuser: RgwUserSubuser, index?: number) {
+    if (_.isNumber(index)) { // Modify
+      // Create an observable to modify the subuser when the form is submitted.
+      this.submitObservables.push(this.rgwUserService.addSubuser(
+        this.userForm.get('user_id').value, subuser.id, subuser.permissions,
+        subuser.secret_key, subuser.generate_secret));
+      this.subusers[index] = subuser;
+    } else { // Add
+      // Create an observable to add the subuser when the form is submitted.
+      this.submitObservables.push(this.rgwUserService.addSubuser(
+        this.userForm.get('user_id').value, subuser.id, subuser.permissions,
+        subuser.secret_key, subuser.generate_secret));
+      this.subusers.push(subuser);
+      // Add a Swift key. If the secret key is auto-generated, then visualize
+      // this to the user by displaying a notification instead of the key.
+      this.swiftKeys.push({
+        'user': subuser.id,
+        'secret_key': subuser.generate_secret ?
+          'Apply your changes first...' : subuser.secret_key
+      });
+    }
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Delete a subuser.
+   * @param {number} index The subuser to delete.
+   */
+  deleteSubuser(index: number) {
+    const subuser = this.subusers[index];
+    // Create an observable to delete the subuser when the form is submitted.
+    this.submitObservables.push(this.rgwUserService.deleteSubuser(
+      this.userForm.get('user_id').value, subuser.id));
+    // Remove the associated S3 keys.
+    this.s3Keys = this.s3Keys.filter((key) => {
+      return key.user !== subuser.id;
+    });
+    // Remove the associated Swift keys.
+    this.swiftKeys = this.swiftKeys.filter((key) => {
+      return key.user !== subuser.id;
+    });
+    // Remove the subuser to update the UI.
+    this.subusers.splice(index, 1);
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Add/Update a capability.
+   */
+  setCapability(cap: RgwUserCapability, index?: number) {
+    const uid = this.userForm.get('user_id').value;
+    if (_.isNumber(index)) { // Modify
+      const oldCap = this.capabilities[index];
+      // Note, the RadosGW Admin OPS API does not support the modification of
+      // user capabilities. Because of that it is necessary to delete it and
+      // then to re-add the capability with its new value/permission.
+      this.submitObservables.push(this.rgwUserService.deleteCapability(
+        uid, oldCap.type, oldCap.perm));
+      this.submitObservables.push(this.rgwUserService.addCapability(
+        uid, cap.type, cap.perm));
+      this.capabilities[index] = cap;
+    } else { // Add
+      // Create an observable to add the capability when the form is submitted.
+      this.submitObservables.push(this.rgwUserService.addCapability(
+        uid, cap.type, cap.perm));
+      this.capabilities.push(cap);
+    }
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Delete the given capability:
+   * - Delete it from the local array to update the UI
+   * - Create an observable that will be executed on form submit
+   * @param {number} index The capability to delete.
+   */
+  deleteCapability(index: number) {
+    const cap = this.capabilities[index];
+    // Create an observable to delete the capability when the form is submitted.
+    this.submitObservables.push(this.rgwUserService.deleteCapability(
+      this.userForm.get('user_id').value, cap.type, cap.perm));
+    // Remove the capability to update the UI.
+    this.capabilities.splice(index, 1);
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Add/Update a S3 key.
+   */
+  setS3Key(key: RgwUserS3Key, index?: number) {
+    if (_.isNumber(index)) { // Modify
+      // Nothing to do here at the moment.
+    } else { // Add
+      // Create an observable to add the S3 key when the form is submitted.
+      this.submitObservables.push(this.rgwUserService.addS3Key(
+        this.userForm.get('user_id').value, key.user, key.access_key,
+        key.secret_key, key.generate_key));
+      // If the access and the secret key are auto-generated, then visualize
+      // this to the user by displaying a notification instead of the key.
+      this.s3Keys.push({
+        'user': key.user,
+        'access_key': key.generate_key ? 'Apply your changes first...' : key.access_key,
+        'secret_key': key.generate_key ? 'Apply your changes first...' : key.secret_key
+      });
+    }
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Delete a S3 key.
+   * @param {number} index The S3 key to delete.
+   */
+  deleteS3Key(index: number) {
+    const key = this.s3Keys[index];
+    // Create an observable to delete the S3 key when the form is submitted.
+    this.submitObservables.push(this.rgwUserService.deleteS3Key(
+      this.userForm.get('user_id').value, key.access_key));
+    // Remove the S3 key to update the UI.
+    this.s3Keys.splice(index, 1);
+    // Mark the form as dirty to be able to submit it.
+    this.userForm.markAsDirty();
+  }
+
+  /**
+   * Show the specified subuser in a modal dialog.
+   * @param {number | undefined} index The subuser to show.
+   */
+  showSubuserModal(index?: number) {
+    const uid = this.userForm.get('user_id').value;
+    const modalRef = this.bsModalService.show(RgwUserSubuserModalComponent);
+    if (_.isNumber(index)) { // Edit
+      const subuser = this.subusers[index];
+      modalRef.content.setEditing();
+      modalRef.content.setValues(uid, subuser.id, subuser.permissions);
+    } else { // Add
+      modalRef.content.setEditing(false);
+      modalRef.content.setValues(uid);
+      modalRef.content.setSubusers(this.subusers);
+    }
+    modalRef.content.submitAction.subscribe((subuser: RgwUserSubuser) => {
+      this.setSubuser(subuser, index);
+    });
+  }
+
+  /**
+   * Show the specified S3 key in a modal dialog.
+   * @param {number | undefined} index The S3 key to show.
+   */
+  showS3KeyModal(index?: number) {
+    const modalRef = this.bsModalService.show(RgwUserS3KeyModalComponent);
+    if (_.isNumber(index)) { // View
+      const key = this.s3Keys[index];
+      modalRef.content.setViewing();
+      modalRef.content.setValues(key.user, key.access_key, key.secret_key);
+    } else { // Add
+      const candidates = this._getS3KeyUserCandidates();
+      modalRef.content.setViewing(false);
+      modalRef.content.setUserCandidates(candidates);
+      modalRef.content.submitAction.subscribe((key: RgwUserS3Key) => {
+        this.setS3Key(key);
+      });
+    }
+  }
+
+  /**
+   * Show the specified Swift key in a modal dialog.
+   * @param {number} index The Swift key to show.
+   */
+  showSwiftKeyModal(index: number) {
+    const modalRef = this.bsModalService.show(RgwUserSwiftKeyModalComponent);
+    const key = this.swiftKeys[index];
+    modalRef.content.setValues(key.user, key.secret_key);
+  }
+
+  /**
+   * Show the specified capability in a modal dialog.
+   * @param {number | undefined} index The S3 key to show.
+   */
+  showCapabilityModal(index?: number) {
+    const modalRef = this.bsModalService.show(RgwUserCapabilityModalComponent);
+    if (_.isNumber(index)) { // Edit
+      const cap = this.capabilities[index];
+      modalRef.content.setEditing();
+      modalRef.content.setValues(cap.type, cap.perm);
+    } else { // Add
+      modalRef.content.setEditing(false);
+      modalRef.content.setCapabilities(this.capabilities);
+    }
+    modalRef.content.submitAction.subscribe((cap: RgwUserCapability) => {
+      this.setCapability(cap, index);
+    });
+  }
+
+  /**
+   * Check if the general user settings (display name, email, ...) have been modified.
+   * @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;
+    });
+  }
+
+  /**
+   * Check if the user quota has been modified.
+   * @return {Boolean} Returns TRUE if the user quota has been modified.
+   */
+  private _isUserQuotaDirty(): boolean {
+    return [
+      'user_quota_enabled',
+      'user_quota_max_size_unlimited',
+      'user_quota_max_size',
+      'user_quota_max_objects_unlimited',
+      'user_quota_max_objects'
+    ].some((path) => {
+      return this.userForm.get(path).dirty;
+    });
+  }
+
+  /**
+   * Check if the bucket quota has been modified.
+   * @return {Boolean} Returns TRUE if the bucket quota has been modified.
+   */
+  private _isBucketQuotaDirty(): boolean {
+    return [
+      'bucket_quota_enabled',
+      'bucket_quota_max_size_unlimited',
+      'bucket_quota_max_size',
+      'bucket_quota_max_objects_unlimited',
+      'bucket_quota_max_objects'
+    ].some((path) => {
+      return this.userForm.get(path).dirty;
+    });
+  }
+
+  /**
+   * Helper function to get the arguments of the API request when a new
+   * user is created.
+   */
+  private _getApiPutArgs() {
+    const result = {
+      'uid': this.userForm.get('user_id').value,
+      'display-name': this.userForm.get('display_name').value
+    };
+    const suspendedCtl = this.userForm.get('suspended');
+    if (suspendedCtl.value) {
+      _.extend(result, {'suspended': suspendedCtl.value});
+    }
+    const emailCtl = this.userForm.get('email');
+    if (_.isString(emailCtl.value) && emailCtl.value.length > 0) {
+      _.extend(result, { 'email': emailCtl.value });
+    }
+    const maxBucketsCtl = this.userForm.get('max_buckets');
+    if (maxBucketsCtl.value > 0) {
+      _.extend(result, {'max-buckets': maxBucketsCtl.value});
+    }
+    const generateKeyCtl = this.userForm.get('generate_key');
+    if (!generateKeyCtl.value) {
+      _.extend(result, {
+        'access-key': this.userForm.get('access_key').value,
+        'secret-key': this.userForm.get('secret_key').value
+      });
+    } else {
+      _.extend(result, {'generate-key': true});
+    }
+    return result;
+  }
+
+  /**
+   * Helper function to get the arguments for the API request when the user
+   * configuration has been modified.
+   */
+  private _getApiPostArgs() {
+    const result = {
+      'uid': this.userForm.get('user_id').value
+    };
+    const argsMap = {
+      'display-name': 'display_name',
+      'email': 'email',
+      'max-buckets': 'max_buckets',
+      'suspended': 'suspended'
+    };
+    for (const key of Object.keys(argsMap)) {
+      const ctl = this.userForm.get(argsMap[key]);
+      if (ctl.dirty) {
+        result[key] = ctl.value;
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Helper function to get the arguments for the API request when the user
+   * quota configuration has been modified.
+   */
+  private _getApiUserQuotaArgs(): object {
+    const result = {
+      'uid': this.userForm.get('user_id').value,
+      'quota-type': 'user',
+      'enabled': this.userForm.get('user_quota_enabled').value,
+      'max-size-kb': -1,
+      'max-objects': -1
+    };
+    if (!this.userForm.get('user_quota_max_size_unlimited').value) {
+      // Convert the given value to bytes.
+      const bytes = new FormatterService().toBytes(this.userForm.get(
+        'user_quota_max_size').value);
+      // Finally convert the value to KiB.
+      result['max-size-kb'] = (bytes / 1024).toFixed(0) as any;
+    }
+    if (!this.userForm.get('user_quota_max_objects_unlimited').value) {
+      result['max-objects'] = this.userForm.get('user_quota_max_objects').value;
+    }
+    return result;
+  }
+
+  /**
+   * Helper function to get the arguments for the API request when the bucket
+   * quota configuration has been modified.
+   */
+  private _getApiBucketQuotaArgs(): object {
+    const result = {
+      'uid': this.userForm.get('user_id').value,
+      'quota-type': 'bucket',
+      'enabled': this.userForm.get('bucket_quota_enabled').value,
+      'max-size-kb': -1,
+      'max-objects': -1
+    };
+    if (!this.userForm.get('bucket_quota_max_size_unlimited').value) {
+      // Convert the given value to bytes.
+      const bytes = new FormatterService().toBytes(this.userForm.get(
+        'bucket_quota_max_size').value);
+      // Finally convert the value to KiB.
+      result['max-size-kb'] = (bytes / 1024).toFixed(0) as any;
+    }
+    if (!this.userForm.get('bucket_quota_max_objects_unlimited').value) {
+      result['max-objects'] = this.userForm.get('bucket_quota_max_objects').value;
+    }
+    return result;
+  }
+
+  /**
+   * Helper method to get the user candidates for S3 keys.
+   * @returns {Array} Returns a list of user identifiers.
+   */
+  private _getS3KeyUserCandidates() {
+    let result = [];
+    // Add the current user id.
+    const user_id = this.userForm.get('user_id').value;
+    if (_.isString(user_id) && !_.isEmpty(user_id)) {
+      result.push(user_id);
+    }
+    // Append the subusers.
+    this.subusers.forEach((subUser) => {
+      result.push(subUser.id);
+    });
+    // Note that it's possible to create multiple S3 key pairs for a user,
+    // thus we append already configured users, too.
+    this.s3Keys.forEach((key) => {
+      result.push(key.user);
+    });
+    result = _.uniq(result);
+    return result;
+  }
+}
index 66434a0e66605984ca9509316d62229b44731979..b39c007a3e587be7f825ff907767e3b1605c0121 100644 (file)
@@ -7,13 +7,76 @@
         aria-current="page">Users</li>
   </ol>
 </nav>
-<cd-table [data]="users"
+<cd-table #table
+          [autoReload]="false"
+          [data]="users"
           [columns]="columns"
           columnMode="flex"
-          selectionType="single"
+          selectionType="multi"
           (updateSelection)="updateSelection($event)"
           identifier="user_id"
           (fetchData)="getUserList()">
+  <div class="table-actions">
+    <div class="btn-group" dropdown>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="!selection.hasSelection"
+              routerLink="/rgw/user/add">
+        <i class="fa fa-fw fa-plus"></i>
+        <ng-container i18n>Add</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasSingleSelection"
+              routerLink="/rgw/user/edit/{{ selection.first()?.user_id }}">
+        <i class="fa fa-fw fa-pencil"></i>
+        <ng-container i18n>Edit</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary"
+              *ngIf="selection.hasMultiSelection"
+              (click)="deleteAction()">
+        <i class="fa fa-fw fa-trash-o"></i>
+        <ng-container i18n>Delete</ng-container>
+      </button>
+      <button type="button"
+              class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split"
+              dropdownToggle>
+        <span class="caret"></span>
+        <span class="sr-only"></span>
+      </button>
+      <ul class="dropdown-menu"
+          *dropdownMenu
+          role="menu">
+        <li role="menuitem">
+          <a class="dropdown-item"
+             routerLink="/rgw/user/add"
+             i18n>
+            <i class="fa fa-fw fa-plus"></i>
+            Add
+          </a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSingleSelection}">
+          <a class="dropdown-item"
+             routerLink="/rgw/user/edit/{{ selection.first()?.user_id }}"
+             i18n>
+            <i class="fa fa-fw fa-pencil"></i>
+            Edit
+          </a>
+        </li>
+        <li role="menuitem"
+            [ngClass]="{'disabled': !selection.hasSelection}">
+          <a class="dropdown-item"
+             (click)="deleteAction()"
+             i18n>
+            <i class="fa fa-fw fa-trash-o"></i>
+            Delete
+          </a>
+        </li>
+      </ul>
+    </div>
+  </div>
   <cd-rgw-user-details cdTableDetail
                        [selection]="selection">
   </cd-rgw-user-details>
index 1b0c18b8550db3b52c9a285877f6064c1580518b..04cafb7ad3cd480768c1ded5d25924e2df2a232f 100644 (file)
@@ -1,7 +1,9 @@
 import { HttpClientModule } from '@angular/common/http';
 import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
 
 import { BsDropdownModule } from 'ngx-bootstrap';
+import { BsModalService } from 'ngx-bootstrap/modal';
 import { TabsModule } from 'ngx-bootstrap/tabs';
 
 import { SharedModule } from '../../../shared/shared.module';
@@ -20,9 +22,13 @@ describe('RgwUserListComponent', () => {
       ],
       imports: [
         HttpClientModule,
+        RouterTestingModule,
         BsDropdownModule.forRoot(),
         TabsModule.forRoot(),
         SharedModule
+      ],
+      providers: [
+        BsModalService
       ]
     })
     .compileComponents();
index 2953ab7523ac4712536a4c108fadebf791a3f4b3..3304841d7adf3a559b84f9a6e167c108a0155d38 100644 (file)
@@ -1,6 +1,16 @@
-import { Component } from '@angular/core';
+import { Component, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { BsModalService } from 'ngx-bootstrap';
+import 'rxjs/add/observable/forkJoin';
+import { Observable } from 'rxjs/Observable';
+import { Subscriber } from 'rxjs/Subscriber';
 
 import { RgwUserService } from '../../../shared/api/rgw-user.service';
+import {
+  DeletionModalComponent
+} from '../../../shared/components/deletion-modal/deletion-modal.component';
+import { TableComponent } from '../../../shared/datatable/table/table.component';
 import { CellTemplate } from '../../../shared/enum/cell-template.enum';
 import { CdTableColumn } from '../../../shared/models/cd-table-column';
 import { CdTableSelection } from '../../../shared/models/cd-table-selection';
@@ -12,11 +22,15 @@ import { CdTableSelection } from '../../../shared/models/cd-table-selection';
 })
 export class RgwUserListComponent {
 
+  @ViewChild(TableComponent) table: TableComponent;
+
   columns: CdTableColumn[] = [];
   users: object[] = [];
   selection: CdTableSelection = new CdTableSelection();
 
-  constructor(private rgwUserService: RgwUserService) {
+  constructor(private router: Router,
+              private rgwUserService: RgwUserService,
+              private bsModalService: BsModalService) {
     this.columns = [
       {
         name: 'Username',
@@ -61,4 +75,25 @@ export class RgwUserListComponent {
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
+
+  deleteAction() {
+    const modalRef = this.bsModalService.show(DeletionModalComponent);
+    modalRef.content.setUp({
+      metaType: this.selection.hasSingleSelection ? 'user' : 'users',
+      deletionObserver: (): Observable<any> => {
+        return new Observable((observer: Subscriber<any>) => {
+          Observable.forkJoin(
+            this.selection.selected.map((user: any) => {
+              return this.rgwUserService.delete(user.user_id);
+            }))
+            .subscribe(null, null, () => {
+              observer.complete();
+              // Finally reload the data table content.
+              this.table.refreshBtn();
+            });
+        });
+      },
+      modalRef: modalRef
+    });
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.html
new file mode 100644 (file)
index 0000000..64b2902
--- /dev/null
@@ -0,0 +1,158 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>S3 key
+  </h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<form class="form-horizontal"
+      #frm="ngForm"
+      [formGroup]="formGroup"
+      novalidate>
+  <div class="modal-body">
+
+    <!-- Username -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.user.dirty) && formGroup.controls.user.invalid}">
+      <label class="control-label col-sm-3"
+             for="user"
+             i18n>Username
+        <span class="required"
+              *ngIf="!viewing">
+        </span>
+      </label>
+      <div class="col-sm-9">
+        <input id="user"
+               class="form-control"
+               type="text"
+               *ngIf="viewing"
+               [readonly]="true"
+               formControlName="user">
+        <select id="user"
+                class="form-control"
+                formControlName="user"
+                *ngIf="!viewing"
+                autofocus>
+          <option i18n
+                  *ngIf="userCandidates !== null"
+                  [ngValue]="null">-- Select a username --
+          </option>
+          <option *ngFor="let userCandidate of userCandidates"
+                  [value]="userCandidate">{{ userCandidate }}</option>
+        </select>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.user.dirty) && formGroup.controls.user.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+    <!-- Auto-generate key -->
+    <div class="form-group"
+         *ngIf="!viewing">
+      <div class="col-sm-offset-3 col-sm-9">
+        <div class="checkbox checkbox-primary">
+          <input id="generate_key"
+                 type="checkbox"
+                 formControlName="generate_key">
+          <label for="generate_key"
+                 i18n>Auto-generate key
+          </label>
+        </div>
+      </div>
+    </div>
+
+    <!-- Access key -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.access_key.dirty) && formGroup.controls.access_key.invalid}"
+         *ngIf="!formGroup.controls.generate_key.value">
+      <label class="control-label col-sm-3"
+             for="access_key"
+             i18n>Access key
+        <span class="required"
+              *ngIf="!viewing">
+        </span>
+      </label>
+      <div class="col-sm-9">
+        <div class="input-group">
+          <input id="access_key"
+                 class="form-control"
+                 type="password"
+                 [readonly]="viewing"
+                 formControlName="access_key">
+          <span class="input-group-btn">
+            <button type="button"
+                    class="btn btn-default"
+                    cdPasswordButton="access_key">
+            </button>
+            <button type="button"
+                    class="btn btn-default"
+                    cdCopy2ClipboardButton="access_key">
+            </button>
+          </span>
+        </div>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.access_key.dirty) && formGroup.controls.access_key.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+    <!-- Secret key -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.secret_key.dirty) && formGroup.controls.secret_key.invalid}"
+         *ngIf="!formGroup.controls.generate_key.value">
+      <label class="control-label col-sm-3"
+             for="secret_key"
+             i18n>Secret key
+        <span class="required"
+              *ngIf="!viewing">
+        </span>
+      </label>
+      <div class="col-sm-9">
+        <div class="input-group">
+          <input id="secret_key"
+                 class="form-control"
+                 type="password"
+                 [readonly]="viewing"
+                 formControlName="secret_key">
+          <span class="input-group-btn">
+            <button type="button"
+                    class="btn btn-default"
+                    cdPasswordButton="secret_key">
+            </button>
+            <button type="button"
+                    class="btn btn-default"
+                    cdCopy2ClipboardButton="secret_key">
+            </button>
+          </span>
+        </div>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.secret_key.dirty) && formGroup.controls.secret_key.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+  </div>
+  <div class="modal-footer">
+    <cd-submit-button *ngIf="!viewing"
+                      (submitAction)="onSubmit()"
+                      [form]="formGroup"
+                      i18n>Add
+    </cd-submit-button>
+    <button class="btn btn-sm"
+            type="button"
+            [ngClass]="{'btn-primary': viewing, 'btn-default': !viewing}"
+            (click)="bsModalRef.hide()"
+            i18n>Close
+    </button>
+  </div>
+</form>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..382f9df
--- /dev/null
@@ -0,0 +1,34 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal.component';
+
+describe('RgwUserS3KeyModalComponent', () => {
+  let component: RgwUserS3KeyModalComponent;
+  let fixture: ComponentFixture<RgwUserS3KeyModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwUserS3KeyModalComponent ],
+      imports: [
+        ReactiveFormsModule,
+        SharedModule
+      ],
+      providers: [ BsModalRef ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwUserS3KeyModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-s3-key-modal/rgw-user-s3-key-modal.component.ts
new file mode 100644 (file)
index 0000000..613962f
--- /dev/null
@@ -0,0 +1,97 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service';
+
+import { CdValidators } from '../../../shared/validators/cd-validators';
+import { RgwUserS3Key } from '../models/rgw-user-s3-key';
+
+@Component({
+  selector: 'cd-rgw-user-s3-key-modal',
+  templateUrl: './rgw-user-s3-key-modal.component.html',
+  styleUrls: ['./rgw-user-s3-key-modal.component.scss']
+})
+export class RgwUserS3KeyModalComponent {
+
+  /**
+   * The event that is triggered when the 'Add' button as been pressed.
+   */
+  @Output() submitAction = new EventEmitter();
+
+  formGroup: FormGroup;
+  viewing = true;
+  userCandidates: string[] = [];
+
+  constructor(private formBuilder: FormBuilder,
+              public bsModalRef: BsModalRef) {
+    this.createForm();
+    this.listenToChanges();
+  }
+
+  createForm() {
+    this.formGroup = this.formBuilder.group({
+      'user': [
+        null,
+        [Validators.required]
+      ],
+      'generate_key': [
+        true
+      ],
+      'access_key': [
+        null,
+        [CdValidators.requiredIf({'generate_key': false})]
+      ],
+      'secret_key': [
+        null,
+        [CdValidators.requiredIf({'generate_key': false})]
+      ]
+    });
+  }
+
+  listenToChanges() {
+    // Reset the validation status of various controls, especially those that are using
+    // the 'requiredIf' validator. This is necessary because the controls itself are not
+    // validated again if the status of their prerequisites have been changed.
+    this.formGroup.get('generate_key').valueChanges.subscribe(() => {
+      ['access_key', 'secret_key'].forEach((path) => {
+        this.formGroup.get(path).updateValueAndValidity({onlySelf: true});
+      });
+    });
+  }
+
+  /**
+   * Set the 'viewing' flag. If set to TRUE, the modal dialog is in 'View' mode,
+   * otherwise in 'Add' mode. According to the mode the dialog and its controls
+   * behave different.
+   * @param {boolean} viewing
+   */
+  setViewing(viewing: boolean = true) {
+    this.viewing = viewing;
+  }
+
+  /**
+   * Set the values displayed in the dialog.
+   */
+  setValues(user: string, access_key: string, secret_key: string) {
+    this.formGroup.setValue({
+      'user': user,
+      'generate_key': _.isEmpty(access_key),
+      'access_key': access_key,
+      'secret_key': secret_key
+    });
+  }
+
+  /**
+   * Set the user candidates displayed in the 'Username' dropdown box.
+   */
+  setUserCandidates(candidates: string[]) {
+    this.userCandidates = candidates;
+  }
+
+  onSubmit() {
+    const key: RgwUserS3Key = this.formGroup.value;
+    this.submitAction.emit(key);
+    this.bsModalRef.hide();
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.html
new file mode 100644 (file)
index 0000000..12b7148
--- /dev/null
@@ -0,0 +1,168 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>Subuser
+  </h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<form class="form-horizontal"
+      #frm="ngForm"
+      [formGroup]="formGroup"
+      novalidate>
+  <div class="modal-body">
+
+    <!-- Username -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.uid.dirty) && formGroup.controls.uid.invalid}">
+      <label class="control-label col-sm-3"
+             for="uid"
+             i18n>Username
+      </label>
+      <div class="col-sm-9">
+        <input id="uid"
+               class="form-control"
+               type="text"
+               formControlName="uid"
+               [readonly]="true">
+      </div>
+    </div>
+
+    <!-- Subuser -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.subuid.dirty) && formGroup.controls.subuid.invalid}">
+      <label class="control-label col-sm-3"
+             for="subuid"
+             i18n>Subuser
+        <span class="required"
+              *ngIf="!editing">
+        </span>
+      </label>
+      <div class="col-sm-9">
+        <input id="subuid"
+               class="form-control"
+               type="text"
+               formControlName="subuid"
+               [readonly]="editing"
+               autofocus>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.subuid.dirty) && formGroup.controls.subuid.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.subuid.dirty) && formGroup.controls.subuid.hasError('subuserIdExists')"
+              i18n>
+          The chosen subuser ID is already in use.
+        </span>
+      </div>
+    </div>
+
+    <!-- Permission -->
+    <div class="form-group"
+         [ngClass]="{'has-error': (frm.submitted || formGroup.controls.perm.dirty) && formGroup.controls.perm.invalid}">
+      <label class="control-label col-sm-3"
+             for="perm"
+             i18n>Permission
+        <span class="required"></span>
+      </label>
+      <div class="col-sm-9">
+        <select id="perm"
+                class="form-control"
+                formControlName="perm">
+          <option i18n
+                  [ngValue]="null">
+            -- Select a permission --
+          </option>
+          <option *ngFor="let perm of ['read', 'write']"
+                  [value]="perm">
+            {{ perm }}
+          </option>
+          <option i18n
+                  value="read-write">
+            read, write
+          </option>
+          <option i18n
+                  value="full-control">
+            full
+          </option>
+        </select>
+        <span class="help-block"
+              *ngIf="(frm.submitted || formGroup.controls.perm.dirty) && formGroup.controls.perm.hasError('required')"
+              i18n>
+          This field is required.
+        </span>
+      </div>
+    </div>
+
+    <!-- Swift key -->
+    <fieldset *ngIf="!editing">
+      <legend i18n>Swift key</legend>
+
+      <!-- Auto-generate key -->
+      <div class="form-group">
+        <div class="col-sm-offset-3 col-sm-9">
+          <div class="checkbox checkbox-primary">
+            <input id="generate_secret"
+                   type="checkbox"
+                   formControlName="generate_secret">
+            <label for="generate_secret"
+                   i18n>Auto-generate secret
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <!-- Secret key -->
+      <div class="form-group"
+           [ngClass]="{'has-error': (frm.submitted || formGroup.controls.secret_key.dirty) && formGroup.controls.secret_key.invalid}"
+           *ngIf="!editing && !formGroup.controls.generate_secret.value">
+        <label class="control-label col-sm-3"
+               for="secret_key"
+               i18n>Secret key
+          <span class="required"></span>
+        </label>
+        <div class="col-sm-9">
+          <div class="input-group">
+            <input id="secret_key"
+                   class="form-control"
+                   type="password"
+                   formControlName="secret_key">
+            <span class="input-group-btn">
+              <button type="button"
+                      class="btn btn-default"
+                      cdPasswordButton="secret_key">
+              </button>
+              <button type="button"
+                      class="btn btn-default"
+                      cdCopy2ClipboardButton="secret_key">
+              </button>
+            </span>
+          </div>
+          <span class="help-block"
+                *ngIf="(frm.submitted || formGroup.controls.secret_key.dirty) && formGroup.controls.secret_key.hasError('required')"
+                i18n>
+            This field is required.
+          </span>
+        </div>
+      </div>
+
+    </fieldset>
+
+  </div>
+  <div class="modal-footer">
+    <cd-submit-button (submitAction)="onSubmit()"
+                      [form]="formGroup"
+                      i18n>
+      {editing, select, 1 {Update} other {Add}}
+    </cd-submit-button>
+    <button class="btn btn-sm btn-default"
+            type="button"
+            (click)="bsModalRef.hide()"
+            i18n>Close
+    </button>
+  </div>
+</form>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..6e6691a
--- /dev/null
@@ -0,0 +1,75 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { SharedModule } from '../../../shared/shared.module';
+import { RgwUserSubuserModalComponent } from './rgw-user-subuser-modal.component';
+
+describe('RgwUserSubuserModalComponent', () => {
+  let component: RgwUserSubuserModalComponent;
+  let fixture: ComponentFixture<RgwUserSubuserModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwUserSubuserModalComponent ],
+      imports: [
+        ReactiveFormsModule,
+        SharedModule
+      ],
+      providers: [ BsModalRef ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwUserSubuserModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('subuserValidator', () => {
+    beforeEach(() => {
+      component.editing = false;
+      component.subusers = [
+        {id: 'Edith', permissions: 'full-control'},
+        {id: 'Edith:images', permissions: 'read-write'}
+      ];
+    });
+
+    it('should validate subuser (1/5)', () => {
+      component.editing = true;
+      const validatorFn = component.subuserValidator();
+      const resp = validatorFn(new FormControl());
+      expect(resp).toBe(null);
+    });
+
+    it('should validate subuser (2/5)', () => {
+      const validatorFn = component.subuserValidator();
+      const resp = validatorFn(new FormControl(''));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate subuser (3/5)', () => {
+      const validatorFn = component.subuserValidator();
+      const resp = validatorFn(new FormControl('Melissa'));
+      expect(resp).toBe(null);
+    });
+
+    it('should validate subuser (4/5)', () => {
+      const validatorFn = component.subuserValidator();
+      const resp = validatorFn(new FormControl('Edith'));
+      expect(resp.subuserIdExists).toBeTruthy();
+    });
+
+    it('should validate subuser (5/5)', () => {
+      const validatorFn = component.subuserValidator();
+      const resp = validatorFn(new FormControl('images'));
+      expect(resp.subuserIdExists).toBeTruthy();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-subuser-modal/rgw-user-subuser-modal.component.ts
new file mode 100644 (file)
index 0000000..ac3597e
--- /dev/null
@@ -0,0 +1,155 @@
+import { Component, EventEmitter, Output } from '@angular/core';
+import {
+  AbstractControl,
+  FormBuilder,
+  FormGroup,
+  ValidationErrors,
+  ValidatorFn,
+  Validators
+} from '@angular/forms';
+
+import * as _ from 'lodash';
+import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service';
+
+import { CdValidators, isEmptyInputValue } from '../../../shared/validators/cd-validators';
+import { RgwUserSubuser } from '../models/rgw-user-subuser';
+
+@Component({
+  selector: 'cd-rgw-user-subuser-modal',
+  templateUrl: './rgw-user-subuser-modal.component.html',
+  styleUrls: ['./rgw-user-subuser-modal.component.scss']
+})
+export class RgwUserSubuserModalComponent {
+
+  /**
+   * The event that is triggered when the 'Add' or 'Update' button
+   * has been pressed.
+   */
+  @Output() submitAction = new EventEmitter();
+
+  formGroup: FormGroup;
+  editing = true;
+  subusers: RgwUserSubuser[] = [];
+
+  constructor(private formBuilder: FormBuilder,
+              public bsModalRef: BsModalRef) {
+    this.createForm();
+    this.listenToChanges();
+  }
+
+  createForm() {
+    this.formGroup = this.formBuilder.group({
+      'uid': [
+        null
+      ],
+      'subuid': [
+        null,
+        [
+          Validators.required,
+          this.subuserValidator()
+        ]
+      ],
+      'perm': [
+        null,
+        [Validators.required]
+      ],
+      // Swift key
+      'generate_secret': [
+        true
+      ],
+      'secret_key': [
+        null,
+        [CdValidators.requiredIf({'generate_secret': false})]
+      ]
+    });
+  }
+
+  listenToChanges() {
+    // Reset the validation status of various controls, especially those that are using
+    // the 'requiredIf' validator. This is necessary because the controls itself are not
+    // validated again if the status of their prerequisites have been changed.
+    this.formGroup.get('generate_secret').valueChanges.subscribe(() => {
+      ['secret_key'].forEach((path) => {
+        this.formGroup.get(path).updateValueAndValidity({onlySelf: true});
+      });
+    });
+  }
+
+  /**
+   * Validates whether the subuser already exists.
+   */
+  subuserValidator(): ValidatorFn {
+    const self = this;
+    return (control: AbstractControl): ValidationErrors | null => {
+      if (self.editing) {
+        return null;
+      }
+      if (isEmptyInputValue(control.value)) {
+        return null;
+      }
+      const found = self.subusers.some((subuser) => {
+        return _.isEqual(self.getSubuserName(subuser.id), control.value);
+      });
+      return found ? {'subuserIdExists': true} : null;
+    };
+  }
+
+  /**
+   * Get the subuser name.
+   * Examples:
+   *   'johndoe' => 'johndoe'
+   *   'janedoe:xyz' => 'xyz'
+   * @param {string} value The value to process.
+   * @returns {string} Returns the user ID.
+   */
+  private getSubuserName(value: string) {
+    if (_.isEmpty(value)) {
+      return value;
+    }
+    const matches = value.match(/([^:]+)(:(.+))?/);
+    return _.isUndefined(matches[3]) ? matches[1] : matches[3];
+  }
+
+  /**
+   * Set the 'editing' flag. If set to TRUE, the modal dialog is in 'Edit' mode,
+   * otherwise in 'Add' mode. According to the mode the dialog and its controls
+   * behave different.
+   * @param {boolean} viewing
+   */
+  setEditing(editing: boolean = true) {
+    this.editing = editing;
+  }
+
+  /**
+   * Set the values displayed in the dialog.
+   */
+  setValues(uid: string, subuser: string = '', permissions: string = '') {
+    this.formGroup.setValue({
+      'uid': uid,
+      'subuid': this.getSubuserName(subuser),
+      'perm': permissions,
+      'generate_secret': true,
+      'secret_key': null
+    });
+  }
+
+  /**
+   * Set the current capabilities of the user.
+   */
+  setSubusers(subusers: RgwUserSubuser[]) {
+    this.subusers = subusers;
+  }
+
+  onSubmit() {
+    // Get the values from the form and create an object that is sent
+    // by the triggered submit action event.
+    const values = this.formGroup.value;
+    const subuser = new RgwUserSubuser;
+    subuser.id = `${values.uid}:${values.subuid}`;
+    subuser.permissions = values.perm;
+    subuser.generate_secret = values.generate_secret;
+    subuser.secret_key = values.secret_key;
+    this.submitAction.emit(subuser);
+    this.bsModalRef.hide();
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.html
new file mode 100644 (file)
index 0000000..6725314
--- /dev/null
@@ -0,0 +1,67 @@
+<div class="modal-header">
+  <h4 class="modal-title pull-left"
+      i18n>Swift key
+  </h4>
+  <button type="button"
+          class="close pull-right"
+          aria-label="Close"
+          (click)="bsModalRef.hide()">
+    <span aria-hidden="true">&times;</span>
+  </button>
+</div>
+<div class="modal-body">
+  <form class="form-horizontal"
+        novalidate>
+
+    <!-- Username -->
+    <div class="form-group">
+      <label class="control-label col-sm-3"
+             for="user"
+             i18n>Username
+      </label>
+      <div class="col-sm-9">
+        <input id="user"
+               name="user"
+               class="form-control"
+               type="text"
+               [readonly]="true"
+               [(ngModel)]="user">
+      </div>
+    </div>
+
+    <!-- Secret key -->
+    <div class="form-group">
+      <label class="control-label col-sm-3"
+             for="secret_key"
+             i18n>Secret key
+      </label>
+      <div class="col-sm-9">
+        <div class="input-group">
+          <input id="secret_key"
+                 name="secret_key"
+                 class="form-control"
+                 type="password"
+                 [(ngModel)]="secret_key"
+                 [readonly]="true">
+          <span class="input-group-btn">
+            <button type="button"
+                    class="btn btn-default"
+                    cdPasswordButton="secret_key">
+            </button>
+            <button type="button"
+                    class="btn btn-default"
+                    cdCopy2ClipboardButton="secret_key">
+            </button>
+          </span>
+        </div>
+      </div>
+    </div>
+
+  </form>
+</div>
+<div class="modal-footer">
+  <button class="btn btn-sm btn-primary"
+          type="button"
+          (click)="bsModalRef.hide()"
+          i18n>Close</button>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..ac4ae53
--- /dev/null
@@ -0,0 +1,32 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+
+import { BsModalRef } from 'ngx-bootstrap/modal';
+
+import { RgwUserSwiftKeyModalComponent } from './rgw-user-swift-key-modal.component';
+
+describe('RgwUserSwiftKeyModalComponent', () => {
+  let component: RgwUserSwiftKeyModalComponent;
+  let fixture: ComponentFixture<RgwUserSwiftKeyModalComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ RgwUserSwiftKeyModalComponent ],
+      imports: [
+        FormsModule
+      ],
+      providers: [ BsModalRef ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwUserSwiftKeyModalComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-swift-key-modal/rgw-user-swift-key-modal.component.ts
new file mode 100644 (file)
index 0000000..4ebe4ad
--- /dev/null
@@ -0,0 +1,24 @@
+import { Component } from '@angular/core';
+
+import { BsModalRef } from 'ngx-bootstrap/modal/bs-modal-ref.service';
+
+@Component({
+  selector: 'cd-rgw-user-swift-key-modal',
+  templateUrl: './rgw-user-swift-key-modal.component.html',
+  styleUrls: ['./rgw-user-swift-key-modal.component.scss']
+})
+export class RgwUserSwiftKeyModalComponent {
+
+  user: string;
+  secret_key: string;
+
+  constructor(public bsModalRef: BsModalRef) {}
+
+  /**
+   * Set the values displayed in the dialog.
+   */
+  setValues(user: string, secret_key: string) {
+    this.user = user;
+    this.secret_key = secret_key;
+  }
+}
index 6e5aaa5b9d0cdebd78fc157e61d60be04f4f42ec..90623dc98684da92bc457485ca5d00d336bc7979 100644 (file)
@@ -1,32 +1,59 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 
-import { TabsModule } from 'ngx-bootstrap/tabs';
+import { BsDropdownModule, ModalModule, TabsModule, TooltipModule } from 'ngx-bootstrap';
 
+import { AppRoutingModule } from '../../app-routing.module';
 import { SharedModule } from '../../shared/shared.module';
 import { PerformanceCounterModule } from '../performance-counter/performance-counter.module';
 import { RgwBucketDetailsComponent } from './rgw-bucket-details/rgw-bucket-details.component';
+import { RgwBucketFormComponent } from './rgw-bucket-form/rgw-bucket-form.component';
 import { RgwBucketListComponent } from './rgw-bucket-list/rgw-bucket-list.component';
 import { RgwDaemonDetailsComponent } from './rgw-daemon-details/rgw-daemon-details.component';
 import { RgwDaemonListComponent } from './rgw-daemon-list/rgw-daemon-list.component';
+import {
+  RgwUserCapabilityModalComponent
+} from './rgw-user-capability-modal/rgw-user-capability-modal.component';
 import { RgwUserDetailsComponent } from './rgw-user-details/rgw-user-details.component';
+import { RgwUserFormComponent } from './rgw-user-form/rgw-user-form.component';
 import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component';
+import {
+  RgwUserS3KeyModalComponent
+} from './rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
+import {
+  RgwUserSubuserModalComponent
+} from './rgw-user-subuser-modal/rgw-user-subuser-modal.component';
+import {
+  RgwUserSwiftKeyModalComponent
+} from './rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
 
 @NgModule({
   entryComponents: [
     RgwDaemonDetailsComponent,
     RgwBucketDetailsComponent,
-    RgwUserDetailsComponent
+    RgwUserDetailsComponent,
+    RgwUserSwiftKeyModalComponent,
+    RgwUserS3KeyModalComponent,
+    RgwUserCapabilityModalComponent,
+    RgwUserSubuserModalComponent
   ],
   imports: [
     CommonModule,
     SharedModule,
+    AppRoutingModule,
+    FormsModule,
+    ReactiveFormsModule,
     PerformanceCounterModule,
-    TabsModule.forRoot()
+    BsDropdownModule.forRoot(),
+    TabsModule.forRoot(),
+    TooltipModule.forRoot(),
+    ModalModule.forRoot()
   ],
   exports: [
     RgwDaemonListComponent,
     RgwDaemonDetailsComponent,
+    RgwBucketFormComponent,
     RgwBucketListComponent,
     RgwBucketDetailsComponent,
     RgwUserListComponent,
@@ -35,10 +62,17 @@ import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component';
   declarations: [
     RgwDaemonListComponent,
     RgwDaemonDetailsComponent,
+    RgwBucketFormComponent,
     RgwBucketListComponent,
     RgwBucketDetailsComponent,
     RgwUserListComponent,
-    RgwUserDetailsComponent
+    RgwUserDetailsComponent,
+    RgwBucketFormComponent,
+    RgwUserFormComponent,
+    RgwUserSwiftKeyModalComponent,
+    RgwUserS3KeyModalComponent,
+    RgwUserCapabilityModalComponent,
+    RgwUserSubuserModalComponent
   ]
 })
 export class RgwModule { }
index adcfc4a0fe93332bd053168ad6ef58aee9b0c3b8..2b357e7edf6d9e694e764432d6fdae784b062d21 100644 (file)
@@ -1,11 +1,11 @@
-import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
+import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
+
+import * as _ from 'lodash';
 import 'rxjs/add/observable/forkJoin';
 import 'rxjs/add/observable/of';
 import { Observable } from 'rxjs/Observable';
 
-import * as _ from 'lodash';
-
 @Injectable()
 export class RgwBucketService {
 
@@ -13,32 +13,43 @@ export class RgwBucketService {
 
   constructor(private http: HttpClient) { }
 
+  /**
+   * Get the list of buckets.
+   * @return {Observable<Object[]>}
+   */
   list() {
-    return this.http.get(this.url)
+    return this.enumerate()
       .flatMap((buckets: string[]) => {
         if (buckets.length > 0) {
           return Observable.forkJoin(
             buckets.map((bucket: string) => {
               return this.get(bucket);
-            })
-          );
+            }));
         }
         return Observable.of([]);
       });
   }
 
+  /**
+   * Get the list of bucket names.
+   * @return {Observable<string[]>}
+   */
+  enumerate() {
+    return this.http.get(this.url);
+  }
+
   get(bucket: string) {
     let params = new HttpParams();
     params = params.append('bucket', bucket);
-    return this.http.get(this.url, { params: params });
+    return this.http.get(this.url, {params: params});
   }
 
   create(bucket: string, uid: string) {
-    const body = JSON.stringify({
+    const body = {
       'bucket': bucket,
       'uid': uid
-    });
-    return this.http.post(`/api/rgw/bucket`, body);
+    };
+    return this.http.post('/api/rgw/bucket', body);
   }
 
   update(bucketId: string, bucket: string, uid: string) {
@@ -46,27 +57,26 @@ export class RgwBucketService {
     params = params.append('bucket', bucket);
     params = params.append('bucket-id', bucketId as string);
     params = params.append('uid', uid);
-    return this.http.put(this.url, null, { params: params });
+    return this.http.put(this.url, null, {params: params});
   }
 
   delete(bucket: string, purgeObjects = true) {
     let params = new HttpParams();
     params = params.append('bucket', bucket);
     params = params.append('purge-objects', purgeObjects ? 'true' : 'false');
-    return this.http.delete(this.url, { params: params });
+    return this.http.delete(this.url, {params: params});
   }
 
-  find(bucket: string) {
-    let params = new HttpParams();
-    params = params.append('bucket', bucket);
-    return this.http.get(this.url, { params: params })
-      .flatMap((resp: object | null) => {
-        // Make sure we have received valid data.
-        if ((null === resp) || (!_.isObjectLike(resp))) {
-          return Observable.of([]);
-        }
-        // Return an array to be able to support wildcard searching someday.
-        return Observable.of([resp]);
+  /**
+   * Check if the specified bucket exists.
+   * @param {string} uid The bucket name to check.
+   * @return {Observable<boolean>}
+   */
+  exists(bucket: string) {
+    return this.enumerate()
+      .flatMap((resp: string[]) => {
+        const index = _.indexOf(resp, bucket);
+        return Observable.of(-1 !== index);
       });
   }
 }
index 9585fdfa501a59ef8d6f292e7a987ffca0e55434..06766f991169d81ccaee2ad087294704c57c705b 100644 (file)
@@ -1,5 +1,7 @@
 import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
+
+import * as _ from 'lodash';
 import 'rxjs/add/observable/forkJoin';
 import 'rxjs/add/observable/of';
 import { Observable } from 'rxjs/Observable';
@@ -11,6 +13,10 @@ export class RgwUserService {
 
   constructor(private http: HttpClient) { }
 
+  /**
+   * Get the list of users.
+   * @return {Observable<Object[]>}
+   */
   list() {
     return this.enumerate()
       .flatMap((uids: string[]) => {
@@ -18,13 +24,16 @@ export class RgwUserService {
           return Observable.forkJoin(
             uids.map((uid: string) => {
               return this.get(uid);
-            })
-          );
+            }));
         }
         return Observable.of([]);
       });
   }
 
+  /**
+   * Get the list of usernames.
+   * @return {Observable<string[]>}
+   */
   enumerate() {
     return this.http.get('/api/rgw/proxy/metadata/user');
   }
@@ -32,7 +41,7 @@ export class RgwUserService {
   get(uid: string) {
     let params = new HttpParams();
     params = params.append('uid', uid);
-    return this.http.get(this.url, { params: params });
+    return this.http.get(this.url, {params: params});
   }
 
   getQuota(uid: string) {
@@ -40,4 +49,111 @@ export class RgwUserService {
     params = params.append('uid', uid);
     return this.http.get(`${this.url}?quota`, {params: params});
   }
+
+  put(args: object) {
+    let params = new HttpParams();
+    _.keys(args).forEach((key) => {
+      params = params.append(key, args[key]);
+    });
+    return this.http.put(this.url, null, {params: params});
+  }
+
+  putQuota(args: object) {
+    let params = new HttpParams();
+    _.keys(args).forEach((key) => {
+      params = params.append(key, args[key]);
+    });
+    return this.http.put(`${this.url}?quota`, null, {params: params});
+  }
+
+  post(args: object) {
+    let params = new HttpParams();
+    _.keys(args).forEach((key) => {
+      params = params.append(key, args[key]);
+    });
+    return this.http.post(this.url, null, {params: params});
+  }
+
+  delete(uid: string) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    return this.http.delete(this.url, {params: params});
+  }
+
+  addSubuser(uid: string, subuser: string, permissions: string,
+             secretKey: string, generateSecret: boolean) {
+    const mapPermissions = {
+      'full-control': 'full',
+      'read-write': 'readwrite'
+    };
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('subuser', subuser);
+    params = params.append('key-type', 'swift');
+    params = params.append('access', (permissions in mapPermissions) ?
+      mapPermissions[permissions] : permissions);
+    if (generateSecret) {
+      params = params.append('generate-secret', 'true');
+    } else {
+      params = params.append('secret-key', secretKey);
+    }
+    return this.http.put(this.url, null, {params: params});
+  }
+
+  deleteSubuser(uid: string, subuser: string) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('subuser', subuser);
+    params = params.append('purge-keys', 'true');
+    return this.http.delete(this.url, {params: params});
+  }
+
+  addCapability(uid: string, type: string, perm: string) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('user-caps', `${type}=${perm}`);
+    return this.http.put(`${this.url}?caps`, null, {params: params});
+  }
+
+  deleteCapability(uid: string, type: string, perm: string) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('user-caps', `${type}=${perm}`);
+    return this.http.delete(`${this.url}?caps`, {params: params});
+  }
+
+  addS3Key(uid: string, subuser: string, accessKey: string,
+           secretKey: string, generateKey: boolean) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('key-type', 's3');
+    params = params.append('generate-key', generateKey ? 'true' : 'false');
+    if (!generateKey) {
+      params = params.append('access-key', accessKey);
+      params = params.append('secret-key', secretKey);
+    }
+    params = params.append('subuser', subuser);
+    return this.http.put(`${this.url}?key`, null, {params: params});
+  }
+
+  deleteS3Key(uid: string, accessKey: string) {
+    let params = new HttpParams();
+    params = params.append('uid', uid);
+    params = params.append('key-type', 's3');
+    params = params.append('access-key', accessKey);
+    return this.http.delete(`${this.url}?key`, {params: params});
+  }
+
+  /**
+   * Check if the specified user ID exists.
+   * @param {string} uid The user ID to check.
+   * @return {Observable<boolean>}
+   */
+  exists(uid: string) {
+    return this.enumerate()
+      .flatMap((resp: string[]) => {
+        const index = _.indexOf(resp, uid);
+        return Observable.of(-1 !== index);
+      });
+  }
 }
index b375ba256b0a28296643b3c1a7f783225c8c1419..3e152a3334ade807c5a9f671cc4e98f913db5ce0 100644 (file)
@@ -4,24 +4,28 @@ import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@
   selector: '[cdPasswordButton]'
 })
 export class PasswordButtonDirective implements OnInit {
-  private inputElement: any;
-  private iElement: any;
+  private iElement: HTMLElement;
 
   @Input('cdPasswordButton') private cdPasswordButton: string;
 
-  constructor(private el: ElementRef, private renderer: Renderer2) { }
+  constructor(private elementRef: ElementRef,
+              private renderer: Renderer2) {}
 
   ngOnInit() {
-    this.inputElement = document.getElementById(this.cdPasswordButton);
     this.iElement = this.renderer.createElement('i');
     this.renderer.addClass(this.iElement, 'icon-prepend');
     this.renderer.addClass(this.iElement, 'fa');
-    this.renderer.appendChild(this.el.nativeElement, this.iElement);
+    this.renderer.appendChild(this.elementRef.nativeElement, this.iElement);
     this.update();
   }
 
+  private getInputElement() {
+    return document.getElementById(this.cdPasswordButton) as HTMLInputElement;
+  }
+
   private update() {
-    if (this.inputElement.type === 'text') {
+    const inputElement = this.getInputElement();
+    if (inputElement && (inputElement.type === 'text')) {
       this.renderer.removeClass(this.iElement, 'fa-eye');
       this.renderer.addClass(this.iElement, 'fa-eye-slash');
     } else {
@@ -32,8 +36,9 @@ export class PasswordButtonDirective implements OnInit {
 
   @HostListener('click')
   onClick() {
+    const inputElement = this.getInputElement();
     // Modify the type of the input field.
-    this.inputElement.type = (this.inputElement.type === 'password') ? 'text' : 'password';
+    inputElement.type = (inputElement.type === 'password') ? 'text' : 'password';
     // Update the button icon/tooltip.
     this.update();
   }