diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html
new file mode 100644
index 00000000000..ae7bad867a7
--- /dev/null
+++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.html
@@ -0,0 +1,12 @@
+@if ((citaceProStatus$ | async) && (citaceProURL$ | async); as citaceProURL) {
+
+
+
+}
diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts
new file mode 100644
index 00000000000..cbc22bddc15
--- /dev/null
+++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.spec.ts
@@ -0,0 +1,108 @@
+import {
+ ChangeDetectionStrategy,
+ NO_ERRORS_SCHEMA,
+ SecurityContext,
+} from '@angular/core';
+import {
+ ComponentFixture,
+ TestBed,
+} from '@angular/core/testing';
+import {
+ By,
+ DomSanitizer,
+} from '@angular/platform-browser';
+
+import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service';
+import { ConfigurationProperty } from '../../../../../core/shared/configuration-property.model';
+import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
+import { ItemPageCitationFieldComponent } from './item-page-citation.component';
+
+const CITACE_PRO_URL = 'https://www.citacepro.com/api/dspace/citace/oai';
+const CITACE_PRO_UNIVERSITY = 'dspace.jcu.cz';
+
+describe('ItemPageCitationFieldComponent', () => {
+ let component: ItemPageCitationFieldComponent;
+ let fixture: ComponentFixture;
+ const mockHandle = '123456789/3';
+
+ function mockConfigurationDataService(allowed: string) {
+ const valuesByName: { [name: string]: string[] } = {
+ 'citace.pro.url': [CITACE_PRO_URL],
+ 'citace.pro.university': [CITACE_PRO_UNIVERSITY],
+ 'citace.pro.allowed': [allowed],
+ };
+ return {
+ findByPropertyName: (name: string) => createSuccessfulRemoteDataObject$(
+ Object.assign(new ConfigurationProperty(), { name, values: valuesByName[name] ?? [] }),
+ ),
+ };
+ }
+
+ async function init(allowed: string): Promise {
+ await TestBed.configureTestingModule({
+ imports: [ItemPageCitationFieldComponent],
+ providers: [
+ { provide: ConfigurationDataService, useValue: mockConfigurationDataService(allowed) },
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ })
+ .overrideComponent(ItemPageCitationFieldComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default },
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(ItemPageCitationFieldComponent);
+ component = fixture.componentInstance;
+ component.handle = mockHandle;
+ fixture.detectChanges();
+ }
+
+ describe('when citace.pro.allowed is true', () => {
+ beforeEach(async () => {
+ await init('true');
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should enable the widget and compose the CitacePRO URL', () => {
+ expect(component.citaceProStatus$.getValue()).toBeTrue();
+ const sanitizer = TestBed.inject(DomSanitizer);
+ expect(sanitizer.sanitize(SecurityContext.RESOURCE_URL, component.citaceProURL$.getValue()))
+ .toBe(`${CITACE_PRO_URL}:${CITACE_PRO_UNIVERSITY}:${mockHandle}`);
+ });
+
+ it('should render the iframe with a title', () => {
+ const iframe = fixture.debugElement.query(By.css('iframe'));
+ expect(iframe).not.toBeNull();
+ expect(iframe.nativeElement.getAttribute('title')).toBe('Citace PRO');
+ });
+ });
+
+ describe('when citace.pro.allowed is false', () => {
+ beforeEach(async () => {
+ await init('false');
+ });
+
+ it('should keep the widget hidden', () => {
+ expect(component.citaceProStatus$.getValue()).toBeFalse();
+ expect(fixture.debugElement.query(By.css('iframe'))).toBeNull();
+ });
+ });
+
+ describe('makeCitaceProURL', () => {
+ beforeEach(async () => {
+ await init('true');
+ });
+
+ it('should reject a non-http(s) base URL', () => {
+ expect(component.makeCitaceProURL('javascript:alert(1)//', CITACE_PRO_UNIVERSITY)).toBeNull();
+ });
+
+ it('should reject missing config values', () => {
+ expect(component.makeCitaceProURL(undefined, CITACE_PRO_UNIVERSITY)).toBeNull();
+ expect(component.makeCitaceProURL(CITACE_PRO_URL, undefined)).toBeNull();
+ });
+ });
+});
diff --git a/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts
new file mode 100644
index 00000000000..8dcb7c2cdf7
--- /dev/null
+++ b/src/app/item-page/simple/field-components/specific-field/citation/item-page-citation.component.ts
@@ -0,0 +1,85 @@
+import { CommonModule } from '@angular/common';
+import {
+ Component,
+ Input,
+ OnInit,
+} from '@angular/core';
+import {
+ DomSanitizer,
+ SafeResourceUrl,
+} from '@angular/platform-browser';
+import {
+ BehaviorSubject,
+ combineLatest,
+ of,
+} from 'rxjs';
+import {
+ catchError,
+ take,
+} from 'rxjs/operators';
+
+import { ConfigurationDataService } from '../../../../../core/data/configuration-data.service';
+import { getFirstCompletedRemoteData } from '../../../../../core/shared/operators';
+
+@Component({
+ selector: 'ds-item-page-citation-field',
+ templateUrl: './item-page-citation.component.html',
+ imports: [
+ CommonModule,
+ ],
+})
+export class ItemPageCitationFieldComponent implements OnInit {
+ @Input() handle: string;
+
+ citaceProStatus$ = new BehaviorSubject(false);
+ citaceProURL$ = new BehaviorSubject(null);
+
+ constructor(
+ private sanitizer: DomSanitizer,
+ private configService: ConfigurationDataService,
+ ) {}
+
+
+ ngOnInit() {
+ const citaceProUrl$ = this.configService.findByPropertyName('citace.pro.url').pipe(
+ getFirstCompletedRemoteData(),
+ catchError(() => of(null)),
+ );
+ const universityUsingDspace$ = this.configService.findByPropertyName('citace.pro.university').pipe(
+ getFirstCompletedRemoteData(),
+ catchError(() => of(null)),
+ );
+ const citaceProAllowed$ = this.configService.findByPropertyName('citace.pro.allowed').pipe(
+ getFirstCompletedRemoteData(),
+ catchError(() => of(null)),
+ );
+
+ combineLatest([citaceProUrl$, universityUsingDspace$, citaceProAllowed$]).pipe(
+ take(1),
+ ).subscribe(([citaceProUrlData, universityData, citaceProAllowedData]) => {
+ const citaceProBaseUrl = citaceProUrlData?.payload?.values?.[0];
+ const universityUsingDspace = universityData?.payload?.values?.[0];
+ this.citaceProURL$.next(this.makeCitaceProURL(citaceProBaseUrl, universityUsingDspace));
+
+ const citaceProAllowed = citaceProAllowedData?.payload?.values?.[0];
+ this.citaceProStatus$.next(citaceProAllowed === 'true');
+ });
+ }
+
+
+ makeCitaceProURL(
+ citaceProBaseUrl: string,
+ universityUsingDspace: string,
+ ): SafeResourceUrl | null {
+ if (!citaceProBaseUrl || !universityUsingDspace || !this.handle) {
+ return null;
+ }
+ // Only http(s) bases may bypass Angular's resource-URL sanitization;
+ // anything else (javascript:, data:, ...) must not be trusted as iframe src.
+ if (!/^https?:\/\//i.test(citaceProBaseUrl)) {
+ return null;
+ }
+ const url = `${citaceProBaseUrl}:${universityUsingDspace}:${this.handle}`;
+ return this.sanitizer.bypassSecurityTrustResourceUrl(url);
+ }
+}
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html
index a294cc025bc..4ad2eb72584 100644
--- a/src/app/item-page/simple/item-types/publication/publication.component.html
+++ b/src/app/item-page/simple/item-types/publication/publication.component.html
@@ -70,6 +70,7 @@
[relationType]="'isJournalIssueOfPublication'"
[label]="'item.page.journal-issue' | translate">
+
{
{ provide: SearchService, useValue: {} },
{ provide: RouteService, useValue: mockRouteService },
{ provide: BrowseDefinitionDataService, useValue: BrowseDefinitionDataServiceStub },
+ { provide: ConfigurationDataService, useValue: new ConfigurationDataServiceStub() },
{ provide: APP_CONFIG, useValue: environment },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
],
diff --git a/src/app/item-page/simple/item-types/publication/publication.component.ts b/src/app/item-page/simple/item-types/publication/publication.component.ts
index 282198fe7de..85a82936190 100644
--- a/src/app/item-page/simple/item-types/publication/publication.component.ts
+++ b/src/app/item-page/simple/item-types/publication/publication.component.ts
@@ -17,6 +17,7 @@ import { ThemedMediaViewerComponent } from '../../../media-viewer/themed-media-v
import { MiradorViewerComponent } from '../../../mirador-viewer/mirador-viewer.component';
import { ThemedFileSectionComponent } from '../../field-components/file-section/themed-file-section.component';
import { ItemPageAbstractFieldComponent } from '../../field-components/specific-field/abstract/item-page-abstract-field.component';
+import { ItemPageCitationFieldComponent } from '../../field-components/specific-field/citation/item-page-citation.component';
import { ItemPageDateFieldComponent } from '../../field-components/specific-field/date/item-page-date-field.component';
import { GenericItemPageFieldComponent } from '../../field-components/specific-field/generic/generic-item-page-field.component';
import { GeospatialItemPageFieldComponent } from '../../field-components/specific-field/geospatial/geospatial-item-page-field.component';
@@ -43,6 +44,7 @@ import { ItemComponent } from '../shared/item.component';
GenericItemPageFieldComponent,
GeospatialItemPageFieldComponent,
ItemPageAbstractFieldComponent,
+ ItemPageCitationFieldComponent,
ItemPageDateFieldComponent,
ItemPageUriFieldComponent,
MetadataFieldWrapperComponent,
diff --git a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html
index 52ae5556dce..4d5ba91b89a 100644
--- a/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html
+++ b/src/app/item-page/simple/item-types/untyped-item/untyped-item.component.html
@@ -56,6 +56,7 @@
+