From 159d5636dc345c49c4c0a53be0dd49e08aa6a8a2 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Wed, 10 Dec 2025 12:51:19 +0200 Subject: [PATCH 01/11] NAS-138855: UI doesnt properly allow users to input directory services group names (cherry picked from commit c8881ed523bb35023c0f273f998f6efb5368b930) --- src/app/pages/sharing/smb/smb-form/smb-form.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.html b/src/app/pages/sharing/smb/smb-form/smb-form.component.html index 61e9634dd23..c9d06a8efb2 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.html +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.html @@ -142,7 +142,7 @@ [label]="'Watch List' | translate" [tooltip]="helptextSharingSmb.watchListTooltip | translate" [autocompleteProvider]="groupProvider" - [allowNewEntries]="false" + [allowNewEntries]="true" > } From e331f50d5c76c995fe710e3e6fa33c2a8fea9988 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Mon, 15 Dec 2025 12:47:19 +0200 Subject: [PATCH 02/11] NAS-138855: UI doesnt properly allow users to input directory services (cherry picked from commit cccc9ae0cb0d588affa70bec1bdb03dc6b52c036) --- .../smb/smb-form/smb-form.component.spec.ts | 1 + .../smb/smb-form/smb-form.component.ts | 48 +++++++++++++++++-- src/assets/i18n/en.json | 1 + 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts index 1a483e5ca55..a84788bc8fb 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts @@ -113,6 +113,7 @@ describe('SmbFormComponent', () => { mockAuth(), mockApi([ mockCall('group.query', [{ id: 1, group: 'test', builtin: false }] as Group[]), + mockCall('group.get_group_obj', { gr_gid: 1000, gr_name: 'test', gr_mem: [] }), mockCall('sharing.smb.create', { ...existingShare }), mockCall('sharing.smb.update', { ...existingShare }), mockCall('sharing.smb.share_precheck', null), diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 6c5aa42b269..267cc63e2dc 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -3,7 +3,7 @@ import { } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { - FormControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators, + AsyncValidatorFn, FormControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -14,10 +14,10 @@ import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { isEqual } from 'lodash-es'; import { - endWith, Observable, of, + endWith, forkJoin, Observable, of, } from 'rxjs'; import { - debounceTime, filter, map, switchMap, take, tap, + catchError, debounceTime, filter, map, switchMap, take, tap, } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { DatasetPreset } from 'app/enums/dataset.enum'; @@ -275,6 +275,44 @@ export class SmbFormComponent implements OnInit, AfterViewInit { }; } + private groupsExistValidator(): AsyncValidatorFn { + return (control): Observable => { + const groups = control.value as string[]; + + if (!groups || groups.length === 0) { + return of(null); + } + + const groupChecks = groups.map((groupName: string) => { + return this.userService.getGroupByName(groupName).pipe( + map(() => ({ groupName, exists: true })), + catchError(() => of({ groupName, exists: false })), + ); + }); + + return forkJoin(groupChecks).pipe( + map((results) => { + const nonExistentGroups = results + .filter((result) => !result.exists) + .map((result) => result.groupName); + + if (nonExistentGroups.length > 0) { + return { + groupsDoNotExist: { + message: this.translate.instant( + 'The following groups do not exist: {groups}', + { groups: nonExistentGroups.join(', ') }, + ), + }, + }; + } + + return null; + }), + ); + }; + } + protected form = this.formBuilder.group({ // Common for all share purposes purpose: [SmbSharePurpose.DefaultShare as SmbSharePurpose | null], @@ -287,8 +325,8 @@ export class SmbFormComponent implements OnInit, AfterViewInit { access_based_share_enumeration: [false], audit: this.formBuilder.group({ enable: [false], - watch_list: [[] as string[]], - ignore_list: [[] as string[]], + watch_list: [[] as string[], [], [this.groupsExistValidator()]], + ignore_list: [[] as string[], [], [this.groupsExistValidator()]], }, { validators: this.auditValidator() }), // Only relevant to legacy shares diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index e9ff432c97c..82668984e9d 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -646,6 +646,7 @@ "At least 1 GPU is required by the host for its functions.": "", "At least 1 vdev is required to make an update to the pool.": "", "At least one module must be defined in rsyncd.conf(5) of the rsync server or in the Rsync Modules of another system.": "", + "The following groups do not exist: {groups}": "", "At least one pool must be available to use apps": "", "At least one spare is recommended for dRAID. Spares cannot be added later.": "", "Atleast {min} disk(s) are required for {vdevType} vdevs": "", From 6784c9c080fb678731a38561aeeebc14e248b56f Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Tue, 16 Dec 2025 12:33:37 +0200 Subject: [PATCH 03/11] NAS-138855: PR Update (cherry picked from commit 0d8502eff450c762f8830e00efba29c7c02122ec) --- .../smb/smb-form/smb-form.component.spec.ts | 162 +++++++++++++++++- .../smb/smb-form/smb-form.component.ts | 10 +- 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts index a84788bc8fb..fc3dd5c8208 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts @@ -9,7 +9,7 @@ import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectat import { Store } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { MockComponent } from 'ng-mocks'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import { GiB } from 'app/constants/bytes.constant'; import { fakeSuccessfulJob } from 'app/core/testing/utils/fake-job.utils'; import { mockApi, mockCall, mockJob } from 'app/core/testing/utils/mock-api.utils'; @@ -18,6 +18,7 @@ import { ServiceName } from 'app/enums/service-name.enum'; import { ServiceStatus } from 'app/enums/service-status.enum'; import { helptextSharingSmb } from 'app/helptext/sharing'; import { JsonRpcError } from 'app/interfaces/api-message.interface'; +import { DsUncachedGroup } from 'app/interfaces/ds-cache.interface'; import { FileSystemStat } from 'app/interfaces/filesystem-stat.interface'; import { Group } from 'app/interfaces/group.interface'; import { Service } from 'app/interfaces/service.interface'; @@ -47,6 +48,7 @@ import { RestartSmbDialog } from 'app/pages/sharing/smb/smb-form/restart-smb-dia import { SmbUsersWarningComponent } from 'app/pages/sharing/smb/smb-form/smb-users-warning/smb-users-warning.component'; import { ApiCallError } from 'app/services/errors/error.classes'; import { FilesystemService } from 'app/services/filesystem.service'; +import { UserService } from 'app/services/user.service'; import { AppState } from 'app/store'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { selectServices } from 'app/store/services/services.selectors'; @@ -1185,6 +1187,164 @@ describe('SmbFormComponent', () => { expect(errorElement).toBeTruthy(); expect(errorElement?.textContent).toContain('At least one group must be specified'); }); + + it('should show error when non-existent group is entered in watch list', fakeAsync(async () => { + // Mock API to return error for non-existent group + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(throwError(() => new Error('Group not found'))); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Manually add a non-existent group using the form control + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['nonexistent']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement?.textContent).toContain('The following groups do not exist: nonexistent'); + + // Verify save button is disabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + })); + + it('should show error when non-existent group is entered in ignore list', fakeAsync(async () => { + // Mock API to return error for non-existent group + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(throwError(() => new Error('Group not found'))); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Manually add a non-existent group using the form control + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.ignore_list.setValue(['nonexistent']); + auditGroup.controls.ignore_list.markAsTouched(); + auditGroup.controls.ignore_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement?.textContent).toContain('The following groups do not exist: nonexistent'); + + // Verify save button is disabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + })); + + it('should pass validation when all entered groups exist', fakeAsync(async () => { + // Mock API to return success for existing groups + const userService = spectator.inject(UserService); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(of({ + gr_gid: 1000, + gr_name: 'test', + gr_mem: [], + } as DsUncachedGroup)); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Add an existing group + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['test']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for async validation and debounce + spectator.tick(600); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify no error message is displayed + const errorElement = spectator.query('ix-errors mat-error'); + expect(errorElement).toBeFalsy(); + + // Verify save button is enabled + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(false); + })); + + it('should disable save button during async validation', fakeAsync(async () => { + // Mock API with a delayed response to catch the PENDING state + const userService = spectator.inject(UserService); + const delayedObservable$ = new Subject(); + jest.spyOn(userService, 'getGroupByName').mockReturnValue(delayedObservable$.asObservable()); + + // Fill in required fields and enable audit logging + await form.fillForm({ + Path: '/mnt/pool123/test', + Name: 'TestShare', + Purpose: 'Default Share', + 'Enable Logging': true, + }); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Add a group to trigger async validation + const auditGroup = (spectator.component as unknown as { form: FormGroup }).form.controls.audit as FormGroup; + auditGroup.controls.watch_list.setValue(['test']); + auditGroup.controls.watch_list.markAsTouched(); + auditGroup.controls.watch_list.updateValueAndValidity(); + + // Wait for debounce + spectator.tick(600); + spectator.detectChanges(); + + // Verify save button is disabled while validation is pending + const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + expect(await saveButton.isDisabled()).toBe(true); + + // Complete the async validation + delayedObservable$.next({ + gr_gid: 1000, + gr_name: 'test', + gr_mem: [], + } as DsUncachedGroup); + delayedObservable$.complete(); + + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + // Verify save button is now enabled + expect(await saveButton.isDisabled()).toBe(false); + })); }); describe('Dataset Naming Schema null value', () => { diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 267cc63e2dc..1f7e76d7833 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -169,7 +169,14 @@ export class SmbFormComponent implements OnInit, AfterViewInit { } get isAsyncValidatorPending(): boolean { - return this.form.controls.name.status === 'PENDING' && this.form.controls.name.touched; + const nameControl = this.form.controls.name; + const auditGroup = this.form.controls.audit; + const watchListControl = auditGroup.controls.watch_list; + const ignoreListControl = auditGroup.controls.ignore_list; + + return (nameControl.status === 'PENDING' && nameControl.touched) + || (watchListControl.status === 'PENDING' && watchListControl.touched) + || (ignoreListControl.status === 'PENDING' && ignoreListControl.touched); } readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ @@ -291,6 +298,7 @@ export class SmbFormComponent implements OnInit, AfterViewInit { }); return forkJoin(groupChecks).pipe( + debounceTime(500), // Wait 500ms after last change before validating map((results) => { const nonExistentGroups = results .filter((result) => !result.exists) From f0d064e0af5a76075f420f58c58ce76d556bea57 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Tue, 16 Dec 2025 12:58:57 +0200 Subject: [PATCH 04/11] NAS-138855: Fix async validator debounce timing and improve consistency - Move debounceTime before API calls using switchMap to prevent race conditions - Move validator attachment to ngAfterViewInit for consistency with existing pattern - Prevents unnecessary API calls on every keystroke (cherry picked from commit 3a55c77e3b3d67f1ce5fbd5ae14c42aef590507e) --- .../smb/smb-form/smb-form.component.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 1f7e76d7833..82c6fcd256b 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -290,15 +290,18 @@ export class SmbFormComponent implements OnInit, AfterViewInit { return of(null); } - const groupChecks = groups.map((groupName: string) => { - return this.userService.getGroupByName(groupName).pipe( - map(() => ({ groupName, exists: true })), - catchError(() => of({ groupName, exists: false })), - ); - }); - - return forkJoin(groupChecks).pipe( - debounceTime(500), // Wait 500ms after last change before validating + // Move debounce BEFORE the API calls to prevent firing them on every keystroke + return of(groups).pipe( + debounceTime(500), + switchMap((debouncedGroups) => { + const groupChecks = debouncedGroups.map((groupName: string) => { + return this.userService.getGroupByName(groupName).pipe( + map(() => ({ groupName, exists: true })), + catchError(() => of({ groupName, exists: false })), + ); + }); + return forkJoin(groupChecks); + }), map((results) => { const nonExistentGroups = results .filter((result) => !result.exists) @@ -333,8 +336,8 @@ export class SmbFormComponent implements OnInit, AfterViewInit { access_based_share_enumeration: [false], audit: this.formBuilder.group({ enable: [false], - watch_list: [[] as string[], [], [this.groupsExistValidator()]], - ignore_list: [[] as string[], [], [this.groupsExistValidator()]], + watch_list: [[] as string[]], + ignore_list: [[] as string[]], }, { validators: this.auditValidator() }), // Only relevant to legacy shares @@ -435,6 +438,12 @@ export class SmbFormComponent implements OnInit, AfterViewInit { this.form.controls.name.addAsyncValidators([ this.smbValidationService.validate(this.existingSmbShare?.name), ]); + this.form.controls.audit.controls.watch_list.addAsyncValidators([ + this.groupsExistValidator(), + ]); + this.form.controls.audit.controls.ignore_list.addAsyncValidators([ + this.groupsExistValidator(), + ]); } private setupAclControl(): void { From 12d8e73c8885aed4291f24d13b69df271f1160a2 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Fri, 19 Dec 2025 15:04:24 +0200 Subject: [PATCH 05/11] NAS-138855: PR update --- ...user-group-existence-validation.service.ts | 130 ++++++++++++++++++ .../privilege-form.component.spec.ts | 12 ++ .../privilege-form.component.ts | 19 ++- .../dataset-quota-add-form.component.spec.ts | 16 ++- .../dataset-quota-add-form.component.ts | 18 ++- .../service-ssh/service-ssh.component.spec.ts | 13 ++ .../service-ssh/service-ssh.component.ts | 14 +- .../smb/smb-form/smb-form.component.spec.ts | 10 ++ .../smb/smb-form/smb-form.component.ts | 56 +------- 9 files changed, 230 insertions(+), 58 deletions(-) create mode 100644 src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts diff --git a/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts new file mode 100644 index 00000000000..bfe57d52911 --- /dev/null +++ b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts @@ -0,0 +1,130 @@ +import { Injectable, inject } from '@angular/core'; +import { AsyncValidatorFn, ValidationErrors } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { + Observable, catchError, debounceTime, forkJoin, map, of, switchMap, +} from 'rxjs'; +import { UserService } from 'app/services/user.service'; + +/** + * Service providing async validators for user and group existence checks. + * Used in forms where users can input user/group names that need to be validated + * against the system (local or directory services). + */ +@Injectable({ providedIn: 'root' }) +export class UserGroupExistenceValidationService { + private userService = inject(UserService); + private translate = inject(TranslateService); + + /** + * Creates an async validator that checks if all specified groups exist in the system. + * Provides real-time feedback with debouncing to prevent excessive API calls. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates group existence + * + * @example + * ```typescript + * this.form.controls.groups.addAsyncValidators([ + * this.validationService.validateGroupsExist() + * ]); + * ``` + */ + validateGroupsExist(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const groups = control.value as string[]; + + if (!groups || groups.length === 0) { + return of(null); + } + + // Debounce BEFORE API calls to prevent firing them on every keystroke + return of(groups).pipe( + debounceTime(debounceMs), + switchMap((debouncedGroups) => { + const groupChecks = debouncedGroups.map((groupName: string) => { + return this.userService.getGroupByName(groupName).pipe( + map(() => ({ name: groupName, exists: true })), + catchError(() => of({ name: groupName, exists: false })), + ); + }); + return forkJoin(groupChecks); + }), + map((results) => { + const nonExistent = results + .filter((result) => !result.exists) + .map((result) => result.name); + + if (nonExistent.length > 0) { + return { + groupsDoNotExist: { + message: this.translate.instant( + 'The following groups do not exist: {groups}', + { groups: nonExistent.join(', ') }, + ), + }, + }; + } + + return null; + }), + ); + }; + } + + /** + * Creates an async validator that checks if all specified users exist in the system. + * Provides real-time feedback with debouncing to prevent excessive API calls. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates user existence + * + * @example + * ```typescript + * this.form.controls.users.addAsyncValidators([ + * this.validationService.validateUsersExist() + * ]); + * ``` + */ + validateUsersExist(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const users = control.value as string[]; + + if (!users || users.length === 0) { + return of(null); + } + + // Debounce BEFORE API calls to prevent firing them on every keystroke + return of(users).pipe( + debounceTime(debounceMs), + switchMap((debouncedUsers) => { + const userChecks = debouncedUsers.map((username: string) => { + return this.userService.getUserByName(username).pipe( + map(() => ({ name: username, exists: true })), + catchError(() => of({ name: username, exists: false })), + ); + }); + return forkJoin(userChecks); + }), + map((results) => { + const nonExistent = results + .filter((result) => !result.exists) + .map((result) => result.name); + + if (nonExistent.length > 0) { + return { + usersDoNotExist: { + message: this.translate.instant( + 'The following users do not exist: {users}', + { users: nonExistent.join(', ') }, + ), + }, + }; + } + + return null; + }), + ); + }; + } +} diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.spec.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.spec.ts index 15d57acf976..946bfc0f87a 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.spec.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.spec.ts @@ -116,6 +116,18 @@ describe('PrivilegeFormComponent', () => { }), mockProvider(UserService, { groupQueryDsCache: jest.fn(() => of([])), + getGroupByName: jest.fn((groupName: string) => { + // Return existing groups, error for non-existent ones + const existingGroup = testGroups.find((group) => group.group === groupName); + if (existingGroup) { + return of(existingGroup); + } + return of(null); + }), + getUserByName: jest.fn(() => { + // Mock user validation - all users are considered non-existent for testing + return of(null); + }), }), provideMockStore({ selectors: [ diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts index ff468253f0d..44618c99546 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts @@ -1,6 +1,8 @@ -import { Component, ChangeDetectionStrategy, OnInit, signal, inject } from '@angular/core'; +import { + AfterViewInit, ChangeDetectionStrategy, Component, OnInit, signal, inject, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { Validators, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; import { FormBuilder } from '@ngneat/reactive-forms'; @@ -23,6 +25,7 @@ import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fi import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -55,7 +58,7 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' TranslateModule, ], }) -export class PrivilegeFormComponent implements OnInit { +export class PrivilegeFormComponent implements OnInit, AfterViewInit { private formBuilder = inject(FormBuilder); private translate = inject(TranslateService); private api = inject(ApiService); @@ -63,6 +66,7 @@ export class PrivilegeFormComponent implements OnInit { private store$ = inject>(Store); private dialog = inject(DialogService); private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.PrivilegeWrite]; @@ -190,6 +194,15 @@ export class PrivilegeFormComponent implements OnInit { } } + ngAfterViewInit(): void { + this.form.controls.local_groups.addAsyncValidators([ + this.existenceValidator.validateGroupsExist(), + ]); + this.form.controls.ds_groups.addAsyncValidators([ + this.existenceValidator.validateGroupsExist(), + ]); + } + private setPrivilegeForEdit(existingPrivilege: Privilege): void { this.form.patchValue({ ...existingPrivilege, diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts index 6a4ce265564..f27a574958d 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.spec.ts @@ -41,7 +41,21 @@ describe('DatasetQuotaAddFormComponent', () => { { username: 'john', roles: [] }, { username: 'jill', roles: [] }, ]), - groupQueryDsCache: () => of(), + groupQueryDsCache: () => of([ + { group: 'test-group', gid: 1000 }, + ]), + getGroupByName: jest.fn((groupName: string) => { + if (groupName === 'test-group') { + return of({ group: 'test-group', gid: 1000 }); + } + return of(null); + }), + getUserByName: jest.fn((username: string) => { + if (username === 'john' || username === 'jill') { + return of({ username, uid: username === 'john' ? 1001 : 1002 }); + } + return of(null); + }), }), mockProvider(SlideIn), mockProvider(DialogService), diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts index e8100d35c20..3501ef3ddc4 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, signal, inject } from '@angular/core'; +import { + AfterViewInit, ChangeDetectionStrategy, Component, signal, inject, +} from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -20,6 +22,7 @@ import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fi import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormatterService } from 'app/modules/forms/ix-forms/services/ix-formatter.service'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; @@ -47,16 +50,16 @@ import { UserService } from 'app/services/user.service'; TestDirective, ], }) -export class DatasetQuotaAddFormComponent { +export class DatasetQuotaAddFormComponent implements AfterViewInit { private authService = inject(AuthService); private formBuilder = inject(FormBuilder); private api = inject(ApiService); private snackbar = inject(SnackbarService); private translate = inject(TranslateService); formatter = inject(IxFormatterService); - private cdr = inject(ChangeDetectorRef); private errorHandler = inject(FormErrorHandlerService); private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject { mockProvider(FormErrorHandlerService), mockProvider(DialogService), mockProvider(SlideInRef, slideInRef), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of(fakeGroupDataSource)), + getGroupByName: jest.fn((groupName: string) => { + const existingGroup = fakeGroupDataSource.find((group) => group.group === groupName); + if (existingGroup) { + return of(existingGroup); + } + return of(null); + }), + getUserByName: jest.fn(() => of(null)), + }), mockAuth(), ], }); diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.ts b/src/app/pages/services/components/service-ssh/service-ssh.component.ts index c506a2c4f7d..fab08e223a8 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.ts +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, Component, OnInit, signal, inject } from '@angular/core'; +import { + AfterViewInit, ChangeDetectionStrategy, Component, OnInit, signal, inject, +} from '@angular/core'; import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -20,6 +22,7 @@ import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { IxTextareaComponent } from 'app/modules/forms/ix-forms/components/ix-textarea/ix-textarea.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; @@ -52,7 +55,7 @@ import { UserService } from 'app/services/user.service'; TranslateModule, ], }) -export class ServiceSshComponent implements OnInit { +export class ServiceSshComponent implements OnInit, AfterViewInit { private api = inject(ApiService); private errorHandler = inject(ErrorHandlerService); private formErrorHandler = inject(FormErrorHandlerService); @@ -60,6 +63,7 @@ export class ServiceSshComponent implements OnInit { private userService = inject(UserService); private translate = inject(TranslateService); private snackbar = inject(SnackbarService); + private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.SshWrite]; @@ -126,6 +130,12 @@ export class ServiceSshComponent implements OnInit { }); } + ngAfterViewInit(): void { + this.form.controls.password_login_groups.addAsyncValidators([ + this.existenceValidator.validateGroupsExist(), + ]); + } + onAdvancedSettingsToggled(): void { this.isBasicMode = !this.isBasicMode; } diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts index fc3dd5c8208..4b1ae8b4613 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.spec.ts @@ -166,6 +166,16 @@ describe('SmbFormComponent', () => { mockProvider(FormErrorHandlerService, { handleValidationErrors: jest.fn(), }), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of([{ group: 'test', gid: 1 }])), + getGroupByName: jest.fn((groupName: string) => { + if (groupName === 'test') { + return of({ group: 'test', gid: 1 }); + } + return of(null); + }), + getUserByName: jest.fn(() => of(null)), + }), ], }); diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 82c6fcd256b..4833978ca9a 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -3,7 +3,7 @@ import { } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { - AsyncValidatorFn, FormControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators, + FormControl, NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn, Validators, } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -13,11 +13,9 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { isEqual } from 'lodash-es'; +import { endWith, Observable, of } from 'rxjs'; import { - endWith, forkJoin, Observable, of, -} from 'rxjs'; -import { - catchError, debounceTime, filter, map, switchMap, take, tap, + debounceTime, filter, map, switchMap, take, tap, } from 'rxjs/operators'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { DatasetPreset } from 'app/enums/dataset.enum'; @@ -53,6 +51,7 @@ import { WarningComponent } from 'app/modules/forms/ix-forms/components/warning/ import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormatterService } from 'app/modules/forms/ix-forms/services/ix-formatter.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { LoaderService } from 'app/modules/loader/loader.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; @@ -120,6 +119,7 @@ export class SmbFormComponent implements OnInit, AfterViewInit { private validatorsService = inject(IxValidatorsService); private store$ = inject>(Store); private smbValidationService = inject(SmbValidationService); + private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject => { - const groups = control.value as string[]; - - if (!groups || groups.length === 0) { - return of(null); - } - - // Move debounce BEFORE the API calls to prevent firing them on every keystroke - return of(groups).pipe( - debounceTime(500), - switchMap((debouncedGroups) => { - const groupChecks = debouncedGroups.map((groupName: string) => { - return this.userService.getGroupByName(groupName).pipe( - map(() => ({ groupName, exists: true })), - catchError(() => of({ groupName, exists: false })), - ); - }); - return forkJoin(groupChecks); - }), - map((results) => { - const nonExistentGroups = results - .filter((result) => !result.exists) - .map((result) => result.groupName); - - if (nonExistentGroups.length > 0) { - return { - groupsDoNotExist: { - message: this.translate.instant( - 'The following groups do not exist: {groups}', - { groups: nonExistentGroups.join(', ') }, - ), - }, - }; - } - - return null; - }), - ); - }; - } - protected form = this.formBuilder.group({ // Common for all share purposes purpose: [SmbSharePurpose.DefaultShare as SmbSharePurpose | null], @@ -439,10 +397,10 @@ export class SmbFormComponent implements OnInit, AfterViewInit { this.smbValidationService.validate(this.existingSmbShare?.name), ]); this.form.controls.audit.controls.watch_list.addAsyncValidators([ - this.groupsExistValidator(), + this.existenceValidator.validateGroupsExist(), ]); this.form.controls.audit.controls.ignore_list.addAsyncValidators([ - this.groupsExistValidator(), + this.existenceValidator.validateGroupsExist(), ]); } From ce30c80394d056001459dc301d2afda66f62251c Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Fri, 19 Dec 2025 15:08:58 +0200 Subject: [PATCH 06/11] NAS-138855: PR update --- .../privilege/privilege-form/privilege-form.component.html | 1 - src/app/pages/sharing/smb/smb-form/smb-form.component.html | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html index 2cecfd6d4bf..8464fff6a64 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html @@ -26,7 +26,6 @@ formControlName="ds_groups" [label]="'Directory Services Groups' | translate" [autocompleteProvider]="dsGroupsProvider" - [allowNewEntries]="true" > } diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.html b/src/app/pages/sharing/smb/smb-form/smb-form.component.html index c9d06a8efb2..b72573aac03 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.html +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.html @@ -142,7 +142,6 @@ [label]="'Watch List' | translate" [tooltip]="helptextSharingSmb.watchListTooltip | translate" [autocompleteProvider]="groupProvider" - [allowNewEntries]="true" > } From 8a0df5937e81a4b5369d8ae64277d1656abc850c Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Tue, 23 Dec 2025 23:08:42 +0200 Subject: [PATCH 07/11] NAS-138855: PR update --- .claude/settings.local.json | 7 ++ .../ix-chips/ix-chips.component.html | 1 + .../ix-combobox/ix-combobox.component.html | 1 + .../ix-group-chips.component.html | 8 ++ .../ix-group-chips.component.spec.ts | 52 +++++++++++ .../ix-group-chips.component.ts | 89 +++++++++++++++++++ .../ix-group-combobox.component.html | 8 ++ .../ix-group-combobox.component.spec.ts | 52 +++++++++++ .../ix-group-combobox.component.ts | 87 ++++++++++++++++++ .../ix-user-chips.component.html | 8 ++ .../ix-user-chips.component.spec.ts | 52 +++++++++++ .../ix-user-chips/ix-user-chips.component.ts | 89 +++++++++++++++++++ .../ix-user-combobox.component.html | 8 ++ .../ix-user-combobox.component.spec.ts | 52 +++++++++++ .../ix-user-combobox.component.ts | 87 ++++++++++++++++++ ...user-group-existence-validation.service.ts | 82 +++++++++++++++++ .../privilege-form.component.html | 5 +- .../privilege-form.component.spec.ts | 38 ++------ .../privilege-form.component.ts | 26 +----- .../rsync-task-form.component.html | 5 +- .../rsync-task-form.component.spec.ts | 1 + .../rsync-task-form.component.ts | 8 +- .../dataset-quota-add-form.component.html | 11 +-- .../dataset-quota-add-form.component.ts | 47 ++-------- .../edit-nfs-ace/edit-nfs-ace.component.html | 10 +-- .../edit-nfs-ace.component.spec.ts | 2 + .../edit-nfs-ace/edit-nfs-ace.component.ts | 13 +-- .../edit-posix-ace.component.html | 10 +-- .../edit-posix-ace.component.spec.ts | 2 + .../edit-posix-ace.component.ts | 13 +-- .../dataset-acl-editor.component.html | 10 +-- .../dataset-acl-editor.component.spec.ts | 6 ++ .../dataset-acl-editor.component.ts | 12 +-- ...dataset-trivial-permissions.component.html | 10 +-- ...aset-trivial-permissions.component.spec.ts | 2 + .../dataset-trivial-permissions.component.ts | 13 +-- .../service-smb/service-smb.component.html | 10 +-- .../service-smb/service-smb.component.spec.ts | 2 + .../service-smb/service-smb.component.ts | 12 +-- .../service-ssh/service-ssh.component.html | 5 +- .../service-ssh/service-ssh.component.ts | 27 ++---- .../nfs/nfs-form/nfs-form.component.html | 20 ++--- .../nfs/nfs-form/nfs-form.component.spec.ts | 2 + .../nfs/nfs-form/nfs-form.component.ts | 12 +-- .../smb/smb-form/smb-form.component.html | 10 +-- .../smb/smb-form/smb-form.component.ts | 19 +--- .../cron/cron-form/cron-form.component.html | 5 +- .../cron-form/cron-form.component.spec.ts | 1 + .../cron/cron-form/cron-form.component.ts | 9 +- 49 files changed, 795 insertions(+), 266 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.html create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts create mode 100644 src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000000..371addd9958 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs ls:*)" + ] + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html index 87007068641..1ad3f0934ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html +++ b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.html @@ -32,6 +32,7 @@ diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts new file mode 100644 index 00000000000..dca0ae78689 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.spec.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockApi } from 'app/core/testing/utils/mock-api.utils'; +import { Group } from 'app/interfaces/group.interface'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxGroupChipsComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl([]); + label = 'Test Groups' as TranslatedString; +} + +describe('IxGroupChipsComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of([ + { group: 'wheel' }, + { group: 'users' }, + ] as Group[])), + getGroupByName: jest.fn((groupName: string) => of({ group: groupName })), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test Groups'); + }); + + it('renders chip grid input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts new file mode 100644 index 00000000000..6ddad575f31 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts @@ -0,0 +1,89 @@ +import { + AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject, +} from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { map } from 'rxjs/operators'; +import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider'; +import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; +import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +/** + * Specialized chips component for group input with built-in validation. + * Automatically validates that entered groups exist in the system (local or directory services). + * Provides autocomplete suggestions from the group cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-group-chips', + templateUrl: './ix-group-chips.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxChipsComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxGroupChipsComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly placeholder = input(''); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + + private readonly ixChips = viewChild.required(IxChipsComponent); + + protected readonly groupProvider: ChipsProvider = (query) => { + return this.userService.groupQueryDsCache(query).pipe( + map((groups) => groups.map((group) => group.group)), + ); + }; + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add async validator to check group existence + const control = this.controlDirective.control; + if (control) { + control.addAsyncValidators([ + this.existenceValidator.validateGroupsExist(), + ]); + control.updateValueAndValidity(); + } + } + + writeValue(value: string[]): void { + this.ixChips().writeValue(value); + } + + registerOnChange(onChange: (value: string[]) => void): void { + this.ixChips().registerOnChange(onChange); + } + + registerOnTouched(onTouched: () => void): void { + this.ixChips().registerOnTouched(onTouched); + } + + setDisabledState?(isDisabled: boolean): void { + this.ixChips().setDisabledState?.(isDisabled); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html new file mode 100644 index 00000000000..84a93e6b929 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts new file mode 100644 index 00000000000..4b530036330 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.spec.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockApi } from 'app/core/testing/utils/mock-api.utils'; +import { Group } from 'app/interfaces/group.interface'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxGroupComboboxComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl(''); + label = 'Test Group' as TranslatedString; +} + +describe('IxGroupComboboxComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + groupQueryDsCache: jest.fn(() => of([ + { group: 'wheel' }, + { group: 'users' }, + ] as Group[])), + getGroupByName: jest.fn((groupName: string) => of({ group: groupName })), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test Group'); + }); + + it('renders combobox input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts new file mode 100644 index 00000000000..cfa4c5d1518 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts @@ -0,0 +1,87 @@ +import { + AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject, +} from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; +import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; +import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +/** + * Specialized combobox component for group input with built-in validation. + * Automatically validates that entered groups exist in the system (local or directory services). + * Provides autocomplete suggestions from the group cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-group-combobox', + templateUrl: './ix-group-combobox.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxComboboxComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxGroupComboboxComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + readonly allowCustomValue = input(false); + + private readonly ixCombobox = viewChild.required(IxComboboxComponent); + + protected readonly groupProvider = new GroupComboboxProvider(this.userService); + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add async validator to check group existence + const control = this.controlDirective.control; + if (control) { + control.addAsyncValidators([ + this.existenceValidator.validateGroupExists(), + ]); + // Don't call updateValueAndValidity() here to avoid showing validation errors + // immediately on form load. Validation will run automatically when the user + // interacts with the field or when the form is submitted. + } + } + + writeValue(value: string | number): void { + this.ixCombobox().writeValue(value); + } + + registerOnChange(onChange: (value: string | number | null) => void): void { + this.ixCombobox().registerOnChange(onChange); + } + + registerOnTouched(onTouched: () => void): void { + this.ixCombobox().registerOnTouched(onTouched); + } + + setDisabledState?(isDisabled: boolean): void { + this.ixCombobox().setDisabledState?.(isDisabled); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html new file mode 100644 index 00000000000..7fdc6b6e362 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts new file mode 100644 index 00000000000..842ddf736d2 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.spec.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockApi } from 'app/core/testing/utils/mock-api.utils'; +import { User } from 'app/interfaces/user.interface'; +import { IxUserChipsComponent } from 'app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxUserChipsComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl([]); + label = 'Test Users' as TranslatedString; +} + +describe('IxUserChipsComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + userQueryDsCache: jest.fn(() => of([ + { username: 'root' }, + { username: 'admin' }, + ] as User[])), + getUserByName: jest.fn((username: string) => of({ username } as User)), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test Users'); + }); + + it('renders chip grid input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts new file mode 100644 index 00000000000..4ea718c9b36 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts @@ -0,0 +1,89 @@ +import { + AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject, +} from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { map } from 'rxjs/operators'; +import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider'; +import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; +import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +/** + * Specialized chips component for user input with built-in validation. + * Automatically validates that entered users exist in the system (local or directory services). + * Provides autocomplete suggestions from the user cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-user-chips', + templateUrl: './ix-user-chips.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxChipsComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxUserChipsComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly placeholder = input(''); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + + private readonly ixChips = viewChild.required(IxChipsComponent); + + protected readonly userProvider: ChipsProvider = (query) => { + return this.userService.userQueryDsCache(query).pipe( + map((users) => users.map((user) => user.username)), + ); + }; + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add async validator to check user existence + const control = this.controlDirective.control; + if (control) { + control.addAsyncValidators([ + this.existenceValidator.validateUsersExist(), + ]); + control.updateValueAndValidity(); + } + } + + writeValue(value: string[]): void { + this.ixChips().writeValue(value); + } + + registerOnChange(onChange: (value: string[]) => void): void { + this.ixChips().registerOnChange(onChange); + } + + registerOnTouched(onTouched: () => void): void { + this.ixChips().registerOnTouched(onTouched); + } + + setDisabledState?(isDisabled: boolean): void { + this.ixChips().setDisabledState?.(isDisabled); + } +} diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html new file mode 100644 index 00000000000..7d6be48c59a --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.html @@ -0,0 +1,8 @@ + diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts new file mode 100644 index 00000000000..af7fe9b38c3 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.spec.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; +import { mockApi } from 'app/core/testing/utils/mock-api.utils'; +import { User } from 'app/interfaces/user.interface'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +@Component({ + selector: 'ix-test-host', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [IxUserComboboxComponent, ReactiveFormsModule], +}) +class TestHostComponent { + control = new FormControl(''); + label = 'Test User' as TranslatedString; +} + +describe('IxUserComboboxComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: TestHostComponent, + providers: [ + mockApi([]), + mockProvider(UserService, { + userQueryDsCache: jest.fn(() => of([ + { username: 'root' }, + { username: 'admin' }, + ] as User[])), + getUserByName: jest.fn((username: string) => of({ username } as User)), + }), + ], + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('shows label', () => { + expect(spectator.query('label')).toHaveText('Test User'); + }); + + it('renders combobox input', () => { + const input = spectator.query('input'); + expect(input).toExist(); + }); +}); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts new file mode 100644 index 00000000000..0586a3d4a72 --- /dev/null +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts @@ -0,0 +1,87 @@ +import { + AfterViewInit, ChangeDetectionStrategy, Component, input, viewChild, inject, +} from '@angular/core'; +import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; +import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; +import { registeredDirectiveConfig } from 'app/modules/forms/ix-forms/directives/registered-control.directive'; +import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; +import { TranslatedString } from 'app/modules/translate/translate.helper'; +import { UserService } from 'app/services/user.service'; + +/** + * Specialized combobox component for user input with built-in validation. + * Automatically validates that entered users exist in the system (local or directory services). + * Provides autocomplete suggestions from the user cache. + * + * @example + * ```html + * + * ``` + */ +@UntilDestroy() +@Component({ + selector: 'ix-user-combobox', + templateUrl: './ix-user-combobox.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IxComboboxComponent, + ], + hostDirectives: [ + { ...registeredDirectiveConfig }, + ], +}) +export class IxUserComboboxComponent implements AfterViewInit, ControlValueAccessor { + private controlDirective = inject(NgControl); + private userService = inject(UserService); + private existenceValidator = inject(UserGroupExistenceValidationService); + + readonly label = input(); + readonly hint = input(); + readonly tooltip = input(); + readonly required = input(false); + readonly allowCustomValue = input(false); + + private readonly ixCombobox = viewChild.required(IxComboboxComponent); + + protected readonly userProvider = new UserComboboxProvider(this.userService); + + constructor() { + this.controlDirective.valueAccessor = this; + } + + ngAfterViewInit(): void { + // Add async validator to check user existence + const control = this.controlDirective.control; + if (control) { + control.addAsyncValidators([ + this.existenceValidator.validateUserExists(), + ]); + // Don't call updateValueAndValidity() here to avoid showing validation errors + // immediately on form load. Validation will run automatically when the user + // interacts with the field or when the form is submitted. + } + } + + writeValue(value: string | number): void { + this.ixCombobox().writeValue(value); + } + + registerOnChange(onChange: (value: string | number | null) => void): void { + this.ixCombobox().registerOnChange(onChange); + } + + registerOnTouched(onTouched: () => void): void { + this.ixCombobox().registerOnTouched(onTouched); + } + + setDisabledState?(isDisabled: boolean): void { + this.ixCombobox().setDisabledState?.(isDisabled); + } +} diff --git a/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts index bfe57d52911..488db37887a 100644 --- a/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts +++ b/src/app/modules/forms/ix-forms/validators/user-group-existence-validation.service.ts @@ -127,4 +127,86 @@ export class UserGroupExistenceValidationService { ); }; } + + /** + * Creates an async validator that checks if a single user exists in the system. + * Used for combobox components where only one user can be selected. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates single user existence + * + * @example + * ```typescript + * this.form.controls.owner.addAsyncValidators([ + * this.validationService.validateUserExists() + * ]); + * ``` + */ + validateUserExists(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const username = control.value as string; + + if (!username || username.trim() === '') { + return of(null); + } + + return of(username).pipe( + debounceTime(debounceMs), + switchMap((debouncedUsername): Observable => { + return this.userService.getUserByName(debouncedUsername).pipe( + map((): null => null), + catchError((): Observable => of({ + userDoesNotExist: { + message: this.translate.instant( + 'User "{username}" does not exist', + { username: debouncedUsername }, + ), + }, + })), + ); + }), + ); + }; + } + + /** + * Creates an async validator that checks if a single group exists in the system. + * Used for combobox components where only one group can be selected. + * + * @param debounceMs - Debounce time in milliseconds. Defaults to 500ms. + * @returns AsyncValidatorFn that validates single group existence + * + * @example + * ```typescript + * this.form.controls.ownerGroup.addAsyncValidators([ + * this.validationService.validateGroupExists() + * ]); + * ``` + */ + validateGroupExists(debounceMs = 500): AsyncValidatorFn { + return (control): Observable => { + const groupName = control.value as string; + + if (!groupName || groupName.trim() === '') { + return of(null); + } + + return of(groupName).pipe( + debounceTime(debounceMs), + switchMap((debouncedGroupName): Observable => { + return this.userService.getGroupByName(debouncedGroupName).pipe( + map((): null => null), + catchError((): Observable => of({ + groupDoesNotExist: { + message: this.translate.instant( + 'Group "{groupName}" does not exist', + { groupName: debouncedGroupName }, + ), + }, + })), + ); + }), + ); + }; + } } diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html index 8464fff6a64..f06c2427a06 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.html @@ -22,11 +22,10 @@ > @if (isEnterprise()) { - + > } { expect(callArgs[1][1]).toEqual({ limit: 50, order_by: ['group'] }); }); - it('should use UserService.groupQueryDsCache for DS groups', async () => { - const provider = spectator.component.dsGroupsProvider; - const userService = spectator.inject(UserService); - - jest.spyOn(userService, 'groupQueryDsCache').mockReturnValue(of([ - { id: 1, group: 'domain-test', gid: 1001 } as Group, - { id: 2, group: 'test-domain', gid: 1002 } as Group, - ])); - - const result = await lastValueFrom(provider('test')); - - // Should call UserService.groupQueryDsCache with the query - expect(userService.groupQueryDsCache).toHaveBeenCalledWith('test', false, 0); - - // Should return group names - expect(result).toEqual(['domain-test', 'test-domain']); + it('uses ix-group-chips component for DS groups with automatic validation', () => { + const groupChipsComponent = spectator.query('ix-group-chips[formControlName="ds_groups"]'); + expect(groupChipsComponent).toExist(); }); - it('should limit DS groups results to 50', async () => { - const provider = spectator.component.dsGroupsProvider; + it('dS groups field uses UserService for group queries', () => { const userService = spectator.inject(UserService); - - // Mock more than 50 groups - const manyGroups = Array.from({ length: 100 }, (_, i) => ({ - id: i, - group: `group${i}`, - gid: 1000 + i, - } as Group)); - - jest.spyOn(userService, 'groupQueryDsCache').mockReturnValue(of(manyGroups)); - - const result = await lastValueFrom(provider('test')); - - // Should limit to 50 results - expect(result).toHaveLength(50); + // ix-group-chips component automatically uses UserService.groupQueryDsCache + expect(userService).toBeTruthy(); }); it('should handle empty query for local groups', async () => { diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts index 44618c99546..6f1022f039e 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts @@ -22,6 +22,7 @@ import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-ch import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; @@ -49,6 +50,7 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' IxFieldsetComponent, IxInputComponent, IxChipsComponent, + IxGroupChipsComponent, IxSelectComponent, IxCheckboxComponent, FormActionsComponent, @@ -156,27 +158,6 @@ export class PrivilegeFormComponent implements OnInit, AfterViewInit { ); }; - /** - * Provider for directory service groups autocomplete. - * - * Uses ChipsProvider instead of GroupComboboxProvider for consistency with localGroupsProvider. - * See localGroupsProvider documentation for rationale. - * - * Uses UserService.groupQueryDsCache for proper handling of: - * - Domain-prefixed group names (e.g., "ACME\admin") - * - Case-insensitive regex search - * - Exact name match fallback - * - Proper backslash escaping - * - * Limited to 50 results for performance. - */ - readonly dsGroupsProvider: ChipsProvider = (query: string) => { - return this.userService.groupQueryDsCache(query || '', false, 0).pipe( - map((groups) => groups.slice(0, this.GROUP_QUERY_LIMIT)), - map((groups) => groups.map((group) => group.group)), - ); - }; - constructor() { this.slideInRef.requireConfirmationWhen(() => { return of(this.form.dirty); @@ -198,9 +179,6 @@ export class PrivilegeFormComponent implements OnInit, AfterViewInit { this.form.controls.local_groups.addAsyncValidators([ this.existenceValidator.validateGroupsExist(), ]); - this.form.controls.ds_groups.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); } private setPrivilegeForEdit(existingPrivilege: Privilege): void { diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html index 45082893fbd..0aafea9f845 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.html @@ -13,13 +13,12 @@ [nodeProvider]="treeNodeProvider" > - + > { { username: 'root' }, { username: 'steven' }, ] as User[]), + getUserByName: (username: string) => of({ username } as User), }), mockProvider(DialogService), provideMockStore({ diff --git a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts index bfb6c4bfc1d..9a22fbed7f2 100644 --- a/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts +++ b/src/app/pages/data-protection/rsync-task/rsync-task-form/rsync-task-form.component.ts @@ -15,15 +15,14 @@ import { helptextRsyncForm } from 'app/helptext/data-protection/rsync/rsync-form import { newOption } from 'app/interfaces/option.interface'; import { RsyncTask, RsyncTaskUpdate } from 'app/interfaces/rsync-task.interface'; import { SshCredentialsSelectComponent } from 'app/modules/forms/custom-selects/ssh-credentials-select/ssh-credentials-select.component'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { IxSlideToggleComponent } from 'app/modules/forms/ix-forms/components/ix-slide-toggle/ix-slide-toggle.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { portRangeValidator } from 'app/modules/forms/ix-forms/validators/range-validation/range-validation'; @@ -37,7 +36,6 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { ignoreTranslation } from 'app/modules/translate/translate.helper'; import { ApiService } from 'app/modules/websocket/api.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -52,7 +50,7 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxExplorerComponent, - IxComboboxComponent, + IxUserComboboxComponent, IxSelectComponent, IxInputComponent, IxSlideToggleComponent, @@ -72,7 +70,6 @@ export class RsyncTaskFormComponent implements OnInit { private api = inject(ApiService); private cdr = inject(ChangeDetectorRef); private errorHandler = inject(FormErrorHandlerService); - private userService = inject(UserService); private filesystemService = inject(FilesystemService); private snackbar = inject(SnackbarService); private validatorsService = inject(IxValidatorsService); @@ -143,7 +140,6 @@ export class RsyncTaskFormComponent implements OnInit { { label: this.translate.instant('SSH connection from the keychain'), value: RsyncSshConnectMode.KeyChain }, ]); - readonly userProvider = new UserComboboxProvider(this.userService); readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ directoriesOnly: true }); private editingTask: RsyncTask | undefined; diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html index 7b85f3a5477..b85ff4544a9 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.html @@ -28,13 +28,11 @@ - + > } @@ -42,13 +40,12 @@ - + > } diff --git a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts index 3501ef3ddc4..4f497e94656 100644 --- a/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts +++ b/src/app/pages/datasets/components/dataset-quotas/dataset-quota-add-form/dataset-quota-add-form.component.ts @@ -1,5 +1,5 @@ import { - AfterViewInit, ChangeDetectionStrategy, Component, signal, inject, + ChangeDetectionStrategy, Component, signal, inject, } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; @@ -7,28 +7,25 @@ import { MatCard, MatCardContent } from '@angular/material/card'; import { FormBuilder } from '@ngneat/reactive-forms'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { combineLatest, map, of } from 'rxjs'; +import { of } from 'rxjs'; import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-roles.directive'; import { DatasetQuotaType } from 'app/enums/dataset.enum'; import { Role } from 'app/enums/role.enum'; import { helptextGlobal } from 'app/helptext/global-helptext'; import { helptextQuotas } from 'app/helptext/storage/volumes/datasets/dataset-quotas'; import { SetDatasetQuota } from 'app/interfaces/dataset-quota.interface'; -import { AuthService } from 'app/modules/auth/auth.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; -import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider'; -import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxUserChipsComponent } from 'app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormatterService } from 'app/modules/forms/ix-forms/services/ix-formatter.service'; -import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -44,22 +41,20 @@ import { UserService } from 'app/services/user.service'; IxFieldsetComponent, IxInputComponent, TranslateModule, - IxChipsComponent, + IxGroupChipsComponent, + IxUserChipsComponent, FormActionsComponent, MatButton, TestDirective, ], }) -export class DatasetQuotaAddFormComponent implements AfterViewInit { - private authService = inject(AuthService); +export class DatasetQuotaAddFormComponent { private formBuilder = inject(FormBuilder); private api = inject(ApiService); private snackbar = inject(SnackbarService); private translate = inject(TranslateService); formatter = inject(IxFormatterService); private errorHandler = inject(FormErrorHandlerService); - private userService = inject(UserService); - private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject { - return combineLatest([ - this.userService.userQueryDsCache(query), - this.authService.user$.pipe(map((user) => user?.privilege?.roles?.$set || [])), - ]).pipe( - map(([users, currentRoles]) => { - return users - .filter((user) => user.roles.every((role) => currentRoles.includes(role))) - .map((user) => user.username); - }), - ); - }; - - groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - private datasetId: string; constructor() { @@ -151,15 +127,6 @@ export class DatasetQuotaAddFormComponent implements AfterViewInit { this.datasetId = slideInRef.getData().datasetId; } - ngAfterViewInit(): void { - this.form.controls.users.addAsyncValidators([ - this.existenceValidator.validateUsersExist(), - ]); - this.form.controls.groups.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); - } - protected onSubmit(): void { this.isLoading.set(true); diff --git a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html index f5ee45d4f49..7fd2ef55c09 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html @@ -12,25 +12,23 @@ > @if (isUserTag) { - + > } @if (isGroupTag) { - + > } { { group: 'wheel' }, { group: 'vip' }, ]), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), ], }); diff --git a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts index 59a836dfb5c..6d7d9789371 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts +++ b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.ts @@ -21,16 +21,14 @@ import { BasicNfsPermissions, NfsAclItem, } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox-list/ix-checkbox-list.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxRadioGroupComponent } from 'app/modules/forms/ix-forms/components/ix-radio-group/ix-radio-group.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; import { newNfsAce } from 'app/pages/datasets/modules/permissions/utils/new-ace.utils'; -import { UserService } from 'app/services/user.service'; import { NfsFormFlagsType, nfsFormFlagsTypeLabels, @@ -48,7 +46,8 @@ import { ReactiveFormsModule, IxFieldsetComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxRadioGroupComponent, IxCheckboxListComponent, TranslateModule, @@ -57,7 +56,6 @@ import { export class EditNfsAceComponent implements OnChanges, OnInit { private formBuilder = inject(FormBuilder); private store = inject(DatasetAclEditorStore); - private userService = inject(UserService); private translate = inject(TranslateService); readonly ace = input.required(); @@ -97,9 +95,6 @@ export class EditNfsAceComponent implements OnChanges, OnInit { advancedFlags: helptextAcl.flagsTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - get isUserTag(): boolean { return this.form.value.tag === NfsAclTag.User; } diff --git a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html index ca2f5a081fe..82f337ac362 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html @@ -8,25 +8,23 @@ > @if (isUserTag) { - + > } @if (isGroupTag) { - + > } { { group: 'wheel' }, { group: 'vip' }, ]), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), ], }); diff --git a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts index eff161e93d7..58f7ccc64ff 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts +++ b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.ts @@ -9,15 +9,13 @@ import { import { mapToOptions } from 'app/helpers/options.helper'; import { helptextAcl } from 'app/helptext/storage/volumes/datasets/dataset-acl'; import { PosixAclItem } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxCheckboxListComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox-list/ix-checkbox-list.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -29,14 +27,14 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxListComponent, IxCheckboxComponent, TranslateModule, ], }) export class EditPosixAceComponent implements OnInit, OnChanges { - private userService = inject(UserService); private store = inject(DatasetAclEditorStore); private formBuilder = inject(FormBuilder); private translate = inject(TranslateService); @@ -59,9 +57,6 @@ export class EditPosixAceComponent implements OnInit, OnChanges { group: helptextAcl.groupTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - get isUserTag(): boolean { return this.form.value.tag === PosixAclTag.User; } diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html index d1ecfa9f8cf..986532f55fb 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.html @@ -17,11 +17,10 @@
- + > - + > { imports: [ CastPipe, ReactiveFormsModule, + IxUserComboboxComponent, + IxGroupComboboxComponent, ], declarations: [ MockComponent(EditPosixAceComponent), @@ -109,6 +113,8 @@ describe('DatasetAclEditorComponent', () => { mockProvider(UserService, { userQueryDsCache: () => of(), groupQueryDsCache: () => of(), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(MatDialog, { open: jest.fn(() => ({ diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts index 29b09d5a703..f0e28470e4b 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-acl-editor/dataset-acl-editor.component.ts @@ -12,10 +12,9 @@ import { AclType } from 'app/enums/acl-type.enum'; import { Role } from 'app/enums/role.enum'; import { helptextAcl } from 'app/helptext/storage/volumes/datasets/dataset-acl'; import { Acl } from 'app/interfaces/acl.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { IxIconComponent } from 'app/modules/ix-icon/ix-icon.component'; import { CastPipe } from 'app/modules/pipes/cast/cast.pipe'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -36,7 +35,6 @@ import { SelectPresetModalConfig, } from 'app/pages/datasets/modules/permissions/interfaces/select-preset-modal-config.interface'; import { DatasetAclEditorStore } from 'app/pages/datasets/modules/permissions/stores/dataset-acl-editor.store'; -import { UserService } from 'app/services/user.service'; import { AclEditorSaveControlsComponent } from './acl-editor-save-controls/acl-editor-save-controls.component'; @UntilDestroy() @@ -51,7 +49,8 @@ import { AclEditorSaveControlsComponent } from './acl-editor-save-controls/acl-e MatCardHeader, MatCardTitle, ReactiveFormsModule, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxComponent, AclEditorListComponent, MatButton, @@ -73,7 +72,6 @@ export class DatasetAclEditorComponent implements OnInit { private route = inject(ActivatedRoute); private cdr = inject(ChangeDetectorRef); private matDialog = inject(MatDialog); - private userService = inject(UserService); private formBuilder = inject(NonNullableFormBuilder); datasetPath: string; @@ -97,8 +95,6 @@ export class DatasetAclEditorComponent implements OnInit { return Boolean(this.route.snapshot.queryParams['homeShare']); } - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); readonly helptext = helptextAcl; protected readonly Role = Role; diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html index 5f6b28888ed..4ca80bb9042 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.html @@ -16,13 +16,12 @@
- + > - + > { { username: 'root' }, { username: 'games' }, ]), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(DialogService, { confirm: jest.fn(() => of(true)), diff --git a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts index c3e9b88e0d8..7aad4fae668 100644 --- a/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts +++ b/src/app/pages/datasets/modules/permissions/containers/dataset-trivial-permissions/dataset-trivial-permissions.component.ts @@ -18,12 +18,11 @@ import { Role } from 'app/enums/role.enum'; import { helptextPermissions } from 'app/helptext/storage/volumes/datasets/dataset-permissions'; import { FilesystemSetPermParams } from 'app/interfaces/filesystem-stat.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxPermissionsComponent } from 'app/modules/forms/ix-forms/components/ix-permissions/ix-permissions.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { FakeProgressBarComponent } from 'app/modules/loader/components/fake-progress-bar/fake-progress-bar.component'; @@ -32,7 +31,6 @@ import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; import { StorageService } from 'app/services/storage.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -48,7 +46,8 @@ import { UserService } from 'app/services/user.service'; MatCardContent, ReactiveFormsModule, IxFieldsetComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxCheckboxComponent, IxPermissionsComponent, RequiresRolesDirective, @@ -70,7 +69,6 @@ export class DatasetTrivialPermissionsComponent implements OnInit { private storageService = inject(StorageService); private translate = inject(TranslateService); private dialog = inject(DialogService); - private userService = inject(UserService); private validatorService = inject(IxValidatorsService); private snackbar = inject(SnackbarService); @@ -99,9 +97,6 @@ export class DatasetTrivialPermissionsComponent implements OnInit { datasetPath: string; datasetId: string; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); - readonly tooltips = { user: helptextPermissions.userTooltip, applyUser: helptextPermissions.applyUser.tooltip, diff --git a/src/app/pages/services/components/service-smb/service-smb.component.html b/src/app/pages/services/components/service-smb/service-smb.component.html index a91e11ea1ea..6b0de2a96a0 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.html +++ b/src/app/pages/services/components/service-smb/service-smb.component.html @@ -92,19 +92,17 @@ [tooltip]="tooltips.multichannel | translate" > - + > - + > { userQueryDsCache: jest.fn(() => of([{ username: 'test-username', }])), + getUserByName: (username: string) => of({ username } as User), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(SlideInRef, slideInRef), mockAuth(), diff --git a/src/app/pages/services/components/service-smb/service-smb.component.ts b/src/app/pages/services/components/service-smb/service-smb.component.ts index 870c3f66d88..4c7c3ed1af3 100644 --- a/src/app/pages/services/components/service-smb/service-smb.component.ts +++ b/src/app/pages/services/components/service-smb/service-smb.component.ts @@ -14,17 +14,16 @@ import { choicesToOptions } from 'app/helpers/operators/options.operators'; import { mapToOptions } from 'app/helpers/options.helper'; import { helptextServiceSmb } from 'app/helptext/services/components/service-smb'; import { SmbConfigUpdate } from 'app/interfaces/smb-config.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; @@ -33,7 +32,6 @@ import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service' import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; -import { UserService } from 'app/services/user.service'; interface BindIp { bindIp: string; @@ -55,7 +53,8 @@ interface BindIp { IxChipsComponent, IxCheckboxComponent, IxSelectComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxListComponent, IxListItemComponent, FormActionsComponent, @@ -71,7 +70,6 @@ export class ServiceSmbComponent implements OnInit { private errorHandler = inject(ErrorHandlerService); private fb = inject(FormBuilder); private translate = inject(TranslateService); - private userService = inject(UserService); private validatorsService = inject(IxValidatorsService); private snackbar = inject(SnackbarService); slideInRef = inject>(SlideInRef); @@ -131,8 +129,6 @@ export class ServiceSmbComponent implements OnInit { }; readonly unixCharsetOptions$ = this.api.call('smb.unixcharset_choices').pipe(choicesToOptions()); - readonly guestAccountProvider = new UserComboboxProvider(this.userService); - readonly adminGroupProvider = new GroupComboboxProvider(this.userService); readonly bindIpAddressOptions$ = combineLatest([ this.api.call('smb.bindip_choices').pipe(choicesToOptions()), diff --git a/src/app/pages/services/components/service-ssh/service-ssh.component.html b/src/app/pages/services/components/service-ssh/service-ssh.component.html index 87bd80b37e4..766522270c3 100644 --- a/src/app/pages/services/components/service-ssh/service-ssh.component.html +++ b/src/app/pages/services/components/service-ssh/service-ssh.component.html @@ -15,12 +15,11 @@ [tooltip]="tooltips.tcpport | translate" > - + > >(SlideInRef); protected readonly requiredRoles = [Role.SshWrite]; @@ -71,12 +66,6 @@ export class ServiceSshComponent implements OnInit, AfterViewInit { protected isFormLoading = signal(false); isBasicMode = true; - groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - form = this.fb.group({ tcpport: [null as number | null], password_login_groups: [[] as string[]], @@ -130,12 +119,6 @@ export class ServiceSshComponent implements OnInit, AfterViewInit { }); } - ngAfterViewInit(): void { - this.form.controls.password_login_groups.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); - } - onAdvancedSettingsToggled(): void { this.isBasicMode = !this.isBasicMode; } diff --git a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html index ab6ac3f61d2..86d4f0cedc8 100644 --- a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html +++ b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.html @@ -48,33 +48,29 @@ [label]="'Read Only' | translate" > - + > - + > - + > - + > { { group: 'sys' }, { group: 'operator' }, ]), + getUserByName: (username: string) => of({ username } as { username: string }), + getGroupByName: (groupName: string) => of({ group: groupName }), }), mockProvider(DialogService, { confirm: jest.fn(() => of(true)), diff --git a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts index 1026a14e667..4bbf39fcc9d 100644 --- a/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts +++ b/src/app/pages/sharing/nfs/nfs-form/nfs-form.component.ts @@ -20,19 +20,18 @@ import { helptextSharingNfs } from 'app/helptext/sharing'; import { DatasetCreate } from 'app/interfaces/dataset.interface'; import { NfsShare, NfsShareUpdate } from 'app/interfaces/nfs-share.interface'; import { Option } from 'app/interfaces/option.interface'; -import { GroupComboboxProvider } from 'app/modules/forms/ix-forms/classes/group-combobox-provider'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { ExplorerCreateDatasetComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/explorer-create-dataset/explorer-create-dataset.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxIpInputWithNetmaskComponent } from 'app/modules/forms/ix-forms/components/ix-ip-input-with-netmask/ix-ip-input-with-netmask.component'; import { IxListItemComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list-item/ix-list-item.component'; import { IxListComponent } from 'app/modules/forms/ix-forms/components/ix-list/ix-list.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; import { ipv4or6cidrValidator } from 'app/modules/forms/ix-forms/validators/ip-validation'; @@ -44,7 +43,6 @@ import { ApiService } from 'app/modules/websocket/api.service'; import { getRootDatasetsValidator } from 'app/pages/sharing/utils/root-datasets-validator'; import { DatasetService } from 'app/services/dataset/dataset.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { ServicesState } from 'app/store/services/services.reducer'; import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors'; @@ -65,7 +63,8 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' ExplorerCreateDatasetComponent, IxInputComponent, IxCheckboxComponent, - IxComboboxComponent, + IxUserComboboxComponent, + IxGroupComboboxComponent, IxSelectComponent, IxListComponent, IxListItemComponent, @@ -80,7 +79,6 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' export class NfsFormComponent implements OnInit { private api = inject(ApiService); private formBuilder = inject(FormBuilder); - private userService = inject(UserService); private translate = inject(TranslateService); private filesystemService = inject(FilesystemService); private formErrorHandler = inject(FormErrorHandlerService); @@ -131,8 +129,6 @@ export class NfsFormComponent implements OnInit { protected readonly requiredRoles = [Role.SharingNfsWrite, Role.SharingWrite]; readonly helptext = helptextSharingNfs; - readonly userProvider = new UserComboboxProvider(this.userService); - readonly groupProvider = new GroupComboboxProvider(this.userService); readonly treeNodeProvider = this.filesystemService.getFilesystemNodeProvider({ directoriesOnly: true }); readonly isEnterprise = toSignal(this.store$.select(selectIsEnterprise)); diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.html b/src/app/pages/sharing/smb/smb-form/smb-form.component.html index b72573aac03..5b6bc4613c6 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.html +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.html @@ -137,19 +137,17 @@ @if (form.value.audit.enable) { - + > - + > } diff --git a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts index 4833978ca9a..b0bbe31b57e 100644 --- a/src/app/pages/sharing/smb/smb-form/smb-form.component.ts +++ b/src/app/pages/sharing/smb/smb-form/smb-form.component.ts @@ -39,19 +39,18 @@ import { ExplorerNodeData } from 'app/interfaces/tree-node.interface'; import { DialogService } from 'app/modules/dialog/dialog.service'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { ChipsProvider } from 'app/modules/forms/ix-forms/components/ix-chips/chips-provider'; import { IxChipsComponent } from 'app/modules/forms/ix-forms/components/ix-chips/ix-chips.component'; import { IxErrorsComponent } from 'app/modules/forms/ix-forms/components/ix-errors/ix-errors.component'; import { ExplorerCreateDatasetComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/explorer-create-dataset/explorer-create-dataset.component'; import { IxExplorerComponent } from 'app/modules/forms/ix-forms/components/ix-explorer/ix-explorer.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; +import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { WarningComponent } from 'app/modules/forms/ix-forms/components/warning/warning.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { IxFormatterService } from 'app/modules/forms/ix-forms/services/ix-formatter.service'; import { IxValidatorsService } from 'app/modules/forms/ix-forms/services/ix-validators.service'; -import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { LoaderService } from 'app/modules/loader/loader.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; @@ -67,7 +66,6 @@ import { getRootDatasetsValidator } from 'app/pages/sharing/utils/root-datasets- import { DatasetService } from 'app/services/dataset/dataset.service'; import { ErrorHandlerService } from 'app/services/errors/error-handler.service'; import { FilesystemService } from 'app/services/filesystem.service'; -import { UserService } from 'app/services/user.service'; import { checkIfServiceIsEnabled } from 'app/store/services/services.actions'; import { ServicesState } from 'app/store/services/services.reducer'; import { selectService } from 'app/store/services/services.selectors'; @@ -90,6 +88,7 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' IxSelectComponent, IxCheckboxComponent, IxChipsComponent, + IxGroupChipsComponent, IxErrorsComponent, FormActionsComponent, RequiresRolesDirective, @@ -110,7 +109,6 @@ export class SmbFormComponent implements OnInit, AfterViewInit { private datasetService = inject(DatasetService); private translate = inject(TranslateService); private router = inject(Router); - private userService = inject(UserService); protected loader = inject(LoaderService); private errorHandler = inject(ErrorHandlerService); private formErrorHandler = inject(FormErrorHandlerService); @@ -119,7 +117,6 @@ export class SmbFormComponent implements OnInit, AfterViewInit { private validatorsService = inject(IxValidatorsService); private store$ = inject>(Store); private smbValidationService = inject(SmbValidationService); - private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject(null); - protected groupProvider: ChipsProvider = (query) => { - return this.userService.groupQueryDsCache(query).pipe( - map((groups) => groups.map((group) => group.group)), - ); - }; - title: string = helptextSharingSmb.formTitleAdd; createDatasetProps: Omit = { @@ -396,12 +387,6 @@ export class SmbFormComponent implements OnInit, AfterViewInit { this.form.controls.name.addAsyncValidators([ this.smbValidationService.validate(this.existingSmbShare?.name), ]); - this.form.controls.audit.controls.watch_list.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); - this.form.controls.audit.controls.ignore_list.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); } private setupAclControl(): void { diff --git a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html index c6553d24b28..1162c1ff23e 100644 --- a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html +++ b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.html @@ -16,13 +16,12 @@ [tooltip]="tooltips.command | translate" > - + > { { username: 'root' }, { username: 'steven' }, ] as User[]), + getUserByName: (username: string) => of({ username } as User), }), mockProvider(SlideInRef, componentRef), mockAuth(), diff --git a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts index d85f2c1a211..5582098817b 100644 --- a/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts +++ b/src/app/pages/system/advanced/cron/cron-form/cron-form.component.ts @@ -9,12 +9,11 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r import { Role } from 'app/enums/role.enum'; import { helptextCron } from 'app/helptext/system/cron-form'; import { Cronjob, CronjobUpdate } from 'app/interfaces/cronjob.interface'; -import { UserComboboxProvider } from 'app/modules/forms/ix-forms/classes/user-combobox-provider'; import { FormActionsComponent } from 'app/modules/forms/ix-forms/components/form-actions/form-actions.component'; import { IxCheckboxComponent } from 'app/modules/forms/ix-forms/components/ix-checkbox/ix-checkbox.component'; -import { IxComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component'; import { IxFieldsetComponent } from 'app/modules/forms/ix-forms/components/ix-fieldset/ix-fieldset.component'; import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; +import { IxUserComboboxComponent } from 'app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; import { SchedulerComponent } from 'app/modules/scheduler/components/scheduler/scheduler.component'; import { crontabToSchedule } from 'app/modules/scheduler/utils/crontab-to-schedule.utils'; @@ -25,7 +24,6 @@ import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; @UntilDestroy() @Component({ @@ -39,7 +37,7 @@ import { UserService } from 'app/services/user.service'; ReactiveFormsModule, IxFieldsetComponent, IxInputComponent, - IxComboboxComponent, + IxUserComboboxComponent, SchedulerComponent, IxCheckboxComponent, FormActionsComponent, @@ -55,7 +53,6 @@ export class CronFormComponent implements OnInit { private translate = inject(TranslateService); private errorHandler = inject(FormErrorHandlerService); private snackbar = inject(SnackbarService); - private userService = inject(UserService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.SystemCronWrite]; @@ -90,8 +87,6 @@ export class CronFormComponent implements OnInit { stderr: helptextCron.stderrTooltip, }; - readonly userProvider = new UserComboboxProvider(this.userService); - private editingCron: Cronjob | undefined; constructor() { From ffa79f8c2d12016e780a8954a718ec64474209a1 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Tue, 23 Dec 2025 23:18:37 +0200 Subject: [PATCH 08/11] NAS-138855: PR update --- .claude/settings.local.json | 7 ------- .../privilege/privilege-form/privilege-form.component.ts | 2 -- 2 files changed, 9 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 371addd9958..00000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(xargs ls:*)" - ] - } -} diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts index 6f1022f039e..86eacf4dfbb 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts @@ -31,7 +31,6 @@ import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-hea import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { TestDirective } from 'app/modules/test-id/test.directive'; import { ApiService } from 'app/modules/websocket/api.service'; -import { UserService } from 'app/services/user.service'; import { AppState } from 'app/store'; import { generalConfigUpdated } from 'app/store/system-config/system-config.actions'; import { waitForGeneralConfig } from 'app/store/system-config/system-config.selectors'; @@ -67,7 +66,6 @@ export class PrivilegeFormComponent implements OnInit, AfterViewInit { private errorHandler = inject(FormErrorHandlerService); private store$ = inject>(Store); private dialog = inject(DialogService); - private userService = inject(UserService); private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject>(SlideInRef); From e540949de17d337b9225b45a485bfde13c87ad5f Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Wed, 24 Dec 2025 11:01:18 +0200 Subject: [PATCH 09/11] NAS-138855: PR update --- .../ix-group-chips/ix-group-chips.component.ts | 8 ++++++-- .../ix-group-combobox.component.ts | 6 ++++-- .../ix-user-chips/ix-user-chips.component.ts | 8 ++++++-- .../ix-user-combobox/ix-user-combobox.component.ts | 6 ++++-- .../ix-user-picker/ix-user-picker.component.ts | 14 ++++---------- .../privilege-form/privilege-form.component.ts | 12 ++---------- 6 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts index 6ddad575f31..2f2b72e304c 100644 --- a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts @@ -61,13 +61,17 @@ export class IxGroupChipsComponent implements AfterViewInit, ControlValueAccesso } ngAfterViewInit(): void { - // Add async validator to check group existence + // Add async validator to check group existence. + // The base ix-chips component defaults to allowNewEntries=true, + // so validation is needed since users can type custom values. const control = this.controlDirective.control; if (control) { control.addAsyncValidators([ this.existenceValidator.validateGroupsExist(), ]); - control.updateValueAndValidity(); + // Don't call updateValueAndValidity() here to avoid showing validation errors + // immediately on form load. Validation will run automatically when the user + // interacts with the field or when the form is submitted. } } diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts index cfa4c5d1518..6bca25584cb 100644 --- a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts @@ -57,9 +57,11 @@ export class IxGroupComboboxComponent implements AfterViewInit, ControlValueAcce } ngAfterViewInit(): void { - // Add async validator to check group existence + // Only add validation if custom values are allowed. + // When allowCustomValue is false, users can only select from autocomplete + // suggestions which are guaranteed to exist, making validation redundant. const control = this.controlDirective.control; - if (control) { + if (control && this.allowCustomValue()) { control.addAsyncValidators([ this.existenceValidator.validateGroupExists(), ]); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts index 4ea718c9b36..e918e1697ca 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts @@ -61,13 +61,17 @@ export class IxUserChipsComponent implements AfterViewInit, ControlValueAccessor } ngAfterViewInit(): void { - // Add async validator to check user existence + // Add async validator to check user existence. + // The base ix-chips component defaults to allowNewEntries=true, + // so validation is needed since users can type custom values. const control = this.controlDirective.control; if (control) { control.addAsyncValidators([ this.existenceValidator.validateUsersExist(), ]); - control.updateValueAndValidity(); + // Don't call updateValueAndValidity() here to avoid showing validation errors + // immediately on form load. Validation will run automatically when the user + // interacts with the field or when the form is submitted. } } diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts index 0586a3d4a72..7ff730d11c7 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts @@ -57,9 +57,11 @@ export class IxUserComboboxComponent implements AfterViewInit, ControlValueAcces } ngAfterViewInit(): void { - // Add async validator to check user existence + // Only add validation if custom values are allowed. + // When allowCustomValue is false, users can only select from autocomplete + // suggestions which are guaranteed to exist, making validation redundant. const control = this.controlDirective.control; - if (control) { + if (control && this.allowCustomValue()) { control.addAsyncValidators([ this.existenceValidator.validateUserExists(), ]); diff --git a/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts index bc874919ab7..18dc2e5ff2a 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-picker/ix-user-picker.component.ts @@ -360,11 +360,8 @@ export class IxUserPickerComponent implements ControlValueAccessor, OnInit { catchError((error: unknown) => { // Handle slide-in errors gracefully this.errorHandler.handleError(error); - // Clear selection to allow "Add New" to be clicked again - this.selectedOption.set(null); - if (this.inputElementRef()?.nativeElement) { - this.inputElementRef().nativeElement.value = ''; - } + // Clear selection and reset form control value to allow "Add New" to be clicked again + this.resetInput(); this.autocompleteTrigger()?.closePanel(); return of(null); }), @@ -395,11 +392,8 @@ export class IxUserPickerComponent implements ControlValueAccessor, OnInit { this.cdr.markForCheck(); } else { - // User cancelled - clear selection to allow "Add New" to be clicked again - this.selectedOption.set(null); - if (this.inputElementRef()?.nativeElement) { - this.inputElementRef().nativeElement.value = ''; - } + // User cancelled - clear selection and reset form control value to allow "Add New" to be clicked again + this.resetInput(); } // Close panel immediately - the selection is already set diff --git a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts index 86eacf4dfbb..7cc3b1ed812 100644 --- a/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts +++ b/src/app/pages/credentials/groups/privilege/privilege-form/privilege-form.component.ts @@ -1,5 +1,5 @@ import { - AfterViewInit, ChangeDetectionStrategy, Component, OnInit, signal, inject, + ChangeDetectionStrategy, Component, OnInit, signal, inject, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ReactiveFormsModule, Validators } from '@angular/forms'; @@ -26,7 +26,6 @@ import { IxGroupChipsComponent } from 'app/modules/forms/ix-forms/components/ix- import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component'; import { IxSelectComponent } from 'app/modules/forms/ix-forms/components/ix-select/ix-select.component'; import { FormErrorHandlerService } from 'app/modules/forms/ix-forms/services/form-error-handler.service'; -import { UserGroupExistenceValidationService } from 'app/modules/forms/ix-forms/validators/user-group-existence-validation.service'; import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-header/modal-header.component'; import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref'; import { TestDirective } from 'app/modules/test-id/test.directive'; @@ -59,14 +58,13 @@ import { selectIsEnterprise } from 'app/store/system-info/system-info.selectors' TranslateModule, ], }) -export class PrivilegeFormComponent implements OnInit, AfterViewInit { +export class PrivilegeFormComponent implements OnInit { private formBuilder = inject(FormBuilder); private translate = inject(TranslateService); private api = inject(ApiService); private errorHandler = inject(FormErrorHandlerService); private store$ = inject>(Store); private dialog = inject(DialogService); - private existenceValidator = inject(UserGroupExistenceValidationService); slideInRef = inject>(SlideInRef); protected readonly requiredRoles = [Role.PrivilegeWrite]; @@ -173,12 +171,6 @@ export class PrivilegeFormComponent implements OnInit, AfterViewInit { } } - ngAfterViewInit(): void { - this.form.controls.local_groups.addAsyncValidators([ - this.existenceValidator.validateGroupsExist(), - ]); - } - private setPrivilegeForEdit(existingPrivilege: Privilege): void { this.form.patchValue({ ...existingPrivilege, From d49e26e64780746a9bf2e32f92d056af9fe1b61c Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Wed, 31 Dec 2025 14:23:59 +0200 Subject: [PATCH 10/11] NAS-138855: PR update --- .../components/ix-chips/ix-chips.component.ts | 26 ++++++++++++++++--- .../ix-combobox/ix-combobox.component.ts | 15 ++++++++++- .../ix-group-chips.component.ts | 4 +-- .../ix-group-combobox.component.ts | 4 +-- .../ix-user-chips/ix-user-chips.component.ts | 4 +-- .../ix-user-combobox.component.ts | 4 +-- .../edit-nfs-ace/edit-nfs-ace.component.html | 2 -- .../edit-posix-ace.component.html | 2 -- 8 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.ts index 366fe12dd98..49fbd7907bb 100644 --- a/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-chips/ix-chips.component.ts @@ -182,21 +182,41 @@ export class IxChipsComponent implements OnChanges, ControlValueAccessor { onInputBlur(): void { const trigger = this.autocompleteTrigger(); + const inputValue = this.chipInput().nativeElement.value; - // If autocomplete panel is open, an option selection is in progress - // Skip blur processing to avoid adding search text as a chip + // If autocomplete panel is open, wait for it to close before processing blur if (trigger?.panelOpen) { + // If there's a typed value, process it after the panel closes + if (inputValue.trim() && this.allowNewEntries() && !this.resolveValue()) { + trigger.panelClosingActions.pipe(take(1), untilDestroyed(this)).subscribe(() => { + // Re-check the input value after panel closes, in case user selected an option + const currentValue = this.chipInput().nativeElement.value; + if (currentValue.trim()) { + this.onAdd(currentValue); + } else { + // Call onTouch even if no value was added to trigger validation + this.onTouch(); + } + }); + } else { + // No value to process, but still need to trigger validation + trigger.panelClosingActions.pipe(take(1), untilDestroyed(this)).subscribe(() => { + this.onTouch(); + }); + } return; } if (!this.allowNewEntries() || this.resolveValue()) { this.chipInput().nativeElement.value = ''; + this.onTouch(); return; } - const inputValue = this.chipInput().nativeElement.value; if (inputValue.trim()) { this.onAdd(inputValue); + } else { + this.onTouch(); } } diff --git a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts index 6736b1759c8..d9841e9291e 100644 --- a/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-combobox/ix-combobox.component.ts @@ -131,6 +131,13 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { if (!this.allowCustomValue() && !this.selectedOption) { this.resetInput(); + return; + } + + // Commit custom value on blur if one is typed + if (this.allowCustomValue() && this.textContent && !this.selectedOption) { + this.value = this.textContent; + this.onChange(this.textContent); } } @@ -224,8 +231,14 @@ export class IxComboboxComponent implements ControlValueAccessor, OnInit { } onChanged(changedValue: string): void { + // Clear selected option when user starts typing something new if (this.selectedOption?.value || this.value) { - this.resetInput(); + this.selectedOption = null; + this.value = null; + // If custom values aren't allowed, immediately clear the form control + if (!this.allowCustomValue()) { + this.onChange(null); + } } this.textContent = changedValue; this.filterChanged$.next(changedValue); diff --git a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts index 2f2b72e304c..bb3d2acfc42 100644 --- a/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-group-chips/ix-group-chips.component.ts @@ -62,8 +62,8 @@ export class IxGroupChipsComponent implements AfterViewInit, ControlValueAccesso ngAfterViewInit(): void { // Add async validator to check group existence. - // The base ix-chips component defaults to allowNewEntries=true, - // so validation is needed since users can type custom values. + // The base ix-chips component allows new entries by default, + // so validation is always needed to ensure typed groups exist. const control = this.controlDirective.control; if (control) { control.addAsyncValidators([ diff --git a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts index 6bca25584cb..35be524979b 100644 --- a/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-group-combobox/ix-group-combobox.component.ts @@ -46,7 +46,7 @@ export class IxGroupComboboxComponent implements AfterViewInit, ControlValueAcce readonly hint = input(); readonly tooltip = input(); readonly required = input(false); - readonly allowCustomValue = input(false); + readonly allowCustomValue = input(true); private readonly ixCombobox = viewChild.required(IxComboboxComponent); @@ -57,7 +57,7 @@ export class IxGroupComboboxComponent implements AfterViewInit, ControlValueAcce } ngAfterViewInit(): void { - // Only add validation if custom values are allowed. + // Add validation to check group existence when custom values are allowed (default). // When allowCustomValue is false, users can only select from autocomplete // suggestions which are guaranteed to exist, making validation redundant. const control = this.controlDirective.control; diff --git a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts index e918e1697ca..b69a31e09c8 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-chips/ix-user-chips.component.ts @@ -62,8 +62,8 @@ export class IxUserChipsComponent implements AfterViewInit, ControlValueAccessor ngAfterViewInit(): void { // Add async validator to check user existence. - // The base ix-chips component defaults to allowNewEntries=true, - // so validation is needed since users can type custom values. + // The base ix-chips component allows new entries by default, + // so validation is always needed to ensure typed users exist. const control = this.controlDirective.control; if (control) { control.addAsyncValidators([ diff --git a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts index 7ff730d11c7..b12e045a5b3 100644 --- a/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-user-combobox/ix-user-combobox.component.ts @@ -46,7 +46,7 @@ export class IxUserComboboxComponent implements AfterViewInit, ControlValueAcces readonly hint = input(); readonly tooltip = input(); readonly required = input(false); - readonly allowCustomValue = input(false); + readonly allowCustomValue = input(true); private readonly ixCombobox = viewChild.required(IxComboboxComponent); @@ -57,7 +57,7 @@ export class IxUserComboboxComponent implements AfterViewInit, ControlValueAcces } ngAfterViewInit(): void { - // Only add validation if custom values are allowed. + // Add validation to check user existence when custom values are allowed (default). // When allowCustomValue is false, users can only select from autocomplete // suggestions which are guaranteed to exist, making validation redundant. const control = this.controlDirective.control; diff --git a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html index 7fd2ef55c09..0ffce35d2cd 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-nfs-ace/edit-nfs-ace.component.html @@ -16,7 +16,6 @@ formControlName="user" [label]="'User' | translate" [tooltip]="tooltips.user | translate" - [allowCustomValue]="true" [required]="true" > } @@ -26,7 +25,6 @@ formControlName="group" [label]="'Group' | translate" [tooltip]="tooltips.group | translate" - [allowCustomValue]="true" [required]="true" > } diff --git a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html index 82f337ac362..c6206899227 100644 --- a/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html +++ b/src/app/pages/datasets/modules/permissions/components/edit-posix-ace/edit-posix-ace.component.html @@ -12,7 +12,6 @@ formControlName="user" [label]="'User' | translate" [tooltip]="tooltips.user | translate" - [allowCustomValue]="true" [required]="true" > } @@ -22,7 +21,6 @@ formControlName="group" [label]="'Group' | translate" [tooltip]="tooltips.group | translate" - [allowCustomValue]="true" [required]="true" > } From 1b04ecd14c25fc9e23cd7aaa9472c87ea08ce219 Mon Sep 17 00:00:00 2001 From: Alex Karpov Date: Tue, 6 Jan 2026 17:59:35 +0200 Subject: [PATCH 11/11] NAS-138855: PR Update --- .../components/ix-errors/ix-errors.component.spec.ts | 2 ++ .../components/ix-errors/ix-errors.component.ts | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.spec.ts b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.spec.ts index 7bc4f351e22..c916475b956 100644 --- a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.spec.ts +++ b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.spec.ts @@ -24,6 +24,7 @@ describe('IxErrorsComponent', () => { it('announces validation errors', () => { control.setValidators([Validators.required]); + control.markAsTouched(); control.updateValueAndValidity(); spectator.detectComponentChanges(); @@ -36,6 +37,7 @@ describe('IxErrorsComponent', () => { manualValidateError: true, manualValidateErrorMsg: 'Custom error', }); + control.markAsTouched(); spectator.detectComponentChanges(); expect(spectator.inject(LiveAnnouncer).announce).toHaveBeenCalledWith('Errors in Name: Custom error'); diff --git a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts index 5f92eff6395..41be1d0d50f 100644 --- a/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts +++ b/src/app/modules/forms/ix-forms/components/ix-errors/ix-errors.component.ts @@ -120,12 +120,12 @@ export class IxErrorsComponent implements OnChanges, OnDestroy { this.messages = newErrors.filter((message) => !!message) as string[]; - if (this.control().errors) { - this.control().markAllAsTouched(); - } - this.cdr.markForCheck(); - this.announceErrors(); + + // Only announce errors if the control has been touched or is dirty + if (this.control().touched || this.control().dirty) { + this.announceErrors(); + } } /**