diff --git a/src/app/account/settings/acc-settings/acc-settings.component.spec.ts b/src/app/account/settings/acc-settings/acc-settings.component.spec.ts index 8cedd294a..b64ed9387 100644 --- a/src/app/account/settings/acc-settings/acc-settings.component.spec.ts +++ b/src/app/account/settings/acc-settings/acc-settings.component.spec.ts @@ -121,23 +121,14 @@ describe('AccountSettingsComponent', () => { }); describe('Main form tests', () => { - it('initializes the form with default values', async () => { - component.ngOnInit(); - fixture.detectChanges(); - - // Wait for async observable subscription and form patching to complete - await fixture.whenStable(); - - // Trigger change detection after async updates - fixture.detectChanges(); - + it('initializes the form with default values', () => { const formValue = component.form.getRawValue(); expect(formValue.name).toBe(userResponse.attributes.name); expect(formValue.email).toBe(userResponse.attributes.email); expect(formValue.registeredSince).toBe('08/04/2025 6:25:56'); }); - it('validates email as required', async () => { + it('validates email as required', () => { const emailControl = component.form.get('email'); emailControl?.patchValue(null); component.form.updateValueAndValidity(); diff --git a/src/app/core/_components/forms/simple-forms/form.component.ts b/src/app/core/_components/forms/simple-forms/form.component.ts index cfd19b12b..f6f024877 100644 --- a/src/app/core/_components/forms/simple-forms/form.component.ts +++ b/src/app/core/_components/forms/simple-forms/form.component.ts @@ -1,19 +1,20 @@ import { Subscription } from 'rxjs'; -import { GlobalService } from 'src/app/core/_services/main.service'; -import { MetadataService } from 'src/app/core/_services/metadata.service'; -import { AlertService } from 'src/app/core/_services/shared/alert.service'; -import { AutoTitleService } from 'src/app/core/_services/shared/autotitle.service'; -import { UnsubscribeService } from 'src/app/core/_services/unsubscribe.service'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Params, Router } from '@angular/router'; +import { BaseModel } from '@models/base.model'; import { ResponseWrapper } from '@models/response.model'; import { JsonAPISerializer } from '@services/api/serializer-service'; import { ConfirmDialogService } from '@services/confirm/confirm-dialog.service'; import { ServiceConfig } from '@services/main.config'; +import { GlobalService } from '@services/main.service'; +import { MetadataService } from '@services/metadata.service'; +import { AlertService } from '@services/shared/alert.service'; +import { AutoTitleService } from '@services/shared/autotitle.service'; +import { UnsubscribeService } from '@services/unsubscribe.service'; @Component({ selector: 'app-form', @@ -25,7 +26,8 @@ import { ServiceConfig } from '@services/main.config'; */ export class FormComponent implements OnInit, OnDestroy { // Metadata Text, titles, subtitles, forms, and API path - globalMetadata: any[] = []; + globalMetadata: ReturnType[0]; + serviceConfig: ServiceConfig; /** @@ -78,16 +80,14 @@ export class FormComponent implements OnInit, OnDestroy { /** * An array of form field metadata that describes the form structure. * Each item in the array represents a form field, including its type, label, and other properties. - * @type {any[]} */ - formMetadata: any[] = []; + formMetadata: ReturnType = []; /** * Initial values for form fields (optional). * If provided, these values are used to initialize form controls in the dynamic form. - * @type {any[]} */ - formValues: any[] = []; + formValues: (BaseModel & Record)[] = []; // Subscription for managing asynchronous data retrieval private subscriptionService: Subscription; @@ -128,7 +128,7 @@ export class FormComponent implements OnInit, OnDestroy { this.formMetadata = this.metadataService.getFormMetadata(formKind); this.title = this.globalMetadata['title']; this.customform = this.globalMetadata['customform']; - titleService.set([this.title]); + this.titleService.set([this.title]); // Load metadata and form information if (this.type === 'edit') { this.getIndex(); @@ -199,7 +199,7 @@ export class FormComponent implements OnInit, OnDestroy { * Handles the submission of the form. * @param formValues - The values submitted from the form. */ - onFormSubmit(formValues: any) { + onFormSubmit(formValues: (BaseModel & Record)[]) { if (this.customform) { this.modifyFormValues(formValues); } @@ -226,7 +226,7 @@ export class FormComponent implements OnInit, OnDestroy { * @param formValues - The form values to be modified. * @returns The modified form values. */ - modifyFormValues(formValues: any) { + modifyFormValues(formValues: (BaseModel | Record)[]) { // Check the formMetadata for fields with 'replacevalue' property this.getIndex(); for (const field of this.formMetadata) { diff --git a/src/app/core/_components/forms/simple-forms/formconfig.component.ts b/src/app/core/_components/forms/simple-forms/formconfig.component.ts index 94880ff54..b8a1e9a09 100644 --- a/src/app/core/_components/forms/simple-forms/formconfig.component.ts +++ b/src/app/core/_components/forms/simple-forms/formconfig.component.ts @@ -4,18 +4,22 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { JConfig } from '@models/configs.model'; import { HorizontalNav } from '@models/horizontalnav.model'; import { ResponseWrapper } from '@models/response.model'; import { JsonAPISerializer } from '@services/api/serializer-service'; import { SERV, ServiceConfig } from '@services/main.config'; import { GlobalService } from '@services/main.service'; -import { MetadataService } from '@services/metadata.service'; +import { InfoMetadataForm, MetadataFormField, MetadataService } from '@services/metadata.service'; import { AlertService } from '@services/shared/alert.service'; import { AutoTitleService } from '@services/shared/autotitle.service'; import { UIConfigService } from '@services/shared/storage.service'; import { UnsubscribeService } from '@services/unsubscribe.service'; +type ConfigValues = Record; +type ConfigIds = Record; + @Component({ selector: 'app-form', templateUrl: 'formconfig.component.html', @@ -26,7 +30,7 @@ import { UnsubscribeService } from '@services/unsubscribe.service'; */ export class FormConfigComponent implements OnInit, OnDestroy { // Metadata Text, titles, subtitles, forms, and API path - globalMetadata: any[] = []; + globalMetadata: InfoMetadataForm; serviceConfig: ServiceConfig; /** @@ -51,23 +55,21 @@ export class FormConfigComponent implements OnInit, OnDestroy { /** * An array of form field metadata that describes the form structure. * Each item in the array represents a form field, including its type, label, and other properties. - * @type {any[]} */ - formMetadata: any[] = []; + formMetadata: MetadataFormField[] = []; /** * Initial values for form fields (optional). * If provided, these values are used to initialize form controls in the dynamic form. - * @type {any[]} + */ - formValues: any[] = []; + formValues: object; /** * An array of objects containing IDs and corresponding item names. * This information is used to map form field names to their associated IDs. - * @type {any[]} */ - formIds: any[] = []; + formIds: ConfigIds = {}; // Subscription for managing asynchronous data retrieval private mySubscription: Subscription; @@ -135,19 +137,22 @@ export class FormConfigComponent implements OnInit, OnDestroy { .getAll(this.serviceConfig, { page: { size: 500 } }) .subscribe((response: ResponseWrapper) => { const responseBody = { data: response.data, included: response.included }; - const configValues = this.serializer.deserialize(responseBody) as any[]; + const config = this.serializer.deserialize(responseBody); + + this.formValues = config.reduce((configValues, item) => { + let value: string | boolean = item.value; - this.formValues = configValues.reduce((configValues, item) => { if (item.value === '1') { - item.value = true; + value = true; } else if (item.value === '0') { - item.value = false; + value = false; } - configValues[item.item] = item.value; + + configValues[item.item] = value; return configValues; }, {}); // Maps the item with the id, so can be used for update - this.formIds = configValues.reduce((result, item) => { + this.formIds = config.reduce((result, item) => { result[item.item] = item.id; return result; }, {}); @@ -168,10 +173,10 @@ export class FormConfigComponent implements OnInit, OnDestroy { * Handles the submission of the form. * @param form - The form object containing the updated values. */ - onFormSubmit(form: any) { + onFormSubmit(form: FormGroup) { const currentFormValues = form; const initialFormValues = this.formValues; - const changedFields: Record = {}; + const changedFields: Record = {}; for (const key in currentFormValues) { if (Object.prototype.hasOwnProperty.call(currentFormValues, key)) { diff --git a/src/app/core/_constants/settings.config.ts b/src/app/core/_constants/settings.config.ts index d35bee83e..3adab70f2 100644 --- a/src/app/core/_constants/settings.config.ts +++ b/src/app/core/_constants/settings.config.ts @@ -6,6 +6,14 @@ export interface Setting { description: string; } +/** + * Options for select inputs, used in various settings + */ +export interface Option { + label: string; + value: number | string | boolean; +} + export const dateFormats: Setting[] = [ { value: 'd/M/yy', description: 'd/M/yy (ie. 6/7/23 )' }, { diff --git a/src/app/core/_services/metadata.service.ts b/src/app/core/_services/metadata.service.ts index d095d1f7f..41a8b2361 100644 --- a/src/app/core/_services/metadata.service.ts +++ b/src/app/core/_services/metadata.service.ts @@ -1,72 +1,85 @@ +import { Observable, of } from 'rxjs'; + import { Injectable } from '@angular/core'; -import { FormControl, Validators } from '@angular/forms'; +import { FormControl, ValidatorFn, Validators } from '@angular/forms'; import { SERV } from '@services/main.config'; -import { GlobalService } from '@services/main.service'; -import { TooltipService } from '@services/shared/tooltip.service'; +import { ConfigTooltipsLevel, TooltipService } from '@services/shared/tooltip.service'; import { fileFormat } from '@src/app/core/_constants/files.config'; -import { ACTIONARRAY, NOTIFARRAY } from '@src/app/core/_constants/notifications.config'; -import { ACCESS_GROUP_FIELD_MAPPING } from '@src/app/core/_constants/select.config'; -import { dateFormats, proxytype, serverlog } from '@src/app/core/_constants/settings.config'; -import { environment } from '@src/environments/environment'; +import { ACCESS_GROUP_FIELD_MAPPING, FieldMapping } from '@src/app/core/_constants/select.config'; +import { Option, Setting, dateFormats, proxytype, serverlog } from '@src/app/core/_constants/settings.config'; +import { urlValidator } from '@src/app/core/_validators/url.validator'; + +/** + * Metadata information for the form page. + * + * Properties: + * - title: Title for the form page + * - customform: Whether the form is custom or standard + * - subtitle: Whether the form has a subtitle + * - submitok: Message displayed upon successful submission + * - submitokredirect: Redirect URL upon successful submission + * - deltitle: Title for deletion confirmation dialog + * - delsubmitok: Message displayed upon successful deletion + * - delsubmitokredirect: Redirect URL upon successful deletion + * - delsubmitcancel: Message displayed when deletion is canceled + */ +export interface InfoMetadataForm { + title: string; + customform?: boolean; + subtitle?: boolean; + submitok?: string; + submitokredirect?: string; + deltitle?: string; + delsubmitok?: string; + delsubmitokredirect?: string; + delsubmitcancel?: string; +} + +/** + * Metadata for each field in the form. + * + * Properties: + * - name: API name to be mapped with the formControl + * - label: Label name to be displayed + * - type: Type of the form field; e.g., select, text, checkbox + * - placeholder: Placeholder text for the input + * - selectOptions: Select options if the type is 'select' + * - selectOptions$: Select options observable if type is 'select' and used with selectEndpoint$ + * - selectEndpoint$: API endpoint route, usually a constant like SERV + * - fieldMapping: Object with the dropdown options mapping, e.g., { id: '_id', name: 'groupName' } + * - requiredasterisk: Indicates if the field is required (shows asterisk) + * - tooltip: Tooltip information as string or more complex type + * - validators: Validation rules + * - isTitle: If true, will use only the label field as a title + */ +export interface MetadataFormField { + name?: string; + label?: string; + type?: string; + placeholder?: string; + selectOptions?: (Setting | Option)[]; + selectOptions$?: Observable<{ label: string; value: number }[]>; + selectEndpoint$?: SERV; + fieldMapping?: Record | FieldMapping; + requiredasterisk?: boolean; + tooltip?: string | boolean; + validators?: ValidatorFn[] | boolean; + isTitle?: boolean; + replacevalue?: string; +} @Injectable({ providedIn: 'root' }) export class MetadataService { - tooltip: any; + private tooltip: ConfigTooltipsLevel; - constructor( - private tooltipService: TooltipService, - private gs: GlobalService - ) { + constructor(private tooltipService: TooltipService) { this.tooltip = this.tooltipService.getConfigTooltips(); } - private maxResults = environment.config.prodApiMaxResults; - - // ToDo in validators, go to the database and add max lenght - //checboxes issues when value is false, - - // // - // Metadata Structure - // // - - // Info Metadata, it contains information about the page such as title, subtitles, and notifications configuration. - infoMetadataForm = { - title: 'Title for the form page', - customform: false, - subtitle: false, - submitok: 'Message displayed upon successful submission', - submitokredirect: 'Redirect URL upon successful submission', - deltitle: 'Title for deletion confirmation', - delsubmitok: 'Message displayed upon successful deletion', - delsubmitokredirect: 'Redirect URL upon successful deletion', - delsubmitcancel: 'Message displayed when deletion is canceled' - }; - - // Metadata form, it contains information about each field. - metadataFormField = [ - { - name: 'API name to be map with the formControl', - label: 'Label name to be displayed', - type: 'Type of the form field; (e.g., select, text, checkbox)', - placeholder: 'Type option text, then add placeholder', - selectOptions: "Select options if the type is 'select'", - selectOptions$: "Select options if the type is 'selectd', used with selectEndpoint", - selectEndpoint$: 'API endpoint route, use SERV', - fieldMapping: 'Object with the dropdown options to be mapped, that is id and name. ie. id: _id, name:groupName', - requiredasterisk: 'Indicates if the field is required', - tooltip: 'Tooltip information as string or using ', - validators: 'Validation rules', - isTitle: 'boolean, if its true will use only the label field' - } - ]; - - // Examples - // Create title between fields. use { label: 'More settings', isTitle: true } - // // // // // // // // // AUTH SECTION // // // // // // // // // @@ -206,7 +219,7 @@ export class MetadataService { type: 'selectd', requiredasterisk: true, selectEndpoint$: SERV.ACCESS_GROUPS, - selectOptions$: [], + selectOptions$: of([]), fieldMapping: ACCESS_GROUP_FIELD_MAPPING }, { name: 'isSecret', label: 'Secret', type: 'checkbox' } @@ -388,10 +401,10 @@ export class MetadataService { { name: 'downloadUrl', label: 'Download URL', - type: 'text', + type: 'url', requiredasterisk: true, - tooltip: 'Link where the client can download a 7zip with the binary', - validators: [Validators.required] + tooltip: 'Link where the client can download a 7zip with the binary, e.g. https://example.com/cracker-1.0.0.7z', + validators: [Validators.required, urlValidator()] }, { name: 'crackerBinaryTypeId', @@ -425,10 +438,10 @@ export class MetadataService { { name: 'downloadUrl', label: 'Download URL', - type: 'text', + type: 'url', requiredasterisk: true, - tooltip: 'Link where the client can download a 7zip with the binary', - validators: [Validators.required] + tooltip: 'Link where the client can download a 7zip with the binary, e.g. https://example.com/cracker-1.0.0.7z', + validators: [Validators.required, urlValidator()] } ]; @@ -483,10 +496,11 @@ export class MetadataService { { name: 'url', label: 'Download URL', - type: 'text', + type: 'url', requiredasterisk: true, - tooltip: false, - validators: [Validators.required] + tooltip: + 'Link where the client can download a 7zip with the preprocessor, e.g. https://example.com/preprocessor-1.0.0.7z', + validators: [Validators.required, urlValidator()] }, { label: 'Commands (set to empty if not available)', isTitle: true }, { @@ -1043,7 +1057,7 @@ export class MetadataService { type: 'selectd', requiredasterisk: true, selectEndpoint$: SERV.ACCESS_PERMISSIONS_GROUPS, - selectOptions$: [], + selectOptions$: of([]), fieldMapping: { id: 'crackerBinaryTypeId', name: 'typeName' }, validators: [Validators.required] }, @@ -1053,7 +1067,7 @@ export class MetadataService { type: 'selectd', requiredasterisk: true, selectEndpoint$: SERV.ACCESS_PERMISSIONS_GROUPS, - selectOptions$: [], + selectOptions$: of([]), fieldMapping: { id: 'crackerBinaryId', name: 'version' }, validators: [Validators.required] } @@ -1078,7 +1092,6 @@ export class MetadataService { } ]; - //This variable holds information about the fields required when creating a new user. newuser = [ { @@ -1101,7 +1114,7 @@ export class MetadataService { type: 'selectd', requiredasterisk: true, selectEndpoint$: SERV.ACCESS_PERMISSIONS_GROUPS, - selectOptions$: [], + selectOptions$: of([]), fieldMapping: { id: 'id', name: 'name' }, validators: [Validators.required] } @@ -1210,7 +1223,7 @@ export class MetadataService { * @param formName - The name of the form for which metadata is requested. * @returns An array of form metadata.editnotifInfo */ - getFormMetadata(formName: string): any[] { + getFormMetadata(formName: string): MetadataFormField[] { if (formName === 'authforgot') { return this.authforgot; } else if (formName === 'editwordlist' || formName === 'editrule' || formName === 'editother') { @@ -1257,7 +1270,7 @@ export class MetadataService { * @param formName - The name of the info metadata for which information is requested. * @returns An array of info metadata. */ - getInfoMetadata(formName: string): any[] { + getInfoMetadata(formName: string): InfoMetadataForm[] { if (formName === 'authforgotInfo') { return this.authforgotInfo; } else if (formName === 'editwordlistInfo') { diff --git a/src/app/core/_services/shared/tooltip.service.ts b/src/app/core/_services/shared/tooltip.service.ts index b606fa3ae..7cae823ff 100644 --- a/src/app/core/_services/shared/tooltip.service.ts +++ b/src/app/core/_services/shared/tooltip.service.ts @@ -1,28 +1,31 @@ -import { CookieService } from '../../_services/shared/cookies.service'; -import { environment } from './../../../../environments/environment'; -import { Injectable } from "@angular/core"; +import { Injectable } from '@angular/core'; + +import { CookieService } from '@services/shared/cookies.service'; + +import { DEFAULT_CONFIG_TOOLTIP } from '@src/config/default/app/tooltip'; +import { environment } from '@src/environments/environment'; + +// This gets the union of all task tooltip levels: tasks['0'] | tasks['1'] | tasks['2'] +export type TaskTooltipsLevel = (typeof DEFAULT_CONFIG_TOOLTIP.tasks)[keyof typeof DEFAULT_CONFIG_TOOLTIP.tasks]; + +// Same for config, union of all config levels +export type ConfigTooltipsLevel = (typeof DEFAULT_CONFIG_TOOLTIP.config)[keyof typeof DEFAULT_CONFIG_TOOLTIP.config]; @Injectable({ providedIn: 'root' }) export class TooltipService { + constructor(private cookieService: CookieService) {} - constructor( - private cookieService: CookieService, - ) {} - - public getTaskTooltips(){ - return environment.tooltip.tasks[this.getTooltipLevel()] + public getTaskTooltips(): TaskTooltipsLevel { + return environment.tooltip.tasks[this.getTooltipLevel()]; } - public getConfigTooltips(){ - return environment.tooltip.config[this.getTooltipLevel()] + public getConfigTooltips(): ConfigTooltipsLevel { + return environment.tooltip.config[this.getTooltipLevel()]; } - public getTooltipLevel(){ + public getTooltipLevel() { return this.cookieService.getCookie('tooltip'); } - } - - diff --git a/src/app/core/_validators/url.validator.ts b/src/app/core/_validators/url.validator.ts new file mode 100644 index 000000000..def19b5be --- /dev/null +++ b/src/app/core/_validators/url.validator.ts @@ -0,0 +1,22 @@ +// url.validator.ts +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +/** + * Creates an Angular ValidatorFn that validates whether a control's value is a valid URL. + * This validator attempts to parse the value using the browser's native URL API. + * @returns A ValidatorFn function to use in Angular Reactive Forms. + */ +export function urlValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const rawValue = control.value?.trim(); + if (!rawValue) { + // Don't validate empty values; leave that to Validators.required + return null; + } + if (URL.canParse(rawValue)) { + const { hostname } = new URL(rawValue); + return hostname ? null : { invalidUrl: { value: rawValue } }; + } + return { invalidUrl: { value: rawValue } }; + }; +} diff --git a/src/app/shared/dynamic-form-builder/dynamicform.component.html b/src/app/shared/dynamic-form-builder/dynamicform.component.html index 18e6f6499..f53453b4a 100644 --- a/src/app/shared/dynamic-form-builder/dynamicform.component.html +++ b/src/app/shared/dynamic-form-builder/dynamicform.component.html @@ -59,6 +59,13 @@

{{ field.label }}

[formControlName]="field.name" /> + + + {{ field.label }} + + {{ field.label }} is required + + + Invalid URL format + +
diff --git a/src/app/shared/input/text/text.component.html b/src/app/shared/input/text/text.component.html index 3345405a4..57324bee1 100644 --- a/src/app/shared/input/text/text.component.html +++ b/src/app/shared/input/text/text.component.html @@ -51,6 +51,7 @@ {{ title }} is required Please enter a valid {{ title?.toLowerCase() }} + Please enter a valid {{ title?.toLowerCase() }} Minimum length is {{ inputField.errors.minlength.requiredLength }} diff --git a/src/app/shared/input/text/text.component.ts b/src/app/shared/input/text/text.component.ts index 18b71c8cc..04749391d 100644 --- a/src/app/shared/input/text/text.component.ts +++ b/src/app/shared/input/text/text.component.ts @@ -31,7 +31,7 @@ import { AbstractInputComponent } from '@src/app/shared/input/abstract-input'; }) export class InputTextComponent extends AbstractInputComponent { @Input() pattern: string | RegExp; - @Input() inputType: 'text' | 'password' | 'email' = 'text'; + @Input() inputType: 'text' | 'password' | 'email' | 'url' = 'text'; @Input() icon: string; @Input() width: string = ''; @Input() minLength?: number;