diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a72d0d6c18..7272cfcd1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,8 @@ jobs: DSPACE_CACHE_SERVERSIDE_ANONYMOUSCACHE_MAX: 0 # Tell Cypress to run e2e tests using the same UI URL CYPRESS_BASE_URL: http://127.0.0.1:4000 + # Disable the cookie consent banner in e2e tests to avoid errors because of elements hidden by it + DSPACE_INFO_ENABLECOOKIECONSENTPOPUP: false # When Chrome version is specified, we pin to a specific version of Chrome # Comment this out to use the latest release #CHROME_VERSION: "90.0.4430.212-1" diff --git a/config/config.example.yml b/config/config.example.yml index ac2a645e25..c82df9e3b2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -458,3 +458,8 @@ search: # Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count. # e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved. defaultFiltersCount: 5 + +# Configuration for storing accessibility settings, used by the AccessibilitySettingsService +accessibility: + # The duration in days after which the accessibility settings cookie expires + cookieExpirationDuration: 7 diff --git a/src/app/accessibility/accessibility-settings.config.ts b/src/app/accessibility/accessibility-settings.config.ts new file mode 100644 index 0000000000..1852579c3d --- /dev/null +++ b/src/app/accessibility/accessibility-settings.config.ts @@ -0,0 +1,11 @@ +import { Config } from '../../config/config.interface'; + +/** + * Configuration interface used by the AccessibilitySettingsService + */ +export class AccessibilitySettingsConfig implements Config { + /** + * The duration in days after which the accessibility settings cookie expires + */ + cookieExpirationDuration: number; +} diff --git a/src/app/accessibility/accessibility-settings.service.spec.ts b/src/app/accessibility/accessibility-settings.service.spec.ts new file mode 100644 index 0000000000..0434aa972d --- /dev/null +++ b/src/app/accessibility/accessibility-settings.service.spec.ts @@ -0,0 +1,410 @@ +import { + AccessibilitySettingsService, + AccessibilitySettings, + ACCESSIBILITY_SETTINGS_METADATA_KEY, + ACCESSIBILITY_COOKIE, AccessibilitySettingsFormValues, FullAccessibilitySettings +} from './accessibility-settings.service'; +import { CookieService } from '../core/services/cookie.service'; +import { AuthService } from '../core/auth/auth.service'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { CookieServiceMock } from '../shared/mocks/cookie.service.mock'; +import { AuthServiceStub } from '../shared/testing/auth-service.stub'; +import { of } from 'rxjs'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { fakeAsync, flush } from '@angular/core/testing'; +import { createSuccessfulRemoteDataObject$, createFailedRemoteDataObject$ } from '../shared/remote-data.utils'; +import { KlaroServiceStub } from '../shared/cookies/klaro.service.stub'; +import { AppConfig } from '../../config/app-config.interface'; + + +describe('accessibilitySettingsService', () => { + let service: AccessibilitySettingsService; + let cookieService: CookieServiceMock; + let authService: AuthServiceStub; + let ePersonService: EPersonDataService; + let klaroService: KlaroServiceStub; + let appConfig: AppConfig; + + beforeEach(() => { + cookieService = new CookieServiceMock(); + authService = new AuthServiceStub(); + klaroService = new KlaroServiceStub(); + appConfig = { accessibility: { cookieExpirationDuration: 10 }} as AppConfig; + + klaroService.getSavedPreferences.and.returnValue(of({ accessibility: true })); + + ePersonService = jasmine.createSpyObj('ePersonService', { + createPatchFromCache: of([{ + op: 'add', + value: null, + }]), + patch: of({}), + }); + + service = new AccessibilitySettingsService( + cookieService as unknown as CookieService, + authService as unknown as AuthService, + ePersonService, + klaroService, + appConfig, + ); + }); + + describe('get', () => { + it('should return the setting if it is set', () => { + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.getAll = jasmine.createSpy('getAll').and.returnValue(of(settings)); + + service.get('notificationTimeOut', 'default').subscribe(value => + expect(value).toEqual('1000') + ); + }); + + it('should return the default value if the setting is not set', () => { + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.getAll = jasmine.createSpy('getAll').and.returnValue(of(settings)); + + service.get('liveRegionTimeOut', 'default').subscribe(value => + expect(value).toEqual('default') + ); + }); + }); + + describe('getAsNumber', () => { + it('should return the setting as number if the value for the setting can be parsed to a number', () => { + service.get = jasmine.createSpy('get').and.returnValue(of('1000')); + + service.getAsNumber('notificationTimeOut').subscribe(value => + expect(value).toEqual(1000) + ); + }); + + it('should return the default value if no value is set for the setting', () => { + service.get = jasmine.createSpy('get').and.returnValue(of(null)); + + service.getAsNumber('notificationTimeOut', 123).subscribe(value => + expect(value).toEqual(123) + ); + }); + + it('should return the default value if the value for the setting can not be parsed to a number', () => { + service.get = jasmine.createSpy('get').and.returnValue(of('text')); + + service.getAsNumber('notificationTimeOut', 123).subscribe(value => + expect(value).toEqual(123) + ); + }); + }); + + describe('getAll', () => { + it('should attempt to get the settings from metadata first', () => { + service.getAllSettingsFromAuthenticatedUserMetadata = + jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ })); + + service.getAll().subscribe(); + expect(service.getAllSettingsFromAuthenticatedUserMetadata).toHaveBeenCalled(); + }); + + it('should attempt to get the settings from the cookie if the settings from metadata are empty', () => { + service.getAllSettingsFromAuthenticatedUserMetadata = + jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ })); + + service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ }); + + service.getAll().subscribe(); + expect(service.getAllSettingsFromCookie).toHaveBeenCalled(); + }); + + it('should not attempt to get the settings from the cookie if the settings from metadata are not empty', () => { + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.getAllSettingsFromAuthenticatedUserMetadata = + jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of(settings)); + + service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ }); + + service.getAll().subscribe(); + expect(service.getAllSettingsFromCookie).not.toHaveBeenCalled(); + }); + + it('should return an empty object if both are empty', () => { + service.getAllSettingsFromAuthenticatedUserMetadata = + jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata').and.returnValue(of({ })); + + service.getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({ }); + + service.getAll().subscribe(value => expect(value).toEqual({})); + }); + }); + + describe('getAllSettingsFromCookie', () => { + it('should retrieve the settings from the cookie', () => { + cookieService.get = jasmine.createSpy(); + + service.getAllSettingsFromCookie(); + expect(cookieService.get).toHaveBeenCalledWith(ACCESSIBILITY_COOKIE); + }); + }); + + describe('getAllSettingsFromAuthenticatedUserMetadata', () => { + it('should retrieve all settings from the user\'s metadata', () => { + const settings = { 'liveRegionTimeOut': '1000' }; + + const user = new EPerson(); + user.setMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY, null, JSON.stringify(settings)); + + authService.getAuthenticatedUserFromStoreIfAuthenticated = + jasmine.createSpy('getAuthenticatedUserFromStoreIfAuthenticated').and.returnValue(of(user)); + + service.getAllSettingsFromAuthenticatedUserMetadata().subscribe(value => + expect(value).toEqual(settings) + ); + }); + }); + + describe('set', () => { + it('should correctly update the chosen setting', () => { + service.updateSettings = jasmine.createSpy('updateSettings'); + + service.set('liveRegionTimeOut', '500'); + expect(service.updateSettings).toHaveBeenCalledWith({ liveRegionTimeOut: '500' }); + }); + }); + + describe('setSettings', () => { + beforeEach(() => { + service.setSettingsInCookie = jasmine.createSpy('setSettingsInCookie').and.returnValue(of('cookie')); + }); + + it('should attempt to set settings in metadata', () => { + service.setSettingsInAuthenticatedUserMetadata = + jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('failed')); + + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.setSettings(settings).subscribe(); + expect(service.setSettingsInAuthenticatedUserMetadata).toHaveBeenCalledWith(settings); + }); + + it('should set settings in cookie if metadata failed', () => { + service.setSettingsInAuthenticatedUserMetadata = + jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of(false)); + + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.setSettings(settings).subscribe(); + expect(service.setSettingsInCookie).toHaveBeenCalled(); + }); + + it('should not set settings in cookie if metadata succeeded', () => { + service.setSettingsInAuthenticatedUserMetadata = + jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('metadata')); + + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.setSettings(settings).subscribe(); + expect(service.setSettingsInCookie).not.toHaveBeenCalled(); + }); + + it('should return \'metadata\' if settings are stored in metadata', () => { + service.setSettingsInAuthenticatedUserMetadata = + jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of('metadata')); + + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.setSettings(settings).subscribe(value => + expect(value).toEqual('metadata') + ); + }); + + it('should return \'cookie\' if settings are stored in cookie', () => { + service.setSettingsInAuthenticatedUserMetadata = + jasmine.createSpy('setSettingsInAuthenticatedUserMetadata').and.returnValue(of(false)); + + const settings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.setSettings(settings).subscribe(value => + expect(value).toEqual('cookie') + ); + }); + }); + + describe('updateSettings', () => { + it('should call setSettings with the updated settings', () => { + const beforeSettings: AccessibilitySettings = { + notificationTimeOut: '1000', + }; + + service.getAll = jasmine.createSpy('getAll').and.returnValue(of(beforeSettings)); + service.setSettings = jasmine.createSpy('setSettings').and.returnValue(of('cookie')); + + const newSettings: AccessibilitySettings = { + liveRegionTimeOut: '2000', + }; + + const combinedSettings: AccessibilitySettings = { + notificationTimeOut: '1000', + liveRegionTimeOut: '2000', + }; + + service.updateSettings(newSettings).subscribe(); + expect(service.setSettings).toHaveBeenCalledWith(combinedSettings); + }); + }); + + describe('setSettingsInAuthenticatedUserMetadata', () => { + beforeEach(() => { + service.setSettingsInMetadata = jasmine.createSpy('setSettingsInMetadata').and.returnValue(of(null)); + }); + + it('should store settings in metadata when the user is authenticated', fakeAsync(() => { + const user = new EPerson(); + authService.getAuthenticatedUserFromStoreIfAuthenticated = jasmine.createSpy().and.returnValue(of(user)); + + service.setSettingsInAuthenticatedUserMetadata({}).subscribe(); + flush(); + + expect(service.setSettingsInMetadata).toHaveBeenCalled(); + })); + + it('should emit "failed" when the user is not authenticated', fakeAsync(() => { + authService.getAuthenticatedUserFromStoreIfAuthenticated = jasmine.createSpy().and.returnValue(of(null)); + + service.setSettingsInAuthenticatedUserMetadata({}) + .subscribe(value => expect(value).toEqual('failed')); + flush(); + + expect(service.setSettingsInMetadata).not.toHaveBeenCalled(); + })); + }); + + describe('setSettingsInMetadata', () => { + const ePerson = new EPerson(); + + beforeEach(() => { + ePerson.setMetadata = jasmine.createSpy('setMetadata'); + ePerson.removeMetadata = jasmine.createSpy('removeMetadata'); + }); + + it('should set the settings in metadata', () => { + service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe(); + expect(ePerson.setMetadata).toHaveBeenCalled(); + }); + + it('should remove the metadata when the settings are emtpy', () => { + service.setSettingsInMetadata(ePerson, {}).subscribe(); + expect(ePerson.setMetadata).not.toHaveBeenCalled(); + expect(ePerson.removeMetadata).toHaveBeenCalled(); + }); + + it('should create a patch with the metadata changes', () => { + service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe(); + expect(ePersonService.createPatchFromCache).toHaveBeenCalled(); + }); + + it('should send the patch request', () => { + service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }).subscribe(); + expect(ePersonService.patch).toHaveBeenCalled(); + }); + + it('should emit "metadata" when the update succeeded', fakeAsync(() => { + ePersonService.patch = jasmine.createSpy().and.returnValue(createSuccessfulRemoteDataObject$({})); + + service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }) + .subscribe(value => { + expect(value).toEqual('metadata'); + }); + + flush(); + })); + + it('should emit "failed" when the update failed', fakeAsync(() => { + ePersonService.patch = jasmine.createSpy().and.returnValue(createFailedRemoteDataObject$()); + + service.setSettingsInMetadata(ePerson, { ['liveRegionTimeOut']: '500' }) + .subscribe(value => { + expect(value).toEqual('failed'); + }); + + flush(); + })); + }); + + describe('setSettingsInCookie', () => { + beforeEach(() => { + cookieService.set = jasmine.createSpy('set'); + cookieService.remove = jasmine.createSpy('remove'); + }); + + it('should fail to store settings in the cookie when the user has not accepted the cookie', fakeAsync(() => { + klaroService.getSavedPreferences.and.returnValue(of({ accessibility: false })); + + service.setSettingsInCookie({ ['liveRegionTimeOut']: '500' }).subscribe(value => { + expect(value).toEqual('failed'); + }); + flush(); + expect(cookieService.set).not.toHaveBeenCalled(); + })); + + it('should store the settings in a cookie', fakeAsync(() => { + service.setSettingsInCookie({ ['liveRegionTimeOut']: '500' }).subscribe(value => { + expect(value).toEqual('cookie'); + }); + flush(); + expect(cookieService.set).toHaveBeenCalled(); + })); + + it('should remove the cookie when the settings are empty', fakeAsync(() => { + service.setSettingsInCookie({}).subscribe(value => { + expect(value).toEqual('cookie'); + }); + + flush(); + + expect(cookieService.set).not.toHaveBeenCalled(); + expect(cookieService.remove).toHaveBeenCalled(); + })); + }); + + describe('convertFormValuesToStoredValues', () => { + it('should reset the notificationTimeOut when timeOut is enabled but set to "0"', () => { + const formValues: AccessibilitySettingsFormValues = { + notificationTimeOutEnabled: true, + notificationTimeOut: '0', + liveRegionTimeOut: null, + }; + + const storedValues: FullAccessibilitySettings = service.convertFormValuesToStoredValues(formValues); + expect('notificationTimeOut' in storedValues).toBeFalse(); + }); + }); + + it('should keep the notificationTimeOut when timeOut is enabled and differs from "0"', () => { + const formValues: AccessibilitySettingsFormValues = { + notificationTimeOutEnabled: true, + notificationTimeOut: '3', + liveRegionTimeOut: null, + }; + + const storedValues: FullAccessibilitySettings = service.convertFormValuesToStoredValues(formValues); + expect('notificationTimeOut' in storedValues).toBeTrue(); + }); +}); diff --git a/src/app/accessibility/accessibility-settings.service.stub.ts b/src/app/accessibility/accessibility-settings.service.stub.ts new file mode 100644 index 0000000000..1b8c534010 --- /dev/null +++ b/src/app/accessibility/accessibility-settings.service.stub.ts @@ -0,0 +1,44 @@ +import { of } from 'rxjs'; +import { AccessibilitySettingsService } from './accessibility-settings.service'; + +export function getAccessibilitySettingsServiceStub(): AccessibilitySettingsService { + return new AccessibilitySettingsServiceStub() as unknown as AccessibilitySettingsService; +} + +export class AccessibilitySettingsServiceStub { + getAllAccessibilitySettingKeys = jasmine.createSpy('getAllAccessibilitySettingKeys').and.returnValue([]); + + get = jasmine.createSpy('get').and.returnValue(of(null)); + + getAsNumber = jasmine.createSpy('getAsNumber').and.returnValue(of(0)); + + getAll = jasmine.createSpy('getAll').and.returnValue(of({})); + + getAllSettingsFromCookie = jasmine.createSpy('getAllSettingsFromCookie').and.returnValue({}); + + getAllSettingsFromAuthenticatedUserMetadata = jasmine.createSpy('getAllSettingsFromAuthenticatedUserMetadata') + .and.returnValue(of({})); + + set = jasmine.createSpy('setSettings').and.returnValue(of('cookie')); + + updateSettings = jasmine.createSpy('updateSettings').and.returnValue(of('cookie')); + + setSettingsInAuthenticatedUserMetadata = jasmine.createSpy('setSettingsInAuthenticatedUserMetadata') + .and.returnValue(of(false)); + + setSettingsInMetadata = jasmine.createSpy('setSettingsInMetadata').and.returnValue(of(false)); + + setSettingsInCookie = jasmine.createSpy('setSettingsInCookie'); + + getInputType = jasmine.createSpy('getInputType').and.returnValue('text'); + + convertFormValuesToStoredValues = jasmine.createSpy('convertFormValuesToStoredValues').and.returnValue({}); + + convertStoredValuesToFormValues = jasmine.createSpy('convertStoredValuesToFormValues').and.returnValue({}); + + getDefaultValue = jasmine.createSpy('getPlaceholder').and.returnValue('placeholder'); + + isValid = jasmine.createSpy('isValid').and.returnValue(true); + + formValuesValid = jasmine.createSpy('allValid').and.returnValue(true); +} diff --git a/src/app/accessibility/accessibility-settings.service.ts b/src/app/accessibility/accessibility-settings.service.ts new file mode 100644 index 0000000000..411d7fae1f --- /dev/null +++ b/src/app/accessibility/accessibility-settings.service.ts @@ -0,0 +1,361 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { Observable, of, switchMap, combineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { CookieService } from '../core/services/cookie.service'; +import { hasValue, isNotEmpty, hasNoValue } from '../shared/empty.util'; +import { AuthService } from '../core/auth/auth.service'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import cloneDeep from 'lodash/cloneDeep'; +import { environment } from '../../environments/environment'; +import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { KlaroService } from '../shared/cookies/klaro.service'; +import { AppConfig, APP_CONFIG } from '../../config/app-config.interface'; + +/** + * Name of the cookie used to store the settings locally + */ +export const ACCESSIBILITY_COOKIE = 'dsAccessibilityCookie'; + +/** + * Name of the metadata field to store settings on the ePerson + */ +export const ACCESSIBILITY_SETTINGS_METADATA_KEY = 'dspace.accessibility.settings'; + +/** + * Array containing all possible accessibility settings. + * When adding new settings, make sure to add the new setting to the accessibility-settings component form. + * The converter methods to convert from stored format to form format (and vice-versa) need to be updated as well. + */ +export const accessibilitySettingKeys = ['notificationTimeOut', 'liveRegionTimeOut'] as const; + +/** + * Type representing the possible accessibility settings + */ +export type AccessibilitySetting = typeof accessibilitySettingKeys[number]; + +/** + * Type representing an object that contains accessibility settings values for all accessibility settings. + */ +export type FullAccessibilitySettings = { [key in AccessibilitySetting]: string }; + +/** + * Type representing an object that contains accessibility settings values for some accessibility settings. + */ +export type AccessibilitySettings = Partial; + +/** + * The accessibility settings object format used by the accessibility-settings component form. + */ +export interface AccessibilitySettingsFormValues { + notificationTimeOutEnabled: boolean, + notificationTimeOut: string, + liveRegionTimeOut: string, +} + +/** + * Service handling the retrieval and configuration of accessibility settings. + * + * This service stores the configured settings in either a cookie or on the user's metadata depending on whether + * the user is authenticated. + */ +@Injectable({ + providedIn: 'root' +}) +export class AccessibilitySettingsService { + + constructor( + protected cookieService: CookieService, + protected authService: AuthService, + protected ePersonService: EPersonDataService, + @Optional() protected klaroService: KlaroService, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + } + + /** + * Get the stored value for the provided {@link AccessibilitySetting}. If the value does not exist or if it is empty, + * the provided defaultValue is emitted instead. + */ + get(setting: AccessibilitySetting, defaultValue: string = null): Observable { + return this.getAll().pipe( + map(settings => settings[setting]), + map(value => isNotEmpty(value) ? value : defaultValue), + ); + } + + /** + * Get the stored value for the provided {@link AccessibilitySetting} as a number. If the stored value + * could not be converted to a number, the value of the defaultValue parameter is emitted instead. + */ + getAsNumber(setting: AccessibilitySetting, defaultValue: number = null): Observable { + return this.get(setting).pipe( + map(value => hasValue(value) ? parseInt(value, 10) : NaN), + map(number => !isNaN(number) ? number : defaultValue), + ); + } + + /** + * Get all currently stored accessibility settings + */ + getAll(): Observable { + return this.getAllSettingsFromAuthenticatedUserMetadata().pipe( + map(value => isNotEmpty(value) ? value : this.getAllSettingsFromCookie()), + map(value => isNotEmpty(value) ? value : {}), + ); + } + + /** + * Get all settings from the accessibility settings cookie + */ + getAllSettingsFromCookie(): AccessibilitySettings { + return this.cookieService.get(ACCESSIBILITY_COOKIE); + } + + /** + * Attempts to retrieve all settings from the authenticated user's metadata. + * Returns an empty object when no user is authenticated. + */ + getAllSettingsFromAuthenticatedUserMetadata(): Observable { + return this.authService.getAuthenticatedUserFromStoreIfAuthenticated().pipe( + take(1), + map(user => hasValue(user) && hasValue(user.firstMetadataValue(ACCESSIBILITY_SETTINGS_METADATA_KEY)) ? + JSON.parse(user.firstMetadataValue(ACCESSIBILITY_SETTINGS_METADATA_KEY)) : + {} + ), + ); + } + + /** + * Set a single accessibility setting value, leaving all other settings unchanged. + * When setting all values, {@link AccessibilitySettingsService#setSettings} should be used. + * When updating multiple values, {@link AccessibilitySettingsService#updateSettings} should be used. + * + * Returns 'cookie' when the changes were stored in the cookie. + * Returns 'metadata' when the changes were stored in metadata. + * Returns 'failed' when both options failed. + */ + set(setting: AccessibilitySetting, value: string): Observable<'metadata' | 'cookie' | 'failed'> { + return this.updateSettings({ [setting]: value }); + } + + /** + * Set all accessibility settings simultaneously. + * This method removes existing settings if they are missing from the provided {@link AccessibilitySettings} object. + * Removes all settings if the provided object is empty. + * + * Returns 'cookie' when the changes were stored in the cookie. + * Returns 'metadata' when the changes were stored in metadata. + * Returns 'failed' when both options failed. + */ + setSettings(settings: AccessibilitySettings): Observable<'metadata' | 'cookie' | 'failed'> { + return this.setSettingsInAuthenticatedUserMetadata(settings).pipe( + take(1), + map(saveLocation => saveLocation === 'metadata'), + switchMap((savedInMetadata) => + savedInMetadata ? ofMetadata() : this.setSettingsInCookie(settings) + ), + ); + } + + /** + * Update multiple accessibility settings simultaneously. + * This method does not change the settings that are missing from the provided {@link AccessibilitySettings} object. + * + * Returns 'cookie' when the changes were stored in the cookie. + * Returns 'metadata' when the changes were stored in metadata. + * Returns 'failed' when both options failed. + */ + updateSettings(settings: AccessibilitySettings): Observable<'metadata' | 'cookie' | 'failed'> { + return this.getAll().pipe( + take(1), + map(currentSettings => Object.assign({}, currentSettings, settings)), + switchMap(newSettings => this.setSettings(newSettings)) + ); + } + + /** + * Attempts to set the provided settings on the currently authorized user's metadata. + * Emits false when no user is authenticated or when the metadata update failed. + * Emits 'metadata' when the metadata update succeeded, and 'failed' otherwise. + */ + setSettingsInAuthenticatedUserMetadata(settings: AccessibilitySettings): Observable<'metadata' | 'failed'> { + return this.authService.getAuthenticatedUserFromStoreIfAuthenticated().pipe( + take(1), + switchMap(user => { + if (hasValue(user)) { + // EPerson has to be cloned, otherwise the EPerson's metadata can't be modified + const clonedUser = cloneDeep(user); + return this.setSettingsInMetadata(clonedUser, settings); + } else { + return ofFailed(); + } + }) + ); + } + + /** + * Attempts to set the provided settings on the user's metadata. + * Emits false when the update failed, true when the update succeeded. + */ + setSettingsInMetadata( + user: EPerson, + settings: AccessibilitySettings, + ): Observable<'metadata' | 'failed'> { + if (isNotEmpty(settings)) { + user.setMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY, null, JSON.stringify(settings)); + } else { + user.removeMetadata(ACCESSIBILITY_SETTINGS_METADATA_KEY); + } + + return this.ePersonService.createPatchFromCache(user).pipe( + take(1), + switchMap(operations => + isNotEmpty(operations) ? this.ePersonService.patch(user, operations) : createSuccessfulRemoteDataObject$({})), + getFirstCompletedRemoteData(), + switchMap(rd => rd.hasSucceeded ? ofMetadata() : ofFailed()), + ); + } + + /** + * Attempts to set the provided settings in a cookie. + * Emits 'failed' when setting in a cookie failed due to the cookie not being accepted, 'cookie' when it succeeded. + */ + setSettingsInCookie(settings: AccessibilitySettings): Observable<'cookie' | 'failed'> { + if (hasNoValue(this.klaroService)) { + return of('failed'); + } + + return this.klaroService.getSavedPreferences().pipe( + map(preferences => preferences.accessibility), + map((accessibilityCookieAccepted: boolean) => { + if (accessibilityCookieAccepted) { + if (isNotEmpty(settings)) { + this.cookieService.set(ACCESSIBILITY_COOKIE, settings, { expires: this.appConfig.accessibility.cookieExpirationDuration }); + } else { + this.cookieService.remove(ACCESSIBILITY_COOKIE); + } + + return 'cookie'; + } else { + return 'failed'; + } + }), + ); + } + + /** + * Clears all settings in the cookie and attempts to clear settings in metadata. + * Emits an array mentioning which settings succeeded or failed. + */ + clearSettings(): Observable<['cookie' | 'failed', 'metadata' | 'failed']> { + return combineLatest([ + this.setSettingsInCookie({}), + this.setSettingsInAuthenticatedUserMetadata({}), + ]); + } + + /** + * Retrieve the default value to be used for the provided AccessibilitySetting. + * Returns an empty string when no default value is specified for the provided setting. + */ + getDefaultValue(setting: AccessibilitySetting): string { + switch (setting) { + case 'notificationTimeOut': + return millisecondsToSeconds(environment.notifications.timeOut.toString()); + case 'liveRegionTimeOut': + return millisecondsToSeconds(environment.liveRegion.messageTimeOutDurationMs.toString()); + default: + return ''; + } + } + + /** + * Convert values in the provided accessibility settings object to values ready to be stored. + */ + convertFormValuesToStoredValues(settings: AccessibilitySettingsFormValues): FullAccessibilitySettings { + const storedValues = { + notificationTimeOut: settings.notificationTimeOutEnabled ? + secondsToMilliseconds(settings.notificationTimeOut) : '0', + liveRegionTimeOut: secondsToMilliseconds(settings.liveRegionTimeOut), + }; + + // When the user enables the timeout but does not change the timeout duration from 0, + // it is removed from the values to be stored so the default value is used. + // Keeping it at 0 would mean the notifications are not automatically removed. + if (settings.notificationTimeOutEnabled && settings.notificationTimeOut === '0') { + delete storedValues.notificationTimeOut; + } + + return storedValues; + } + + /** + * Convert values in the provided accessibility settings object to values ready to show in the form. + */ + convertStoredValuesToFormValues(settings: AccessibilitySettings): AccessibilitySettingsFormValues { + return { + notificationTimeOutEnabled: parseFloat(settings.notificationTimeOut) !== 0, + notificationTimeOut: millisecondsToSeconds(settings.notificationTimeOut), + liveRegionTimeOut: millisecondsToSeconds(settings.liveRegionTimeOut), + }; + } + + /** + * Returns true if the provided AccessibilitySetting is valid in regard to the provided formValues. + */ + isValid(setting: AccessibilitySetting, formValues: AccessibilitySettingsFormValues): boolean { + switch (setting) { + case 'notificationTimeOut': + return formValues.notificationTimeOutEnabled ? + hasNoValue(formValues.notificationTimeOut) || parseFloat(formValues.notificationTimeOut) > 0 : + true; + case 'liveRegionTimeOut': + return hasNoValue(formValues.liveRegionTimeOut) || parseFloat(formValues.liveRegionTimeOut) > 0; + default: + throw new Error(`Unhandled accessibility setting during validity check: ${setting}`); + } + } + + /** + * Returns true if all settings in the provided AccessibilitySettingsFormValues object are valid + */ + formValuesValid(formValues: AccessibilitySettingsFormValues) { + return accessibilitySettingKeys.every(setting => this.isValid(setting, formValues)); + } +} + +/** + * Converts a string representing seconds to a string representing milliseconds + * Returns null if the input could not be parsed to a float + */ +function secondsToMilliseconds(secondsStr: string): string { + const seconds = parseFloat(secondsStr); + if (isNaN(seconds)) { + return null; + } else { + return (seconds * 1000).toString(); + } +} + +/** + * Converts a string representing milliseconds to a string representing seconds + * Returns null if the input could not be parsed to a float + */ +function millisecondsToSeconds(millisecondsStr: string): string { + const milliseconds = parseFloat(millisecondsStr); + if (isNaN(milliseconds)) { + return null; + } else { + return (milliseconds / 1000).toString(); + } +} + +function ofMetadata(): Observable<'metadata'> { + return of('metadata'); +} + +function ofFailed(): Observable<'failed'> { + return of('failed'); +} diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 3a83674b7f..6d4ee2de4f 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -43,7 +43,11 @@ import { import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload +} from '../shared/operators'; import { AuthMethod } from './models/auth.method'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RemoteData } from '../data/remote-data'; @@ -227,6 +231,23 @@ export class AuthService { ); } + /** + * Returns an observable which emits the currently authenticated user from the store, + * or null if the user is not authenticated. + */ + public getAuthenticatedUserFromStoreIfAuthenticated(): Observable { + return this.store.pipe( + select(getAuthenticatedUserId), + switchMap((id: string) => { + if (hasValue(id)) { + return this.epersonService.findById(id).pipe(getFirstSucceededRemoteDataPayload()); + } else { + return observableOf(null); + } + }), + ); + } + /** * Checks if token is present into browser storage and is valid. */ diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 1ec797e73e..3d08eadc67 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -63,11 +63,15 @@ href="https://www.lyrasis.org/" role="link" tabindex="0">{{ 'footer.link.lyrasis' | translate}}