]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: added Create Account Functionality
authorNaman Munet <naman.munet@ibm.com>
Thu, 26 Dec 2024 11:48:03 +0000 (17:18 +0530)
committerNaman Munet <naman.munet@ibm.com>
Wed, 22 Jan 2025 11:35:33 +0000 (17:05 +0530)
Fixes: https://tracker.ceph.com/issues/69140
Signed-off-by: Naman Munet <naman.munet@ibm.com>
18 files changed:
src/pybind/mgr/dashboard/controllers/rgw_iam.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user-accounts.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts/rgw-user-accounts.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts/rgw-user-accounts.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts/rgw-user-accounts.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user-accounts.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user-accounts.service.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_iam.py

index 458bbbb732188ba081df01b31fe9037f4cf4faf0..1a293a7a17af7a02b558d6320dc667d5553b9229 100644 (file)
@@ -11,9 +11,14 @@ from . import APIDoc, APIRouter, EndpointDoc, RESTController, allow_empty_body
 class RgwUserAccountsController(RESTController):
 
     @allow_empty_body
-    def create(self, account_name: Optional[str] = None,
-               account_id: Optional[str] = None, email: Optional[str] = None):
-        return RgwAccounts.create_account(account_name, account_id, email)
+    def create(self, account_name: Optional[str] = None, tenant: str = None,
+               account_id: Optional[str] = None, email: Optional[str] = None,
+               max_buckets: str = None, max_users: str = None,
+               max_roles: str = None, max_group: str = None,
+               max_access_keys: str = None):
+        return RgwAccounts.create_account(account_name, tenant, account_id, email,
+                                          max_buckets, max_users, max_roles,
+                                          max_group, max_access_keys)
 
     def list(self, detailed: bool = False):
         detailed = str_to_bool(detailed)
@@ -41,8 +46,9 @@ class RgwUserAccountsController(RESTController):
                              'max_size': (str, 'Max size')})
     @RESTController.Resource(method='PUT', path='/quota')
     @allow_empty_body
-    def set_quota(self, quota_type: str, account_id: str, max_size: str, max_objects: str):
-        return RgwAccounts.set_quota(quota_type, account_id, max_size, max_objects)
+    def set_quota(self, quota_type: str, account_id: str, max_size: str, max_objects: str,
+                  enabled: bool):
+        return RgwAccounts.set_quota(quota_type, account_id, max_size, max_objects, enabled)
 
     @EndpointDoc("Enable/Disable RGW Account/Bucket quota",
                  parameters={'account_id': (str, 'Account id')})
