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