import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-initiators-form.component';
import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
+import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
UIShellModule,
TreeviewModule,
TabsModule,
- TagModule
+ TagModule,
+ LayerModule
} from 'carbon-components-angular';
// Icons
ComboBoxModule,
TabsModule,
TagModule,
- GridModule
+ GridModule,
+ LayerModule
],
declarations: [
RbdListComponent,
NvmeofInitiatorsFormComponent,
NvmeofGatewayNodeComponent,
NvmeofGroupFormComponent,
- NvmeofSubsystemsStepOneComponent
+ NvmeofSubsystemsStepOneComponent,
+ NvmeofSubsystemsStepThreeComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
--- /dev/null
+
+<form [formGroup]="formGroup"
+ novalidate>
+ <div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ <div cdsCol
+ [columnNumbers]="{sm: 10, md: 10, lg: 12}">
+ <div cdsRow
+ class="form-heading">
+ <h3 class="cds--type-heading-03"
+ i18n>Authentication</h3>
+ <p
+ class="cds--type-label-02"
+ i18n>Configure authentication to verify the identity of connecting hosts and protect the subsystem from unauthorized access.</p>
+ </div>
+ <div cdsRow
+ class="form-item">
+ <cds-radio-group
+ formControlName="authType"
+ legend="Authentication type"
+ i18n-legend>
+ <cds-radio
+ [value]="AUTHENTICATION.Unidirectional">
+ <div class="auth-radio">
+ <span>Unidirectional</span>
+ <span class="cds--form__helper-text"
+ i18n>Each host can provide an optional DH-HMAC-CHAP key. The subsystem does not require its own key.</span>
+ </div>
+ </cds-radio>
+ <cds-radio
+ [value]="AUTHENTICATION.Bidirectional">
+ <div class="auth-radio">
+ <div cdsStack="horizontal">
+ <span i18n>Bidirectional</span>
+ <cds-tag
+ type="blue"
+ size="sm"
+ i18n>
+ Requires keys on both sides
+ </cds-tag>
+ </div>
+ <span class="cds--form__helper-text"
+ i18n>Both subsystem and hosts must provide DH-HMAC-CHAP keys. All connections will be verified in both directions.</span>
+ </div>
+ </cds-radio>
+ </cds-radio-group>
+ </div>
+ @if(formGroup.get('authType').value === AUTHENTICATION.Bidirectional) {
+ <div cdsRow
+ class="form-item step-3-form-item">
+ <div class="step-3-heading">
+ <h1
+ class="cds--type-heading-compact-01"
+ i18n>Subsystem authentication detail</h1>
+ <p class="cds--type-label-01 text-helper"
+ i18n>Mandatory field.</p>
+ </div>
+ <cds-text-label
+ i18n
+ helperText="A secret key for the subsystem to authenticate itself to hosts."
+ i18n-helperText
+ [invalid]="subDK.isInvalid"
+ [invalidText]="subsystemDchapKeyInvalidTemplate">
+ Subsystem DH-HMAC-CHAP key
+ <input cdsPassword
+ cdValidate
+ #subDK="cdValidate"
+ [invalid]="subDK.isInvalid"
+ formControlName="subsystemDchapKey"
+ type="password"
+ class="step-3-form-item"
+ placeholder="Enter subsystem DH-HMAC-CHAP key"
+ i18n-placeholder
+ autocomplete>
+ </cds-text-label>
+ </div>
+ }
+ <div cdsRow
+ class="form-item step-3-form-item">
+ <div class="step-3-heading">
+ <h1
+ class="cds--type-heading-compact-01"
+ i18n>Host authentication details</h1>
+ <p class="cds--type-label-01 text-helper"
+ i18n>{{formGroup.get('authType').value === AUTHENTICATION.Bidirectional ? 'All fields are required.' : 'Optional fields.'}}</p>
+ </div>
+ @for(hostDchapKeyItem of hostDchapKeyList.controls; track hostDchapKeyItem; let i = $index) {
+ <cds-text-label
+ [formGroupName]="i"
+ i18n>
+ DHCHAP Key | {{hostNQN}}
+ <input cdsPassword
+ formControlName="key"
+ type="password"
+ placeholder="Enter DHCHAP key"
+ class="step-3-form-item"
+ i18n-placeholder
+ autocomplete>
+ </cds-text-label>
+ }
+ </div>
+ </div>
+ </div>
+</form>
+
+<ng-template #subsystemDchapKeyInvalidTemplate>
+@for (err of formGroup.get('subsystemDchapKey').errors | keyvalue; track err.key) {
+<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+}
+</ng-template>
--- /dev/null
+.auth-radio {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.text-helper {
+ color: var(--cds-text-helper);
+}
+
+.step-3-heading {
+ display: flex;
+ justify-content: space-between;
+}
+
+.step-3-form-item {
+ max-inline-size: 23rem;
+}
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { GridModule, RadioModule, TagModule } from 'carbon-components-angular';
+import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
+
+describe('NvmeofSubsystemsStepThreeComponent', () => {
+ let component: NvmeofSubsystemsStepThreeComponent;
+ let fixture: ComponentFixture<NvmeofSubsystemsStepThreeComponent>;
+ let nvmeofService: NvmeofService;
+ let form: CdFormGroup;
+ const mockGroupName = 'default';
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofSubsystemsStepThreeComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ GridModule,
+ RadioModule,
+ TagModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofSubsystemsStepThreeComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ form = component.formGroup;
+ component.group = mockGroupName;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should test form', () => {
+ beforeEach(() => {
+ nvmeofService = TestBed.inject(NvmeofService);
+ spyOn(nvmeofService, 'createSubsystem').and.stub();
+ });
+
+ describe('form initialization', () => {
+ it('should initialize form with default values', () => {
+ expect(form).toBeTruthy();
+ expect(form.get('authType')?.value).toBe(AUTHENTICATION.Unidirectional);
+ expect(form.get('subsystemDchapKey')?.value).toBe(null);
+ });
+ });
+ });
+});
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core';
+import { FormArray, UntypedFormControl } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { AUTHENTICATION } from '~/app/shared/models/nvmeof';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+
+@Component({
+ selector: 'cd-nvmeof-subsystem-step-three',
+ templateUrl: './nvmeof-subsystem-step-3.component.html',
+ styleUrls: ['./nvmeof-subsystem-step-3.component.scss'],
+ standalone: false
+})
+export class NvmeofSubsystemsStepThreeComponent implements OnInit, TearsheetStep {
+ @Input() group!: string;
+ formGroup: CdFormGroup;
+ action: string;
+ pageURL: string;
+ INVALID_TEXTS = {
+ required: $localize`This field is required`
+ };
+ AUTHENTICATION = AUTHENTICATION;
+
+ constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+
+ ngOnInit() {
+ this.createForm();
+ }
+
+ createForm() {
+ this.formGroup = new CdFormGroup({
+ authType: new UntypedFormControl(AUTHENTICATION.Unidirectional),
+ subsystemDchapKey: new UntypedFormControl(null),
+ hostDchapKeyList: new FormArray([this.createHostDchapKeyItem()])
+ });
+ }
+
+ createHostDchapKeyItem() {
+ return new CdFormGroup({
+ key: new UntypedFormControl(null),
+ hostNQN: new UntypedFormControl('')
+ });
+ }
+
+ get hostDchapKeyList() {
+ return this.formGroup.get('hostDchapKeyList') as FormArray;
+ }
+}
#tearsheetStep
[group]="group"></cd-nvmeof-subsystem-step-one>
</cd-tearsheet-step>
+ <cd-tearsheet-step>
+ <ng-template
+ #tearsheetStep></ng-template>
+ </cd-tearsheet-step>
+ <cd-tearsheet-step>
+ <cd-nvmeof-subsystem-step-three
+ #tearsheetStep
+ [group]="group"></cd-nvmeof-subsystem-step-three>
+ </cd-tearsheet-step>
</cd-tearsheet>
} from './nvmeof-subsystems-form.component';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
-import { GridModule, InputModule } from 'carbon-components-angular';
+import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
describe('NvmeofSubsystemsFormComponent', () => {
let component: NvmeofSubsystemsFormComponent;
const mockGroupName = 'default';
const mockPayload: SubsystemPayload = {
nqn: '',
- gw_group: mockGroupName
+ gw_group: mockGroupName,
+ subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
};
beforeEach(async () => {
spyOn(Date, 'now').and.returnValue(mockTimestamp);
await TestBed.configureTestingModule({
- declarations: [NvmeofSubsystemsFormComponent, NvmeofSubsystemsStepOneComponent],
+ declarations: [
+ NvmeofSubsystemsFormComponent,
+ NvmeofSubsystemsStepOneComponent,
+ NvmeofSubsystemsStepThreeComponent
+ ],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
SharedModule,
InputModule,
GridModule,
+ RadioModule,
+ TagModule,
ToastrModule.forRoot()
]
}).compileComponents();
expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
nqn: expectedNqn,
gw_group: mockGroupName,
- enable_ha: true
+ enable_ha: true,
+ dhchap_key: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
});
});
});
export type SubsystemPayload = {
nqn: string;
gw_group: string;
+ subsystemDchapKey: string;
};
@Component({
},
{
label: $localize`Host access control`,
- complete: false
+ invalid: false
},
{
label: $localize`Authentication`,
- complete: false
+ invalid: false
},
{
label: $localize`Advanced options`,
task: new FinishedTask(taskUrl, {
nqn: payload.nqn
}),
- call: this.nvmeofService.createSubsystem({ ...payload, enable_ha: true })
+ call: this.nvmeofService.createSubsystem({
+ nqn: payload.nqn,
+ gw_group: this.group,
+ dhchap_key: payload.subsystemDchapKey,
+ enable_ha: true
+ })
})
.subscribe({
error() {
nqn: mockNQN,
enable_ha: true,
initiators: '*',
- gw_group: mockGroupName
+ gw_group: mockGroupName,
+ dhchap_key: null
};
service.createSubsystem(request).subscribe();
const req = httpTesting.expectOne(`${API_PATH}/subsystem`);
return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`);
}
- createSubsystem(request: { nqn: string; enable_ha: boolean; gw_group: string }) {
+ createSubsystem(request: {
+ nqn: string;
+ enable_ha: boolean;
+ gw_group: string;
+ dhchap_key: string;
+ }) {
return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
}
import {
- ChangeDetectorRef,
Component,
ContentChildren,
EventEmitter,
constructor(
protected formBuilder: FormBuilder,
- private changeDetectorRef: ChangeDetectorRef,
private cdsModalService: ModalCdsService,
private route: ActivatedRoute,
private location: Location,
this.hasModalOutlet = this.route.outlet === 'modal';
}
+ private _updateStepInvalid(index: number, invalid: boolean) {
+ this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step));
+ }
+
onStepSelect(event: { step: Step; index: number }) {
this.currentStep = event.index;
}
}
ngAfterViewInit() {
- // Checking Step validity, subscries ot all steps
- if (!this.stepContents || !this.steps) return;
+ if (!this.stepContents?.length) return;
this.stepContents.forEach((wrapper, index) => {
const form = wrapper.stepComponent?.formGroup;
if (!form) return;
- // Initialize invalid flag if missing
- this.steps[index].invalid = form.invalid;
+ // initial state
+ this._updateStepInvalid(index, form.invalid);
form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
- this.steps[index].invalid = form.invalid;
- this.changeDetectorRef.markForCheck();
+ this._updateStepInvalid(index, form.invalid);
});
});
}
subSystemCount: number;
nodeCount: number;
}
+
+export enum AUTHENTICATION {
+ Unidirectional = 'unidirectional',
+ Bidirectional = 'bidirectional'
+}