index 2cc05048ec4d48fb9ecaff649358cf5b4b4e278c..2d1d446e2aba40d824230f79d5aefd19c30ac5e5 100644 (file)
@@ -1,4 +1,4 @@
-export interface Accounts {
+export interface Account {
   id: string;
   tenant: string;
   name: string;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.html
new file mode 100644 (file)
index 0000000..77a4008
--- /dev/null
@@ -0,0 +1,13 @@
+<!-- Account quota -->
+<div *ngIf="selection.quota">
+  <legend i18n>Account quota</legend>
+  <cd-table-key-value [data]="quota">
+  </cd-table-key-value>
+</div>
+
+<!-- Bucket quota -->
+<div *ngIf="selection.bucket_quota">
+  <legend i18n>Bucket quota</legend>
+  <cd-table-key-value [data]="bucket_quota">
+  </cd-table-key-value>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.spec.ts
new file mode 100644 (file)
index 0000000..ac43cfe
--- /dev/null
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RgwUserAccountsDetailsComponent } from './rgw-user-accounts-details.component';
+
+describe('RgwUserAccountsDetailsComponent', () => {
+  let component: RgwUserAccountsDetailsComponent;
+  let fixture: ComponentFixture<RgwUserAccountsDetailsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [RgwUserAccountsDetailsComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(RgwUserAccountsDetailsComponent);
+    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-accounts-details/rgw-user-accounts-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-details/rgw-user-accounts-details.component.ts
new file mode 100644 (file)
index 0000000..a806205
--- /dev/null
@@ -0,0 +1,37 @@
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+
+@Component({
+  selector: 'cd-rgw-user-accounts-details',
+  templateUrl: './rgw-user-accounts-details.component.html',
+  styleUrls: ['./rgw-user-accounts-details.component.scss']
+})
+export class RgwUserAccountsDetailsComponent implements OnChanges {
+  @Input()
+  selection: any;
+  quota = {};
+  bucket_quota = {};
+
+  constructor(private dimlessBinary: DimlessBinaryPipe) {}
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes.selection && changes.selection.currentValue) {
+      this.quota = this.createDisplayValues('quota');
+      this.bucket_quota = this.createDisplayValues('bucket_quota');
+    }
+  }
+
+  createDisplayValues(quota_type: string) {
+    return {
+      Enabled: this.selection[quota_type].enabled ? 'Yes' : 'No',
+      'Maximum Size':
+        this.selection[quota_type].max_size <= -1
+          ? 'Unlimited'
+          : this.dimlessBinary.transform(this.selection[quota_type].max_size),
+      'Maximum objects':
+        this.selection[quota_type].max_objects <= -1
+          ? 'Unlimited'
+          : this.selection[quota_type].max_objects
+    };
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.html
new file mode 100644 (file)
index 0000000..279d36d
--- /dev/null
@@ -0,0 +1,278 @@
+<div cdsCol
+     [columnNumbers]="{md: 4}">
+  <ng-container *cdFormLoading="loading">
+    <form name="accountForm"
+          #formDir="ngForm"
+          [formGroup]="accountForm"
+          novalidate>
+
+      <div i18n="form title"
+           class="form-header">{{ action | titlecase }} {{ resource | upperFirst }}
+      </div>
+
+      <!-- Account Name -->
+      <div class="form-item">
+        <cds-text-label label="Account Name"
+                        for="acc_name"
+                        cdRequiredField="Account Name"
+                        [invalid]="!accountForm.controls.account_name.valid && accountForm.controls.account_name.dirty"
+                        [invalidText]="accountIdError"
+                        i18n>Account Name
+          <input cdsText
+                 type="text"
+                 placeholder="Enter account name"
+                 id="acc_name"
+                 name="acc_name"
+                 formControlName="account_name"/>
+        </cds-text-label>
+        <ng-template #accountIdError>
+          <span *ngIf="accountForm.showError('account_name', formDir, 'required')"
+                class="invalid-feedback">
+            <ng-container i18n>This field is required.</ng-container>
+          </span>
+        </ng-template>
+      </div>
+      <!-- Tenant -->
+      <div class="form-item">
+        <cds-text-label label="tenant"
+                        for="tenant"
+                        i18n>Tenant
+          <input cdsText
+                 type="text"
+                 placeholder="Enter tenant"
+                 id="tenant"
+                 name="tenant"
+                 formControlName="tenant"/>
+        </cds-text-label>
+      </div>
+
+      <!-- Email -->
+      <div class="form-item">
+        <cds-text-label label="email"
+                        for="email"
+                        [invalid]="!accountForm.controls.email.valid && accountForm.controls.email.dirty"
+                        [invalidText]="emailError"
+                        i18n>Email
+          <input cdsText
+                 type="text"
+                 placeholder="Enter email"
+                 id="email"
+                 name="email"
+                 formControlName="email"/>
+        </cds-text-label>
+        <ng-template #emailError>
+          <span *ngIf="accountForm.showError('email', formDir, 'email')">
+            <ng-container i18n> Please enter a valid email </ng-container>
+          </span>
+        </ng-template>
+      </div>
+
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <!-- Max. bucket mode -->
+          <ng-container *ngTemplateOutlet="selectModeTemplate;context: { formControl: 'max_buckets_mode' }"></ng-container>
+        </div>
+        <div cdsCol>
+        <!-- Max buckets -->
+        <span *ngIf="1 == accountForm.get('max_buckets_mode').value">
+          <ng-container *ngTemplateOutlet="accountMaxValueTemplate;context: { formControl: 'max_buckets' }"></ng-container>
+          </span>
+        </div>
+      </div>
+
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <!-- Max. users mode -->
+          <ng-container *ngTemplateOutlet="selectModeTemplate;context: { formControl: 'max_users_mode' }"></ng-container>
+        </div>
+        <div cdsCol>
+        <!-- Max users -->
+        <span *ngIf="1 == accountForm.get('max_users_mode').value">
+          <ng-container *ngTemplateOutlet="accountMaxValueTemplate;context: { formControl: 'max_users' }"></ng-container>
+          </span>
+        </div>
+      </div>
+
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <!-- Max. roles mode -->
+          <ng-container *ngTemplateOutlet="selectModeTemplate;context: { formControl: 'max_roles_mode' }"></ng-container>
+        </div>
+        <div cdsCol>
+        <!-- Max roles -->
+        <span *ngIf="1 == accountForm.get('max_roles_mode').value">
+          <ng-container *ngTemplateOutlet="accountMaxValueTemplate;context: { formControl: 'max_roles' }"></ng-container>
+        </span>
+        </div>
+      </div>
+
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <!-- Max. group mode -->
+          <ng-container *ngTemplateOutlet="selectModeTemplate;context: { formControl: 'max_group_mode' }"></ng-container>
+        </div>
+        <div cdsCol>
+        <!-- Max group -->
+        <span *ngIf="1 == accountForm.get('max_group_mode').value">
+          <ng-container *ngTemplateOutlet="accountMaxValueTemplate;context: { formControl: 'max_group' }"></ng-container>
+          </span>
+        </div>
+      </div>
+
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <!-- Max. acess keys mode -->
+          <ng-container *ngTemplateOutlet="selectModeTemplate;context: { formControl: 'max_access_keys_mode' }"></ng-container>
+        </div>
+        <div cdsCol>
+        <!-- Max acess keys -->
+        <span *ngIf="1 == accountForm.get('max_access_keys_mode').value">
+          <ng-container *ngTemplateOutlet="accountMaxValueTemplate;context: { formControl: 'max_access_keys' }"></ng-container>
+        </span>
+        </div>
+      </div>
+
+      <!-- Account Quota -->
+      <div class="form-item">
+        <ng-container *ngTemplateOutlet="quotaTemplate;context: { formControl: {
+                      enabled: 'account_quota_enabled',
+                      unlimitedSize: 'account_quota_max_size_unlimited',
+                      maxSize: 'account_quota_max_size',
+                      unlimitedObjects: 'account_quota_max_objects_unlimited',
+                      maxObjects: 'account_quota_max_objects'
+                      },
+                      quotaType: 'account'
+                      }">
+        </ng-container>
+      </div>
+
+      <!-- Bucket Quota -->
+      <div class="form-item">
+        <ng-container *ngTemplateOutlet="quotaTemplate;
+                      context: {
+                      formControl: {
+                      enabled: 'bucket_quota_enabled',
+                      unlimitedSize: 'bucket_quota_max_size_unlimited',
+                      maxSize: 'bucket_quota_max_size',
+                      unlimitedObjects: 'bucket_quota_max_objects_unlimited',
+                      maxObjects: 'bucket_quota_max_objects'
+                      },
+                      quotaType: 'bucket'
+                      }">
+        </ng-container>
+      </div>
+      <cd-form-button-panel (submitActionEvent)="submit()"
+                            [form]="formDir"
+                            [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+                            wrappingClass="text-right"></cd-form-button-panel>
+
+    </form>
+  </ng-container>
+</div>
+
+<ng-template #selectModeTemplate
+             [formGroup]="accountForm"
+             let-formControl="formControl">
+  <cds-select [formControlName]="formControl"
+              [name]="formControl"
+              [id]="formControl"
+              label="{{formControl.split('_')[1] | upperFirst}} Mode"
+              (change)="onModeChange($event.target.value, formControl)">
+    <option value="-1">Disabled</option>
+    <option value="0">Unlimited</option>
+    <option value="1">Custom</option>
+  </cds-select>
+</ng-template>
+
+<ng-template #accountMaxValueTemplate
+             let-formControl="formControl"
+             [formGroup]="accountForm">
+<cds-number [id]="formControl"
+            [name]="formControl"
+            formControlName="{{formControl}}"
+            label="{{formControl.split('_')[0] | upperFirst}}. {{formControl.split('_').length > 2 ? formControl.split('_')[1]+' '+formControl.split('_')[2]: formControl.split('_')[1]}}"
+            [min]="1"
+            [invalid]="!accountForm.controls[formControl].valid && accountForm.controls[formControl].dirty"
+            [invalidText]="maxValError"></cds-number>
+<ng-template #maxValError>
+  <span *ngIf="accountForm.showError(formControl, formDir, 'required')"
+        i18n>This field is required.</span>
+  <span *ngIf="accountForm.showError(formControl, formDir, 'pattern')"
+        i18n>The entered value must be a number greater than 0</span>
+</ng-template>
+</ng-template>
+
+<ng-template #quotaTemplate
+             let-quotaType="quotaType"
+             let-formControl="formControl"
+             [formGroup]="accountForm">
+  <fieldset class="cds--fieldset">
+    <legend class="cds--label">{{quotaType | upperFirst}} Quota</legend>
+    <!-- Enabled -->
+    <cds-checkbox [formControlName]="formControl.enabled">
+      Enabled
+    </cds-checkbox >
+    <!-- Unlimited size -->
+    <cds-checkbox *ngIf="accountForm.controls[formControl.enabled].value"
+                  [formControlName]="formControl.unlimitedSize">
+                  Unlimited size
+    </cds-checkbox>
+    <!-- Maximum size -->
+    <div class="form-item"
+         *ngIf="accountForm.controls[formControl.enabled].value && !accountForm.getValue(formControl.unlimitedSize)">
+      <cds-text-label [label]="formControl.maxSize"
+                      [for]="formControl.maxSize"
+                      [invalid]="!accountForm.controls[formControl.maxSize].valid && accountForm.controls[formControl.maxSize].dirty"
+                      [invalidText]="quotaSizeError"
+                      i18n>Max. size
+        <input cdsText
+               type="text"
+               placeholder="Enter size"
+               [id]="formControl.maxSize"
+               [name]="formControl.maxSize"
+               [formControlName]="formControl.maxSize"
+               cdDimlessBinary/>
+      </cds-text-label>
+      <ng-template #quotaSizeError>
+        <span *ngIf="accountForm.showError(formControl.maxSize, formDir, 'required')"
+              i18n>This field is required.</span>
+        <span *ngIf="accountForm.showError(formControl.maxSize, formDir, 'quotaMaxSize')"
+              i18n>The value is not valid.</span>
+        <span *ngIf="accountForm.showError(formControl.maxSize, formDir, 'pattern')"
+              i18n>Size must be a number or in a valid format. eg: 5 GiB</span>
+      </ng-template>
+    </div>
+    <!-- Unlimited objects -->
+    <cds-checkbox *ngIf="accountForm.controls[formControl.enabled].value"
+                  [formControlName]="formControl.unlimitedObjects">
+                  Unlimited objects
+    </cds-checkbox>
+  <!-- Maximum objects -->
+    <div class="form-item"
+         *ngIf="accountForm.controls[formControl.enabled].value && !accountForm.getValue(formControl.unlimitedObjects)">
+      <cds-text-label [label]="formControl.maxObjects"
+                      [for]="formControl.maxObjects"
+                      [invalid]="!accountForm.controls[formControl.maxObjects].valid && accountForm.controls[formControl.maxObjects].dirty"
+                      [invalidText]="quotaObjectError"
+                      i18n>Max. objects
+        <input cdsText
+               type="number"
+               placeholder="Enter number of objects"
+               [id]="formControl.maxObjects"
+               [name]="formControl.maxObjects"
+               [formControlName]="formControl.maxObjects"/>
+      </cds-text-label>
+      <ng-template #quotaObjectError>
+        <span *ngIf="accountForm.showError(formControl.maxObjects, formDir, 'required')"
+              i18n>This field is required.</span>
+        <span *ngIf="accountForm.showError(formControl.maxObjects, formDir, 'pattern')"
+              i18n>Please enter a valid number</span>
+      </ng-template>
+    </div>
+  </fieldset>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-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-accounts-form/rgw-user-accounts-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.spec.ts
new file mode 100644 (file)
index 0000000..0cd52fa
--- /dev/null
@@ -0,0 +1,56 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form.component';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { RouterTestingModule } from '@angular/router/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ToastrModule } from 'ngx-toastr';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { of } from 'rxjs';
+import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service';
+import { ModalModule } from 'carbon-components-angular';
+
+class MockRgwUserAccountsService {
+  create = jest.fn().mockReturnValue(of(null));
+}
+
+describe('RgwUserAccountsFormComponent', () => {
+  let component: RgwUserAccountsFormComponent;
+  let fixture: ComponentFixture<RgwUserAccountsFormComponent>;
+  let rgwUserAccountsService: MockRgwUserAccountsService;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [RgwUserAccountsFormComponent],
+      imports: [
+        ComponentsModule,
+        ToastrModule.forRoot(),
+        HttpClientTestingModule,
+        PipesModule,
+        RouterTestingModule,
+        ModalModule
+      ],
+      providers: [{ provide: RgwUserAccountsService, useClass: MockRgwUserAccountsService }]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(RgwUserAccountsFormComponent);
+    rgwUserAccountsService = (TestBed.inject(
+      RgwUserAccountsService
+    ) as unknown) as MockRgwUserAccountsService;
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should call create method of MockRgwUserAccountsService and show success notification', () => {
+    component.editing = false;
+    const spy = jest.spyOn(component, 'submit');
+    const createDataSpy = jest.spyOn(rgwUserAccountsService, 'create').mockReturnValue(of(null));
+    component.submit();
+    expect(spy).toHaveBeenCalled();
+    expect(createDataSpy).toHaveBeenCalled();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.ts
new file mode 100644 (file)
index 0000000..7d2734b
--- /dev/null
@@ -0,0 +1,279 @@
+import { Component } from '@angular/core';
+import { AbstractControl, ValidationErrors, Validators } from '@angular/forms';
+import { Router } from '@angular/router';
+import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { Account } from '../models/rgw-user-accounts';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdValidators, isEmptyInputValue } from '~/app/shared/forms/cd-validators';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { Observable, concat as observableConcat } from 'rxjs';
+
+@Component({
+  selector: 'cd-rgw-user-accounts-form',
+  templateUrl: './rgw-user-accounts-form.component.html',
+  styleUrls: ['./rgw-user-accounts-form.component.scss']
+})
+export class RgwUserAccountsFormComponent extends CdForm {
+  accountForm: CdFormGroup;
+  action: string;
+  resource: string;
+  editing: boolean = false;
+  submitObservables: Observable<Object>[] = [];
+
+  constructor(
+    private router: Router,
+    private actionLabels: ActionLabelsI18n,
+    private rgwUserAccountsService: RgwUserAccountsService,
+    private notificationService: NotificationService,
+    private formBuilder: CdFormBuilder
+  ) {
+    super();
+    this.editing = this.router.url.includes('rgw/accounts/edit');
+    this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
+    this.resource = $localize`Account`;
+    this.createForm();
+    this.loadingReady();
+  }
+
+  private createForm() {
+    this.accountForm = this.formBuilder.group({
+      account_id: [''],
+      tenant: [''],
+      account_name: ['', Validators.required],
+      email: ['', CdValidators.email],
+      max_users_mode: [1],
+      max_users: [
+        1000,
+        [CdValidators.requiredIf({ max_users_mode: '1' }), CdValidators.number(false)]
+      ],
+      max_roles_mode: [1],
+      max_roles: [
+        1000,
+        [CdValidators.requiredIf({ max_roles_mode: '1' }), CdValidators.number(false)]
+      ],
+      max_group_mode: [1],
+      max_group: [
+        1000,
+        [CdValidators.requiredIf({ max_group_mode: '1' }), CdValidators.number(false)]
+      ],
+      max_access_keys_mode: [1],
+      max_access_keys: [
+        4,
+        [CdValidators.requiredIf({ max_access_keys_mode: '1' }), CdValidators.number(false)]
+      ],
+      max_buckets_mode: [1],
+      max_buckets: [
+        1000,
+        [CdValidators.requiredIf({ max_buckets_mode: '1' }), CdValidators.number(false)]
+      ],
+      account_quota_enabled: [false],
+      account_quota_max_size_unlimited: [true],
+      account_quota_max_size: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              account_quota_enabled: true,
+              account_quota_max_size_unlimited: false
+            },
+            [Validators.required, this.quotaMaxSizeValidator]
+          )
+        ]
+      ],
+      account_quota_max_objects_unlimited: [true],
+      account_quota_max_objects: [
+        null,
+        [
+          CdValidators.requiredIf({
+            account_quota_enabled: true,
+            account_quota_max_objects_unlimited: false
+          }),
+          Validators.pattern(/^[0-9]+$/)
+        ]
+      ],
+      bucket_quota_enabled: [false],
+      bucket_quota_max_size_unlimited: [true],
+      bucket_quota_max_size: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              bucket_quota_enabled: true,
+              bucket_quota_max_size_unlimited: false
+            },
+            [Validators.required, this.quotaMaxSizeValidator]
+          )
+        ]
+      ],
+      bucket_quota_max_objects_unlimited: [true],
+      bucket_quota_max_objects: [
+        null,
+        [
+          CdValidators.requiredIf({
+            bucket_quota_enabled: true,
+            bucket_quota_max_objects_unlimited: false
+          }),
+          Validators.pattern(/^[0-9]+$/)
+        ]
+      ]
+    });
+  }
+
+  /**
+   * Validate the quota maximum size, e.g. 1096, 1K, 30M or 1.9MiB.
+   */
+  quotaMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+    if (isEmptyInputValue(control.value)) {
+      return null;
+    }
+    const m = RegExp('^(\\d+(\\.\\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;
+  }
+
+  submit() {
+    let notificationTitle: string = '';
+    if (this.accountForm.invalid) {
+      return;
+    }
+
+    if (this.accountForm.pending) {
+      this.accountForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+
+    if (!this.editing) {
+      const formvalue = this.accountForm.value;
+      const createPayload = {
+        account_id: formvalue.account_id,
+        account_name: formvalue.account_name,
+        email: formvalue.email,
+        tenant: formvalue.tenant,
+        max_users: this.getValueFromFormControl('max_users'),
+        max_buckets: this.getValueFromFormControl('max_buckets'),
+        max_roles: this.getValueFromFormControl('max_roles'),
+        max_group: this.getValueFromFormControl('max_group'),
+        max_access_keys: this.getValueFromFormControl('max_access_keys')
+      };
+      notificationTitle = $localize`Account created successfully`;
+      this.rgwUserAccountsService.create(createPayload).subscribe({
+        next: (account: Account) => {
+          this.accountForm.get('account_id').setValue(account.id);
+          this.setQuotaConfig();
+          this.notificationService.show(NotificationType.success, notificationTitle);
+        },
+        error: () => {
+          // Reset the 'Submit' button.
+          this.accountForm.setErrors({ cdSubmitButton: true });
+        }
+      });
+    }
+  }
+
+  setQuotaConfig() {
+    const accountId: string = this.accountForm.get('account_id').value;
+    // Check if account quota has been modified.
+    if (this._isQuotaConfDirty('account')) {
+      const accountQuotaArgs = this._getQuotaArgs('account');
+      this.submitObservables.push(
+        this.rgwUserAccountsService.setQuota(accountId, accountQuotaArgs)
+      );
+    }
+    // Check if bucket quota has been modified.
+    if (this._isQuotaConfDirty('bucket')) {
+      const bucketQuotaArgs = this._getQuotaArgs('bucket');
+      this.submitObservables.push(this.rgwUserAccountsService.setQuota(accountId, bucketQuotaArgs));
+    }
+    // Finally execute all observables one by one in serial.
+    observableConcat(...this.submitObservables).subscribe({
+      error: () => {
+        // Reset the 'Submit' button.
+        this.accountForm.setErrors({ cdSubmitButton: true });
+      },
+      complete: () => {
+        this.goToListView();
+      }
+    });
+    if (this.submitObservables.length == 0) {
+      this.goToListView();
+    }
+  }
+
+  /**
+   * Helper function to get the arguments for the API request when any
+   * quota configuration has been modified.
+   */
+  private _getQuotaArgs(quotaType: string) {
+    const result = {
+      quota_type: quotaType,
+      enabled: this.accountForm.getValue(`${quotaType}_quota_enabled`),
+      max_size: '-1',
+      max_objects: '-1'
+    };
+    if (!this.accountForm.getValue(`${quotaType}_quota_max_size_unlimited`)) {
+      // Convert the given value to bytes.
+      const bytes = new FormatterService().toBytes(
+        this.accountForm.getValue(`${quotaType}_quota_max_size`)
+      );
+      // Finally convert the value to KiB.
+      result['max_size'] = (bytes / 1024).toFixed(0) as any;
+    }
+    if (!this.accountForm.getValue(`${quotaType}_quota_max_objects_unlimited`)) {
+      result['max_objects'] = `${this.accountForm.getValue(`${quotaType}_quota_max_objects`)}`;
+    }
+    return result;
+  }
+
+  /**
+   * Check if any quota has been modified.
+   * @return {Boolean} Returns TRUE if the quota has been modified.
+   */
+  private _isQuotaConfDirty(quotaType: string): boolean {
+    if (this.accountForm.get(`${quotaType}_quota_enabled`).value) {
+      return [
+        `${quotaType}_quota_enabled`,
+        `${quotaType}_quota_max_size_unlimited`,
+        `${quotaType}_quota_max_size`,
+        `${quotaType}_quota_max_objects_unlimited`,
+        `${quotaType}_quota_max_objects`
+      ].some((path) => {
+        return this.accountForm.get(path).dirty;
+      });
+    }
+    return false;
+  }
+
+  onModeChange(mode: string, formControlName: string) {
+    if (mode === '1') {
+      // If 'Custom' mode is selected, then ensure that the form field
+      // 'Max. buckets' contains a valid value. Set it to default if
+      // necessary.
+      if (!this.accountForm.get(formControlName).valid) {
+        this.accountForm.patchValue({
+          [formControlName]: 1000
+        });
+      }
+    }
+  }
+
+  goToListView(): void {
+    this.router.navigate(['rgw/accounts']);
+  }
+
+  getValueFromFormControl(formControlName: string) {
+    const formvalue = this.accountForm.value;
+    return formvalue[`${formControlName}_mode`] == 1
+      ? formvalue[formControlName]
+      : formvalue[`${formControlName}_mode`];
+  }
+}
index b10972a492a4aff902bc787fcdf250e43e21f170..bddb749b466a13362dd101d8f09e8aa24c05fccb 100644 (file)
@@ -12,7 +12,8 @@
           [columns]="columns"
           columnMode="flex"
           selectionType="multiClick"
