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 baff50a8a..e16eb8e65 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 @@ -7,7 +7,7 @@ {{ property.items.properties[prop].title }} -
{{ version[i][prop] }}
-
+
-
diff --git a/ui/src/app/metadata/configuration/container/metadata-options.component.html b/ui/src/app/metadata/configuration/container/metadata-options.component.html index 390d6981c..a03e50663 100644 --- a/ui/src/app/metadata/configuration/container/metadata-options.component.html +++ b/ui/src/app/metadata/configuration/container/metadata-options.component.html @@ -35,7 +35,6 @@

-
Loading... diff --git a/ui/src/app/metadata/configuration/effect/restore.effect.ts b/ui/src/app/metadata/configuration/effect/restore.effect.ts index 7be47103e..f15f21569 100644 --- a/ui/src/app/metadata/configuration/effect/restore.effect.ts +++ b/ui/src/app/metadata/configuration/effect/restore.effect.ts @@ -30,6 +30,8 @@ import { import { SetMetadata } from '../action/configuration.action'; import { removeNulls } from '../../../shared/util'; import { getModel } from '../../../wizard/reducer'; +import { ClearProviderSelection } from '../../provider/action/collection.action'; +import { ClearResolverSelection } from '../../resolver/action/collection.action'; @Injectable() @@ -107,6 +109,15 @@ export class RestoreEffects { ) ); + @Effect() + restoreVersionSuccessClear$ = this.actions$.pipe( + ofType(RestoreActionTypes.RESTORE_VERSION_SUCCESS), + map(action => action.payload), + map(({ id, type }) => + type === 'provider' ? new ClearProviderSelection() : new ClearResolverSelection() + ) + ); + @Effect() restoreVersionSuccessReload$ = this.actions$.pipe( ofType(RestoreActionTypes.RESTORE_VERSION_SUCCESS), 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/metadata/provider/reducer/index.spec.ts b/ui/src/app/metadata/provider/reducer/index.spec.ts new file mode 100644 index 000000000..7ffb3e9db --- /dev/null +++ b/ui/src/app/metadata/provider/reducer/index.spec.ts @@ -0,0 +1,24 @@ +import { getFilteredListFn } from './'; + +describe('Provider Reducer selectors', () => { + describe(`getFilteredListFn method`, () => { + it('should return a list without the provider`s property', () => { + + const fn = getFilteredListFn('name'); + const name = 'foo'; + const collection = ['foo', 'bar', 'baz']; + const provider = { name }; + + expect(fn(collection, provider)).toEqual(['bar', 'baz']); + }); + + it('should return the list if the provider passed is null', () => { + + const fn = getFilteredListFn('name'); + const name = 'foo'; + const collection = ['foo', 'bar', 'baz']; + + expect(fn(collection, null)).toEqual(['foo', 'bar', 'baz']); + }); + }); +}); diff --git a/ui/src/app/metadata/provider/reducer/index.ts b/ui/src/app/metadata/provider/reducer/index.ts index 562a4ab44..8005d63e9 100644 --- a/ui/src/app/metadata/provider/reducer/index.ts +++ b/ui/src/app/metadata/provider/reducer/index.ts @@ -66,11 +66,11 @@ export const getProviderIds = createSelector(getCollectionState, fromCollection. export const getProviderCollectionIsLoaded = createSelector(getCollectionState, fromCollection.getIsLoaded); export const getProviderNames = createSelector(getAllProviders, (providers: MetadataProvider[]) => providers.map(p => p.name)); -export const getFilteredProviderNames = createSelector( - getProviderNames, - getSelectedProvider, (names, provider) => names.filter(name => name !== provider.name) -); +export const getFilteredListFn = (property: string) => + (collection, provider) => !provider ? collection : collection.filter(val => val !== provider[property]); + +export const getFilteredProviderNames = createSelector(getProviderNames, getSelectedProvider, getFilteredListFn('name')); export const getProviderFilters = createSelector(getSelectedProvider, provider => provider.metadataFilters); @@ -78,7 +78,4 @@ export const getProviderXmlIds = createSelector(getAllProviders, (providers: Met export const getOrderedProviders = createSelector(getAllProviders, getProviderOrder, utils.mergeOrderFn); export const getOrderedProvidersInSearch = createSelector(getAllProviders, getProviderOrder, utils.mergeOrderFn); -export const getFilteredProviderXmlIds = createSelector( - getProviderXmlIds, - getSelectedProvider, (ids, provider) => ids.filter(id => id !== provider.xmlId) -); +export const getFilteredProviderXmlIds = createSelector(getProviderXmlIds, getSelectedProvider, getFilteredListFn('xmlId')); diff --git a/ui/src/app/metadata/resolver/action/collection.action.ts b/ui/src/app/metadata/resolver/action/collection.action.ts index dc3843319..57861b17c 100644 --- a/ui/src/app/metadata/resolver/action/collection.action.ts +++ b/ui/src/app/metadata/resolver/action/collection.action.ts @@ -7,6 +7,8 @@ export enum ResolverCollectionActionTypes { SELECT = '[Metadata Resolver] Select', SELECT_SUCCESS = '[Metadata Resolver] Select Success', + CLEAR_SELECTION = '[Metadata Resolver] Selection Clear', + UPDATE_RESOLVER_REQUEST = '[Metadata Resolver] Update Request', UPDATE_RESOLVER_SUCCESS = '[Metadata Resolver] Update Success', UPDATE_RESOLVER_FAIL = '[Metadata Resolver] Update Fail', @@ -143,6 +145,10 @@ export class CreateResolverFromUrlRequest implements Action { constructor(public payload: { name: string, url: string }) { } } +export class ClearResolverSelection implements Action { + readonly type = ResolverCollectionActionTypes.CLEAR_SELECTION; +} + export type ResolverCollectionActionsUnion = | LoadResolverRequest | LoadResolverSuccess @@ -162,4 +168,5 @@ export type ResolverCollectionActionsUnion = | UpdateResolverFail | UpdateResolverConflict | UploadResolverRequest - | CreateResolverFromUrlRequest; + | CreateResolverFromUrlRequest + | ClearResolverSelection; diff --git a/ui/src/app/metadata/resolver/effect/collection.effects.ts b/ui/src/app/metadata/resolver/effect/collection.effects.ts index ff5c5ea9c..2a92b0242 100644 --- a/ui/src/app/metadata/resolver/effect/collection.effects.ts +++ b/ui/src/app/metadata/resolver/effect/collection.effects.ts @@ -35,6 +35,7 @@ import { I18nService } from '../../../i18n/service/i18n.service'; import * as fromRoot from '../../../app.reducer'; import * as fromI18n from '../../../i18n/reducer'; import { FileBackedHttpMetadataResolver } from '../../domain/entity'; +import { UpdateSaving } from '../action/entity.action'; /* istanbul ignore next */ @@ -79,6 +80,21 @@ export class ResolverCollectionEffects { }) ); + @Effect() + updateResolverSavingOnRequest$ = this.actions$.pipe( + ofType(ResolverCollectionActionTypes.UPDATE_RESOLVER_REQUEST), + map(action => new UpdateSaving(true)), + ); + + @Effect() + updateResolverSavingOnResult$ = this.actions$.pipe( + ofType( + ResolverCollectionActionTypes.UPDATE_RESOLVER_SUCCESS, + ResolverCollectionActionTypes.UPDATE_RESOLVER_FAIL + ), + map(action => new UpdateSaving(false)), + ); + @Effect({ dispatch: false }) updateResolverSuccessRedirect$ = this.actions$.pipe( ofType(ResolverCollectionActionTypes.UPDATE_RESOLVER_SUCCESS), diff --git a/ui/src/app/metadata/resolver/reducer/collection.reducer.ts b/ui/src/app/metadata/resolver/reducer/collection.reducer.ts index 93118aec3..425e2dee6 100644 --- a/ui/src/app/metadata/resolver/reducer/collection.reducer.ts +++ b/ui/src/app/metadata/resolver/reducer/collection.reducer.ts @@ -51,6 +51,13 @@ export function reducer(state = initialState, action: ResolverCollectionActionsU }); } + case ResolverCollectionActionTypes.CLEAR_SELECTION: { + return { + ...state, + selectedResolverId: null + }; + } + default: { return state; } 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;