diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 673a47728..5640fd5de 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -477,6 +477,8 @@ message.no-filters-added=No filters have been added to this Metadata Provider message.create-new-version-from-version=Create New Version from Previous Settings message.restoring-this-version-will-copy=Restoring this version will copy the Version ({ date }) configuration and create a new Version from the selected version settings. You can then edit the configuration before saving the new version. +message.invalid-regex-pattern=Invalid Regular Expression + tooltip.entity-id=Entity ID tooltip.service-provider-name=Service Provider Name (Dashboard Display Only) tooltip.force-authn=Disallows use (or reuse) of authentication results and login flows that don\u0027t provide a real-time proof of user presence in the login process diff --git a/ui/src/app/metadata/configuration/component/property/array-property.component.html b/ui/src/app/metadata/configuration/component/property/array-property.component.html index 9a5d1648e..380e0105b 100644 --- a/ui/src/app/metadata/configuration/component/property/array-property.component.html +++ b/ui/src/app/metadata/configuration/component/property/array-property.component.html @@ -20,6 +20,20 @@ + +
+ {{ version[i][prop] }} +
+
+ - +
+
diff --git a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts index 22eae4cda..bb544cf0d 100644 --- a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts +++ b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.spec.ts @@ -14,8 +14,8 @@ describe('EntityAttributesFilter Entity', () => { expect(entity.resourceId).toBe('foo'); expect(entity.enabled).toBe(entity.filterEnabled); expect(entity.id).toBe(entity.resourceId); - expect(entity.getId()).toBe(entity.entityId); - expect(entity.getDisplayId()).toBe(entity.entityId); + expect(entity.getId()).toBe(entity.resourceId); + expect(entity.getDisplayId()).toBe(entity.resourceId); expect(entity.isDraft()).toBe(false); }); }); diff --git a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.ts b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.ts index 839324879..f536a5b38 100644 --- a/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.ts +++ b/ui/src/app/metadata/domain/entity/filter/entity-attributes-filter.ts @@ -31,11 +31,11 @@ export class EntityAttributesFilterEntity implements MetadataFilter, MetadataEnt } getId(): string { - return this.entityId; + return this.resourceId; } getDisplayId(): string { - return this.entityId; + return this.resourceId; } isDraft(): boolean { diff --git a/ui/src/app/metadata/domain/entity/filter/nameid-format-filter.spec.ts b/ui/src/app/metadata/domain/entity/filter/nameid-format-filter.spec.ts new file mode 100644 index 000000000..ba39ffbe2 --- /dev/null +++ b/ui/src/app/metadata/domain/entity/filter/nameid-format-filter.spec.ts @@ -0,0 +1,21 @@ +import { NameIDFormatFilterEntity } from './nameid-format-filter'; + +describe('NameIDFormatFilterEntity Entity', () => { + let entity: NameIDFormatFilterEntity; + beforeEach(() => { + entity = new NameIDFormatFilterEntity({ + resourceId: 'foo', + filterEnabled: false + }); + }); + + it('should be an instance', () => { + expect(entity).toBeDefined(); + expect(entity.resourceId).toBe('foo'); + expect(entity.enabled).toBe(entity.filterEnabled); + expect(entity.id).toBe(entity.resourceId); + expect(entity.getId()).toBe(entity.resourceId); + expect(entity.getDisplayId()).toBe(entity.resourceId); + expect(entity.isDraft()).toBe(false); + }); +}); diff --git a/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts b/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts index 53e3630c8..a5505284d 100644 --- a/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts +++ b/ui/src/app/metadata/filter/model/entity-attributes.filter.spec.ts @@ -5,7 +5,8 @@ describe('Entity Attributes filter form', () => { it('should return an empty object for validators', () => { expect(Object.keys(EntityAttributesFilter.getValidators())).toEqual([ '/', - '/name' + '/name', + '/entityAttributesFilterTarget' ]); }); diff --git a/ui/src/app/metadata/filter/model/entity-attributes.filter.ts b/ui/src/app/metadata/filter/model/entity-attributes.filter.ts index a2642ddc9..f1d58d915 100644 --- a/ui/src/app/metadata/filter/model/entity-attributes.filter.ts +++ b/ui/src/app/metadata/filter/model/entity-attributes.filter.ts @@ -2,7 +2,11 @@ import { FormDefinition } from '../../../wizard/model'; import { MetadataFilter } from '../../domain/model'; import { removeNulls } from '../../../shared/util'; import { EntityAttributesFilterEntity } from '../../domain/entity'; +import { RegexValidator } from '../../../shared/validation/regex.validator'; import { getFilterNames } from '../reducer'; +import { memoize } from '../../../shared/memo'; + +const checkRegex = memoize(RegexValidator.isValidRegex); export const EntityAttributesFilter: FormDefinition = { label: 'EntityAttributes', @@ -37,6 +41,18 @@ export const EntityAttributesFilter: FormDefinition = { params: [value] } : null; return err; + }, + '/entityAttributesFilterTarget': (value, property, form) => { + if (!form || !form.value || !form.value.entityAttributesFilterTarget || + form.value.entityAttributesFilterTarget.entityAttributesFilterTargetType !== 'REGEX') { + return null; + } + return checkRegex(value.value[0]) ? null : { + code: 'INVALID_REGEX', + path: `#${property.path}`, + message: 'message.invalid-regex-pattern', + params: [value.value[0]] + }; } }; return validators; diff --git a/ui/src/app/metadata/filter/model/nameid.filter.spec.ts b/ui/src/app/metadata/filter/model/nameid.filter.spec.ts index f366ca90a..e3d3aca36 100644 --- a/ui/src/app/metadata/filter/model/nameid.filter.spec.ts +++ b/ui/src/app/metadata/filter/model/nameid.filter.spec.ts @@ -5,7 +5,8 @@ describe('NameID Format filter form', () => { it('should return an empty object for validators', () => { expect(Object.keys(NameIDFilter.getValidators())).toEqual([ '/', - '/name' + '/name', + '/nameIdFormatFilterTarget' ]); }); diff --git a/ui/src/app/metadata/filter/model/nameid.filter.ts b/ui/src/app/metadata/filter/model/nameid.filter.ts index 9751d4ce9..ee48f4f89 100644 --- a/ui/src/app/metadata/filter/model/nameid.filter.ts +++ b/ui/src/app/metadata/filter/model/nameid.filter.ts @@ -1,7 +1,11 @@ import { FormDefinition } from '../../../wizard/model'; import { MetadataFilter } from '../../domain/model'; import { NameIDFormatFilterEntity } from '../../domain/entity/filter/nameid-format-filter'; +import { RegexValidator } from '../../../shared/validation/regex.validator'; import { getFilterNames } from '../reducer'; +import { memoize } from '../../../shared/memo'; + +const checkRegex = memoize(RegexValidator.isValidRegex); export const NameIDFilter: FormDefinition = { label: 'NameIDFormat', @@ -36,6 +40,18 @@ export const NameIDFilter: FormDefinition = { params: [value] } : null; return err; + }, + '/nameIdFormatFilterTarget': (value, property, form) => { + if (!form || !form.value || !form.value.nameIdFormatFilterTarget || + form.value.nameIdFormatFilterTarget.nameIdFormatFilterTargetType !== 'REGEX') { + return null; + } + return checkRegex(value.value[0]) ? null : { + code: 'INVALID_REGEX', + path: `#${property.path}`, + message: 'message.invalid-regex-pattern', + params: [value.value[0]] + }; } }; return validators; diff --git a/ui/src/app/schema-form/widget/array/array.component.ts b/ui/src/app/schema-form/widget/array/array.component.ts index 18f93566e..82a089635 100644 --- a/ui/src/app/schema-form/widget/array/array.component.ts +++ b/ui/src/app/schema-form/widget/array/array.component.ts @@ -9,9 +9,9 @@ export interface FormError { code: string; description: string; message: string; - params: any[]; + params?: any[]; path: string; - schemaId: any; + schemaId?: any; } @Component({ diff --git a/ui/src/app/schema-form/widget/filter-target/filter-target.component.html b/ui/src/app/schema-form/widget/filter-target/filter-target.component.html index ef3a7be4a..fcbd80ab8 100644 --- a/ui/src/app/schema-form/widget/filter-target/filter-target.component.html +++ b/ui/src/app/schema-form/widget/filter-target/filter-target.component.html @@ -69,11 +69,21 @@ - - + + Required for Regex   + + + , + {{ error.message }} + +
diff --git a/ui/src/app/schema-form/widget/filter-target/filter-target.component.ts b/ui/src/app/schema-form/widget/filter-target/filter-target.component.ts index 4107ebfd3..7af0c8b0d 100644 --- a/ui/src/app/schema-form/widget/filter-target/filter-target.component.ts +++ b/ui/src/app/schema-form/widget/filter-target/filter-target.component.ts @@ -2,8 +2,8 @@ import { Component, OnDestroy, AfterViewInit } from '@angular/core'; import { FormControl, Validators, AbstractControl, ValidatorFn } from '@angular/forms'; import { ObjectWidget } from 'ngx-schema-form'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { distinctUntilChanged, skipWhile, map } from 'rxjs/operators'; +import { Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, skipWhile, takeUntil, map } from 'rxjs/operators'; import * as fromRoot from '../../../app.reducer'; import * as fromFilters from '../../../metadata/filter/reducer'; @@ -17,7 +17,7 @@ import { QueryEntityIds, ClearSearch } from '../../../metadata/filter/action/sea styleUrls: ['./filter-target.component.scss'] }) export class FilterTargetComponent extends ObjectWidget implements OnDestroy, AfterViewInit { - + private ngUnsubscribe: Subject = new Subject(); ids$: Observable; ids: string[]; @@ -32,6 +32,9 @@ export class FilterTargetComponent extends ObjectWidget implements OnDestroy, Af [Validators.required] ); + errors$: Observable; + hasErrors$: Observable; + constructor( private store: Store ) { @@ -42,6 +45,7 @@ export class FilterTargetComponent extends ObjectWidget implements OnDestroy, Af this.search .valueChanges .pipe( + takeUntil(this.ngUnsubscribe), distinctUntilChanged() ) .subscribe(query => this.searchEntityIds(query)); @@ -49,6 +53,7 @@ export class FilterTargetComponent extends ObjectWidget implements OnDestroy, Af this.script .valueChanges .pipe( + takeUntil(this.ngUnsubscribe), distinctUntilChanged(), skipWhile(() => this.targetType === 'ENTITY') ) @@ -61,6 +66,31 @@ export class FilterTargetComponent extends ObjectWidget implements OnDestroy, Af super.ngAfterViewInit(); this.script.setValue(this.targets[0]); this.search.setValidators(this.unique()); + + this.errors$ = this.formProperty.errorsChanges.pipe( + map(errors => + errors && errors.length > 1 ? + Array + .from(new Set(errors.filter(e => e.code !== 'ARRAY_LENGTH_SHORT').map(e => e.code))) + .map(id => ({ ...errors.find(e => e.code === id) })) + : [] + )); + + this.errors$ + .pipe( + takeUntil(this.ngUnsubscribe), + map(errors => errors.reduce((collection, e) => ({ ...collection, [e.code]: e.message }), {})), + map(errors => Object.keys(errors).length > 0 ? errors : null) + ) + .subscribe(errors => this.script.setErrors( + errors, + { + emitEvent: true + } + ) + ); + + this.hasErrors$ = this.errors$.pipe(map(e => e && e.length > 0)); } unique(): ValidatorFn { @@ -138,5 +168,7 @@ export class FilterTargetComponent extends ObjectWidget implements OnDestroy, Af ngOnDestroy(): void { this.store.dispatch(new ClearSearch()); + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); } } diff --git a/ui/src/app/shared/memo.spec.ts b/ui/src/app/shared/memo.spec.ts new file mode 100644 index 000000000..01fcd9898 --- /dev/null +++ b/ui/src/app/shared/memo.spec.ts @@ -0,0 +1,21 @@ +import { memoize } from './memo'; + +const fns = { + square(n) { + return n * n; + } +}; + +describe('memoize function', () => { + it('should return a memoized function', () => { + spyOn(fns, 'square').and.callThrough(); + const memoized = memoize(fns.square); + const call1 = memoized(1); + const call2 = memoized(2); + const call3 = memoized(2); + expect(call1).toBe(1); + expect(call2).toBe(4); + expect(call3).toBe(4); + expect(fns.square).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/src/app/shared/memo.ts b/ui/src/app/shared/memo.ts new file mode 100644 index 000000000..3f719142a --- /dev/null +++ b/ui/src/app/shared/memo.ts @@ -0,0 +1,15 @@ +export function memoize(func) { + const cache = {}; + return function (...args: any[]) { + const key = JSON.stringify(args); + if (cache[key]) { + return cache[key]; + } else { + const val = func.apply(null, args); + cache[key] = val; + return val; + } + }; +} + +export default { memoize }; diff --git a/ui/src/app/shared/validation/regex.validator.spec.ts b/ui/src/app/shared/validation/regex.validator.spec.ts new file mode 100644 index 000000000..ce8af17ee --- /dev/null +++ b/ui/src/app/shared/validation/regex.validator.spec.ts @@ -0,0 +1,18 @@ +import { RegexValidator } from './regex.validator'; + +describe('RegexValidator', () => { + describe('isValidRegex method', () => { + it('should return true if no error is thrown', () => { + expect(RegexValidator.isValidRegex('/abc/')).toBe(true); + expect(RegexValidator.isValidRegex('/*123/')).toBe(true); + }); + + it('should return false if an error is thrown trying to construct a regex', () => { + expect(RegexValidator.isValidRegex(')')).toBe(false); + }); + + it('should return false if the regex doesnt begin and end with slashes', () => { + expect(RegexValidator.isValidRegex('abc')).toBe(false); + }); + }); +}); diff --git a/ui/src/app/shared/validation/regex.validator.ts b/ui/src/app/shared/validation/regex.validator.ts new file mode 100644 index 000000000..be567a0f4 --- /dev/null +++ b/ui/src/app/shared/validation/regex.validator.ts @@ -0,0 +1,18 @@ +const regexChecker = new RegExp('^\/|\/$', 'g'); + +export class RegexValidator { + static isValidRegex(pattern: string): boolean { + if (!pattern) { + return true; + } + let regex; + try { + regex = new RegExp(pattern); + } catch (err) { + return false; + } + return regexChecker.test(pattern); + } +} + +export default RegexValidator;