-          [hasDetails]="false"
+          [hasDetails]="true"
+          (setExpandedRow)="setExpandedRow($event)"
           (updateSelection)="updateSelection($event)"
           identifier="id"
           (fetchData)="getAccountsList($event)">
@@ -21,4 +22,8 @@
                     [selection]="selection"
                     [tableActions]="tableActions">
   </cd-table-actions>
+  <cd-rgw-user-accounts-details *cdTableDetail
+                                [selection]="expandedRow">
+  </cd-rgw-user-accounts-details>
 </cd-table>
+
index d6bdd8a270c8a0564f4c0373b37ed43e85317002..ff25b3ca17e7c92b874c3a6362b7f0a53995a0c7 100644 (file)
@@ -1,6 +1,11 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 
 import { RgwUserAccountsComponent } from './rgw-user-accounts.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ToastrModule } from 'ngx-toastr';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentsModule } from '~/app/shared/components/components.module';
 
 describe('RgwUserAccountsComponent', () => {
   let component: RgwUserAccountsComponent;
@@ -8,7 +13,14 @@ describe('RgwUserAccountsComponent', () => {
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
-      declarations: [RgwUserAccountsComponent]
+      declarations: [RgwUserAccountsComponent],
+      imports: [
+        ComponentsModule,
+        ToastrModule.forRoot(),
+        HttpClientTestingModule,
+        PipesModule,
+        RouterTestingModule
+      ]
     }).compileComponents();
 
     fixture = TestBed.createComponent(RgwUserAccountsComponent);
index 64686d251c3c44069248a3974230ec3804dd44c2..882a3f40cd2eed63d59b99b663aa9030550098ce 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, OnInit, ViewChild } from '@angular/core';
 
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
@@ -8,28 +8,38 @@ import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { Accounts } from '../models/rgw-user-accounts';
+import { Account } from '../models/rgw-user-accounts';
 import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { Router } from '@angular/router';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+
+const BASE_URL = 'rgw/accounts';
 
 @Component({
   selector: 'cd-rgw-user-accounts',
   templateUrl: './rgw-user-accounts.component.html',
-  styleUrls: ['./rgw-user-accounts.component.scss']
+  styleUrls: ['./rgw-user-accounts.component.scss'],
+  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
 })
-export class RgwUserAccountsComponent implements OnInit {
+export class RgwUserAccountsComponent extends ListWithDetails implements OnInit {
   @ViewChild(TableComponent, { static: true })
   table: TableComponent;
   permission: Permission;
   tableActions: CdTableAction[] = [];
   columns: CdTableColumn[] = [];
-  accounts: Accounts[] = [];
+  accounts: Account[] = [];
   selection: CdTableSelection = new CdTableSelection();
 
   constructor(
     private authStorageService: AuthStorageService,
     public actionLabels: ActionLabelsI18n,
+    private router: Router,
     private rgwUserAccountsService: RgwUserAccountsService
-  ) {}
+  ) {
+    super();
+  }
 
   ngOnInit() {
     this.permission = this.authStorageService.getPermissions().rgw;
@@ -80,11 +90,19 @@ export class RgwUserAccountsComponent implements OnInit {
         flexGrow: 1
       }
     ];
+    const addAction: CdTableAction = {
+      permission: 'create',
+      icon: Icons.add,
+      click: () => this.router.navigate([`${BASE_URL}/${URLVerbs.CREATE}`]),
+      name: this.actionLabels.CREATE,
+      canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+    };
+    this.tableActions = [addAction];
   }
 
-  getAccountsList(context: CdTableFetchDataContext) {
+  getAccountsList(context?: CdTableFetchDataContext) {
     this.rgwUserAccountsService.list(true).subscribe({
-      next: (accounts) => {
+      next: (accounts: Account[]) => {
         this.accounts = accounts;
       },
       error: () => {
index caee3d69b13a55487a916aaa86928019f62b3e91..012e7c2fd21afe011d93d6367b15eb83eef1c205 100644 (file)
@@ -73,10 +73,14 @@ import {
   CodeSnippetModule,
   InputModule,
   CheckboxModule,
-  TreeviewModule
+  TreeviewModule,
+  SelectModule,
+  NumberModule
 } from 'carbon-components-angular';
 import { CephSharedModule } from '../shared/ceph-shared.module';
 import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.component';
+import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form/rgw-user-accounts-form.component';
+import { RgwUserAccountsDetailsComponent } from './rgw-user-accounts-details/rgw-user-accounts-details.component';
 
 @NgModule({
   imports: [
@@ -104,7 +108,9 @@ import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.
     IconModule,
     NgbProgressbar,
     InputModule,
-    CheckboxModule
+    CheckboxModule,
+    SelectModule,
+    NumberModule
   ],
   exports: [
     RgwDaemonListComponent,
@@ -156,7 +162,9 @@ import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.
     RgwMultisiteSyncFlowModalComponent,
     RgwMultisiteSyncPipeModalComponent,
     RgwMultisiteTabsComponent,
-    RgwUserAccountsComponent
+    RgwUserAccountsComponent,
+    RgwUserAccountsFormComponent,
+    RgwUserAccountsDetailsComponent
   ],
   providers: [TitleCasePipe]
 })
@@ -189,7 +197,14 @@ const routes: Routes = [
   {
     path: 'accounts',
     data: { breadcrumbs: 'Accounts' },
-    children: [{ path: '', component: RgwUserAccountsComponent }]
+    children: [
+      { path: '', component: RgwUserAccountsComponent },
+      {
+        path: URLVerbs.CREATE,
+        component: RgwUserAccountsFormComponent,
+        data: { breadcrumbs: ActionLabels.CREATE }
+      }
+    ]
   },
   {
     path: 'roles',
index 6ea9571336e68cda78421884fe1d1d5789b920a0..8a3fd3f01201519063e99ac0cff5aadb8226f8b9 100644 (file)
@@ -2,9 +2,9 @@ import { TestBed } from '@angular/core/testing';
 
 import { RgwUserAccountsService } from './rgw-user-accounts.service';
 import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
-import { Accounts } from '~/app/ceph/rgw/models/rgw-user-accounts';
+import { Account } from '~/app/ceph/rgw/models/rgw-user-accounts';
 
-const mockAccountData: Accounts[] = [
+const mockAccountData: Account[] = [
   {
     id: 'RGW80617806988089685',
     tenant: '',
index 0a6722244303cadbf7e2cdd5c9f113aff913f68a..971261013af97577e4e03670b108e795c47357c2 100644 (file)
@@ -17,4 +17,23 @@ export class RgwUserAccountsService {
     }
     return this.http.get(this.url, { params });
   }
+
+  get(account_id: string): Observable<any> {
+    let params = new HttpParams();
+    if (account_id) {
+      params = params.append('account_id', account_id);
+    }
+    return this.http.get(`${this.url}/get`, { params });
+  }
+
+  create(payload: any): Observable<any> {
+    return this.http.post(this.url, payload);
+  }
+
+  setQuota(
+    account_id: string,
+    payload: { quota_type: string; max_size: string; max_objects: string; enabled: boolean }
+  ) {
+    return this.http.put(`${this.url}/${account_id}/quota`, payload);
+  }
 }
index de1b3e8b60e29eef8c3cf9c5c6c6da1d1f68557f..c548ec32c0d7131d799d72435ac1be6618409226 100644 (file)
@@ -10848,6 +10848,18 @@ paths:
                   type: integer
                 email:
                   type: string
+                max_access_keys:
+                  type: string
+                max_buckets:
+                  type: string
+                max_group:
+                  type: string
+                max_roles:
+                  type: string
+                max_users:
+                  type: string
+                tenant:
+                  type: string
               type: object
       responses:
         '201':
index 5f490323441ace6fb4bb6bcf04b5472f60b61384..43a3f2be181db550af2dc4e9800c2e22496f94a7 100644 (file)
@@ -42,8 +42,11 @@ class RgwAccounts:
         return cls.send_rgw_cmd(get_account_cmd)
 
     @classmethod
-    def create_account(cls, account_name: Optional[str] = None,
-                       account_id: Optional[str] = None, email: Optional[str] = None):
+    def create_account(cls, account_name: Optional[str] = None, tenant: str = None,
+                       account_id: Optional[str] = None, email: Optional[str] = None,
+                       max_buckets: str = None, max_users: str = None,
+                       max_roles: str = None, max_group: str = None,
+                       max_access_keys: str = None):
         create_accounts_cmd = ['account', 'create']
 
         if account_name:
@@ -55,6 +58,24 @@ class RgwAccounts:
         if email:
             create_accounts_cmd += ['--email', email]
 
+        if tenant:
+            create_accounts_cmd += ['--tenant', tenant]
+
+        if max_buckets:
+            create_accounts_cmd += ['--max_buckets', str(max_buckets)]
+
+        if max_users:
+            create_accounts_cmd += ['--max_users', str(max_users)]
+
+        if max_roles:
+            create_accounts_cmd += ['--max_roles', str(max_roles)]
+
+        if max_group:
+            create_accounts_cmd += ['--max_groups', str(max_group)]
+
+        if max_access_keys:
+            create_accounts_cmd += ['--max_access_keys', str(max_access_keys)]
+
         return cls.send_rgw_cmd(create_accounts_cmd)
 
     @classmethod
@@ -83,10 +104,14 @@ class RgwAccounts:
         return cls.send_rgw_cmd(account_stats_cmd)
 
     @classmethod
-    def set_quota(cls, quota_type: str, account_id: str, max_size: str, max_objects: str):
+    def set_quota(cls, quota_type: str, account_id: str, max_size: str, max_objects: str,
+                  enabled: bool):
         set_quota_cmd = ['quota', 'set', '--quota-scope', quota_type, '--account-id', account_id,
                          '--max-size', max_size, '--max-objects', max_objects]
-
+        if enabled:
+            cls.set_quota_status(quota_type, account_id, 'enable')
+        else:
+            cls.set_quota_status(quota_type, account_id, 'disable')
         return cls.send_rgw_cmd(set_quota_cmd)
 
     @classmethod