[title]="title"
[description]="description"
(submitRequested)="onSubmit($event)"
+ (stepChanged)="onStepChanged()"
[isSubmitLoading]="isSubmitLoading"
submitButtonLabel="Add"
i18n-submitButtonLabel>
[group]="group"
[existingHosts]="existingHosts"></cd-nvmeof-subsystem-step-two>
</cd-tearsheet-step>
+ @if(showAuthStep) {
+ <cd-tearsheet-step>
+ <cd-nvmeof-subsystem-step-three
+ #tearsheetStep
+ [stepTwoValue]="stepTwoValue"
+ [group]="group"></cd-nvmeof-subsystem-step-three>
+ </cd-tearsheet-step>
+ }
</cd-tearsheet>
expect(component).toBeTruthy();
});
+ it('should initialize with two steps (Host access control + Authentication optional)', () => {
+ expect(component.steps.length).toBe(2);
+ expect(component.steps[0].label).toBe('Host access control');
+ expect(component.steps[1].label).toBe('Authentication (optional)');
+ });
+
+ it('should hide Authentication step when showAuthStep is false', () => {
+ component.showAuthStep = false;
+ component.rebuildSteps();
+ expect(component.steps.length).toBe(1);
+ expect(component.steps[0].label).toBe('Host access control');
+ });
+
describe('should test form', () => {
beforeEach(() => {
nvmeofService = TestBed.inject(NvmeofService);
hosts: [{ dhchap_key: '', host_nqn: 'host1' }]
});
});
+
+ it('should build hosts from addedHosts when hostDchapKeyList is absent', () => {
+ const subsystemNQN = 'nqn.test';
+ component.subsystemNQN = subsystemNQN;
+ component.group = 'test-group';
+
+ const payload: any = {
+ hostType: HOST_TYPE.SPECIFIC,
+ addedHosts: ['host2'],
+ gw_group: 'test-group'
+ };
+
+ component.onSubmit(payload);
+ expect(nvmeofService.addSubsystemInitiators).toHaveBeenCalledWith(subsystemNQN, {
+ allow_all: false,
+ gw_group: 'test-group',
+ hosts: [{ dhchap_key: '', host_nqn: 'host2' }]
+ });
+ });
});
});
-import { Component, OnInit } from '@angular/core';
+import { Component, OnInit, ViewChild } from '@angular/core';
import { Step } from 'carbon-components-angular';
import { NvmeofService, SubsystemInitiatorRequest } from '~/app/shared/api/nvmeof.service';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { HOST_TYPE, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
+import {
+ AuthStepType,
+ HOST_TYPE,
+ HostStepType,
+ NvmeofSubsystemInitiator
+} from '~/app/shared/models/nvmeof';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { ActivatedRoute, Router } from '@angular/router';
-import { SubsystemPayload } from '../nvmeof-subsystems-form/nvmeof-subsystems-form.component';
+import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
+
+type InitiatorsFormPayload = Pick<HostStepType, 'hostType' | 'addedHosts'> &
+ Partial<Pick<AuthStepType, 'hostDchapKeyList'>>;
+
+const STEP_LABELS = {
+ HOSTS: $localize`Host access control`,
+ AUTH: $localize`Authentication (optional)`
+} as const;
@Component({
selector: 'cd-nvmeof-initiators-form',
subsystemNQN!: string;
isSubmitLoading = false;
existingHosts: string[] = [];
+ showAuthStep = true;
+ stepTwoValue: HostStepType = null;
- steps: Step[] = [
- {
- label: $localize`Host access control`,
- invalid: false
- }
- ];
+ @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
+
+ steps: Step[] = [];
title = $localize`Add Initiator`;
description = $localize`Allow specific hosts to run NVMe/TCP commands to the NVMe subsystem.`;
}
this.fetchExistingHosts();
});
+ this.rebuildSteps();
+ }
+
+ rebuildSteps() {
+ const steps: Step[] = [{ label: STEP_LABELS.HOSTS, invalid: false }];
+
+ if (this.showAuthStep) {
+ steps.push({ label: STEP_LABELS.AUTH, invalid: false });
+ }
+
+ this.steps = steps;
+
+ if (this.tearsheet?.currentStep >= steps.length) {
+ this.tearsheet.currentStep = steps.length - 1;
+ }
+ }
+
+ onStepChanged() {
+ if (!this.tearsheet) return;
+
+ const hostStep = this.tearsheet.getStepValueByLabel<HostStepType>(STEP_LABELS.HOSTS);
+
+ if (hostStep) {
+ this.stepTwoValue = hostStep;
+ }
+
+ const nextShowAuth = (hostStep?.hostType ?? HOST_TYPE.SPECIFIC) === HOST_TYPE.SPECIFIC;
+
+ if (nextShowAuth !== this.showAuthStep) {
+ this.showAuthStep = nextShowAuth;
+ this.rebuildSteps();
+ }
}
fetchExistingHosts() {
});
}
- onSubmit(payload: SubsystemPayload) {
+ onSubmit(payload: InitiatorsFormPayload) {
this.isSubmitLoading = true;
const taskUrl = `nvmeof/initiator/add`;
+ const hostKeyList = payload.hostDchapKeyList || [];
+ const addedHosts = payload.addedHosts || [];
+ const hosts =
+ payload.hostType === HOST_TYPE.SPECIFIC
+ ? hostKeyList.length
+ ? hostKeyList
+ : addedHosts.map((host_nqn: string) => ({ host_nqn, dhchap_key: '' }))
+ : [];
const request: SubsystemInitiatorRequest = {
allow_all: payload.hostType === HOST_TYPE.ALL,
- hosts: payload.hostType === HOST_TYPE.SPECIFIC ? payload.hostDchapKeyList : [],
+ hosts,
gw_group: this.group
};
this.taskWrapperService
listInitiators() {
this.nvmeofService
.getInitiators(this.subsystemNQN, this.group)
- .subscribe((initiators: NvmeofSubsystemInitiator[]) => {
+ .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
+ const initiators = Array.isArray(response) ? response : response?.hosts || [];
this.initiators = initiators;
this.updateAuthStatus();
});
helperText="A secret key for the subsystem to authenticate itself to hosts."
i18n-helperText
[invalid]="subDK.isInvalid"
- [invalidText]="INVALID_TEXTS['required']">
+ [invalidText]="
+ formGroup.get('subsystemDchapKey')?.errors?.invalidBase64
+ ? INVALID_TEXTS['invalidBase64']
+ : INVALID_TEXTS['required']
+ ">
Subsystem DH-HMAC-CHAP key
<input cdsPassword
cdValidate
<cds-text-label
class="cds-mb-3"
[invalid]="hostKey.isInvalid"
- [invalidText]="INVALID_TEXTS['required']">
+ [invalidText]="
+ hostDhchapKeyCtrl(i)?.errors?.invalidBase64
+ ? INVALID_TEXTS['invalidBase64']
+ : INVALID_TEXTS['required']
+ ">
<span
class="cds-mb-3"
i18n>DHCHAP Key | {{hostDchapKeyItem.get('host_nqn')?.value }}</span>
expect(form.get('authType')?.value).toBe(AUTHENTICATION.Unidirectional);
expect(form.get('subsystemDchapKey')?.value).toBe(null);
});
+
+ it('should keep host key optional in unidirectional mode', () => {
+ const hostKeyCtrl = (form.get('hostDchapKeyList') as any).at(0).get('dhchap_key');
+ hostKeyCtrl.setValue('');
+ hostKeyCtrl.markAsTouched();
+ hostKeyCtrl.updateValueAndValidity();
+
+ expect(hostKeyCtrl.hasError('required')).toBeFalsy();
+ });
+
+ it('should require host key in bidirectional mode', () => {
+ form.get('authType')?.setValue(AUTHENTICATION.Bidirectional);
+ const hostKeyCtrl = (form.get('hostDchapKeyList') as any).at(0).get('dhchap_key');
+ hostKeyCtrl.setValue('');
+ hostKeyCtrl.markAsTouched();
+ hostKeyCtrl.updateValueAndValidity();
+
+ expect(hostKeyCtrl.hasError('required')).toBeTruthy();
+ });
+
+ it('should validate host key base64 format when provided', () => {
+ const hostKeyCtrl = (form.get('hostDchapKeyList') as any).at(0).get('dhchap_key');
+ hostKeyCtrl.setValue('not-valid-key');
+ hostKeyCtrl.markAsTouched();
+ hostKeyCtrl.updateValueAndValidity();
+
+ expect(hostKeyCtrl.hasError('invalidBase64')).toBeTruthy();
+ });
});
});
});
import { Component, Input, OnInit } from '@angular/core';
-import { FormArray, UntypedFormControl, Validators } from '@angular/forms';
+import { FormArray, UntypedFormControl } from '@angular/forms';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
action: string;
pageURL: string;
INVALID_TEXTS = {
- required: $localize`This field is required`
+ required: $localize`This field is required`,
+ invalidBase64: $localize`Invalid key format. Use Base64 or DHHC-1:XX:base64:`
};
AUTHENTICATION = AUTHENTICATION;
this.formGroup = new CdFormGroup({
authType: new UntypedFormControl(AUTHENTICATION.Unidirectional),
subsystemDchapKey: new UntypedFormControl(null, [
+ CdValidators.base64(),
CdValidators.requiredIf({
authType: AUTHENTICATION.Bidirectional
})
});
this.syncHostList();
+ this.formGroup.get('authType')?.valueChanges.subscribe(() => {
+ this.refreshHostKeyValidation();
+ });
}
private createHostDhchapKeyFormGroup(hostNQN: string = '', key: string | null = null) {
return new CdFormGroup({
dhchap_key: new UntypedFormControl(key, {
- validators: [Validators.required]
+ validators: [
+ CdValidators.base64(),
+ CdValidators.custom(
+ 'required',
+ (value: string) =>
+ this.formGroup?.get('authType')?.value === AUTHENTICATION.Bidirectional && !value
+ )
+ ]
}),
host_nqn: new UntypedFormControl(hostNQN)
});
}
+ private refreshHostKeyValidation() {
+ this.hostDchapKeyList.controls.forEach((control) => {
+ control.get('dhchap_key')?.updateValueAndValidity({ emitEvent: false });
+ });
+ }
+
ngOnInit() {
this.createForm();
}