diff --git a/cypress/e2e/audit-overview-page.cy.ts b/cypress/e2e/audit-overview-page.cy.ts new file mode 100644 index 00000000000..1e884182c50 --- /dev/null +++ b/cypress/e2e/audit-overview-page.cy.ts @@ -0,0 +1,29 @@ +import { testA11y } from 'cypress/support/utils'; + +describe('Audit Overview Page', () => { + beforeEach(() => { + // Must login as an Admin to see the page + cy.visit('/auditlogs'); + cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD')); + }); + + it('page structure should be correct and should pass accessibility tests', () => { + // Page must first be visible + cy.get('ds-audit-overview').should('be.visible'); + // Check for presence of main container and title + cy.get('.container').should('exist'); + cy.get('[data-test="audit-title"]').should('be.visible'); + cy.get('body').then($body => { + const hasTable = $body.find('[data-test="audit-table"]').length > 0; + const hasEmpty = $body.find('[data-test="audit-empty"]').length > 0; + // At least one present and not both + expect(hasTable || hasEmpty).to.equal(true); + expect(!(hasTable && hasEmpty)).to.equal(true); + }); + // Analyze for accessibility issues + testA11y('ds-audit-overview'); + }); + + + +}); diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 0d20578aa0a..ea943f2f1cc 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -265,6 +265,11 @@ export const APP_ROUTES: Route[] = [ loadChildren: () => import('./access-control/access-control-routes').then((m) => m.ROUTES), canActivate: [groupAdministratorGuard, endUserAgreementCurrentUserGuard], }, + { + path: 'auditlogs', + loadChildren: () => import('./audit-page/audit-page-routes').then((m) => m.ROUTES), + canActivate: [siteAdministratorGuard, endUserAgreementCurrentUserGuard], + }, { path: 'subscriptions', loadChildren: () => import('./subscriptions-page/subscriptions-page-routes') diff --git a/src/app/app.menus.ts b/src/app/app.menus.ts index e63b948c2f7..e230b039719 100644 --- a/src/app/app.menus.ts +++ b/src/app/app.menus.ts @@ -10,6 +10,8 @@ import { MenuID } from './shared/menu/menu-id.model'; import { MenuRoute } from './shared/menu/menu-route.model'; import { AccessControlMenuProvider } from './shared/menu/providers/access-control.menu'; import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.menu'; +import { AuditLogsMenuProvider } from './shared/menu/providers/audit-item.menu'; +import { AuditOverviewMenuProvider } from './shared/menu/providers/audit-overview.menu'; import { BrowseMenuProvider } from './shared/menu/providers/browse.menu'; import { CoarNotifyMenuProvider } from './shared/menu/providers/coar-notify.menu'; import { SubscribeMenuProvider } from './shared/menu/providers/comcol-subscribe.menu'; @@ -72,6 +74,7 @@ export const MENUS = buildMenuStructure({ HealthMenuProvider, SystemWideAlertMenuProvider, CoarNotifyMenuProvider, + AuditOverviewMenuProvider, ], [MenuID.DSO_EDIT]: [ DsoOptionMenuProvider.withSubs([ @@ -90,6 +93,11 @@ export const MENUS = buildMenuStructure({ VersioningMenuProvider.onRoute( MenuRoute.ITEM_PAGE, ), + AuditLogsMenuProvider.onRoute( + MenuRoute.COMMUNITY_PAGE, + MenuRoute.COLLECTION_PAGE, + MenuRoute.ITEM_PAGE, + ), OrcidMenuProvider.onRoute( MenuRoute.ITEM_PAGE, ), diff --git a/src/app/audit-page/audit-page-routes.ts b/src/app/audit-page/audit-page-routes.ts new file mode 100644 index 00000000000..541aa78cc55 --- /dev/null +++ b/src/app/audit-page/audit-page-routes.ts @@ -0,0 +1,21 @@ +import { Route } from '@angular/router'; + +import { authenticatedGuard } from '../core/auth/authenticated.guard'; +import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { AuditOverviewComponent } from './overview/audit-overview.component'; + +export const ROUTES: Route[] = [ + { + path: '', + canActivate: [authenticatedGuard], + children: [ + { + path: '', + component: AuditOverviewComponent, + data: { title: 'audit.overview.title', breadcrumbKey: 'audit.overview' }, + resolve: { breadcrumb: i18nBreadcrumbResolver }, + }, + ], + }, + +]; diff --git a/src/app/audit-page/audit-table/audit-table.component.html b/src/app/audit-page/audit-table/audit-table.component.html new file mode 100644 index 00000000000..cf9cd0d06e1 --- /dev/null +++ b/src/app/audit-page/audit-table/audit-table.component.html @@ -0,0 +1,157 @@ +@if (audits.totalElements === 0) { +
{{ 'audit.data.not-found' | translate }}
+} @else { + +
+ + + + + + + @if (isOverviewPage) { + + + + + } @else { + + } + + + + @for (audit of audits?.page; track audit) { + + + + + @if (isOverviewPage) { + + + + + } @else { + + } + + @if (audit.hasDetails) { + + @if (isOverviewPage) { + + } @else { + + } + + } + } + +
{{ 'audit.overview.table.entityType' | translate }}{{ 'audit.overview.table.eperson' | translate }}{{ 'audit.overview.table.timestamp' | translate }}{{ 'audit.overview.table.subjectUUID' | translate }}{{ 'audit.overview.table.subjectType' | translate }}{{ 'audit.overview.table.objectUUID' | translate }}{{ 'audit.overview.table.objectType' | translate }}{{ 'audit.overview.table.other' | translate }}
+ @if (audit.hasDetails) { +
+ +
+ {{ audit.eventType }} +
+
+ } @else { +
+ {{ audit.eventType }} +
+ } +
{{ audit.epersonName }}{{ audit.timeStamp | date:dateFormat:'UTC' }} + @if (audit.objectUUID) { + + @if (objectRoute !== ('/' + auditPath)) { + {{audit.objectUUID}} + } @else { + {{audit.objectUUID}} + } + + } + {{ audit.objectType }} + @if (audit.subjectUUID) { + + @if (subjectRoute !== ('/' + auditPath)) { + {{audit.subjectUUID}} + } @else { + {{audit.subjectUUID}} + } + + } + {{ audit.subjectType }} + + @if (audit.otherAuditObject; as dso) { + {{ getDsoName(dso) }} ({{ dso.type }}) + } @else { + {{ dataNotAvailable }} + } + +
+ + + +
+
+
+} + + +
+
+ @if (audit.metadataField) { +
+ {{"audit.detail.metadata.field" | translate}} + {{ audit.metadataField | dsStringReplace: "_":"." }} +
+ } + @if (audit.value) { +
+ {{"audit.detail.metadata.value" | translate}} + + {{ audit.value }} + +
+ } + @if (audit.authority) { +
+ {{"audit.detail.metadata.authority" | translate}} + {{ audit.authority }} +
+ } + @if (audit.confidence !== null) { +
+ {{"audit.detail.metadata.confidence" | translate}} + {{ audit.confidence }} +
+ } + @if (audit.place !== null) { +
+ {{"audit.detail.metadata.place" | translate}} + {{ audit.place }} +
+ } + @if (audit.action) { +
+ {{"audit.detail.metadata.action" | translate}} + {{ audit.action }} +
+ } + @if (audit.checksum) { +
+ {{"audit.detail.metadata.checksum" | translate}} + {{ audit.checksum }} +
+ } +
+
+
diff --git a/src/app/audit-page/audit-table/audit-table.component.spec.ts b/src/app/audit-page/audit-table/audit-table.component.spec.ts new file mode 100644 index 00000000000..04515769ec8 --- /dev/null +++ b/src/app/audit-page/audit-table/audit-table.component.spec.ts @@ -0,0 +1,103 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Audit } from '@dspace/core/audit/model/audit.model'; +import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '@dspace/core/data/dspace-object-data.service'; +import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { AuditMock } from '@dspace/core/testing/audit.mock'; +import { DSONameServiceMock } from '@dspace/core/testing/dso-name.service.mock'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { PaginationComponent } from 'src/app/shared/pagination/pagination.component'; + +import { AuditTableComponent } from './audit-table.component'; + +describe('AuditTableComponent', () => { + let component: AuditTableComponent; + let fixture: ComponentFixture; + + let audits = new PaginatedList() as PaginatedList; + const dSpaceObjectDataService = jasmine.createSpyObj('DSpaceObjectDataService', { findById: createSuccessfulRemoteDataObject$(new DSpaceObject()) }); + + + beforeEach(waitForAsync(() => { + audits.page = [ AuditMock ]; + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + AuditTableComponent, + PaginationComponent, + ], + providers: [ + { provide: DSONameService, useValue: new DSONameServiceMock() }, + { provide: DSpaceObjectDataService, useValue: dSpaceObjectDataService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AuditTableComponent, { + remove: { imports: [PaginationComponent] }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuditTableComponent); + component = fixture.componentInstance; + component.audits = audits; + component.isOverviewPage = true; + fixture.detectChanges(); + }); + + describe('table structure', () => { + + it('should display the entityType in the first column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(1)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].eventType); + }); + + it('should display the eperson in the second column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(2)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].epersonName); + }); + + it('should display the timestamp in the third column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(3)')).nativeElement; + expect(el.textContent).toContain('2020-11-13 10:41:06'); + }); + + it('should display the objectUUID in the fourth column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(4)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].objectUUID); + }); + + it('should display the objectType in the fifth column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(5)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].objectType); + }); + + it('should display the subjectUUID in the sixth column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(6)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].subjectUUID); + }); + + it('should display the subjectType in the seventh column', () => { + const rowElements = fixture.debugElement.queryAll(By.css('tbody tr')); + const el = rowElements[0].query(By.css('td:nth-child(7)')).nativeElement; + expect(el.textContent).toContain(audits.page[0].subjectType); + }); + }); +}); diff --git a/src/app/audit-page/audit-table/audit-table.component.ts b/src/app/audit-page/audit-table/audit-table.component.ts new file mode 100644 index 00000000000..aae807212a7 --- /dev/null +++ b/src/app/audit-page/audit-table/audit-table.component.ts @@ -0,0 +1,109 @@ +import { + AsyncPipe, + DatePipe, + NgClass, + NgTemplateOutlet, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Input, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { AUDIT_PERSON_NOT_AVAILABLE } from '@dspace/core/data/audit-data.service'; +import { DSpaceObjectDataService } from '@dspace/core/data/dspace-object-data.service'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { getDSORoute } from '@dspace/core/router/utils/dso-route.utils'; +import { getFirstSucceededRemoteDataPayload } from '@dspace/core/shared/operators'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Audit } from '../../core/audit/model/audit.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { PaginationComponent } from '../../shared/pagination/pagination.component'; +import { StringReplacePipe } from '../../shared/utils/string-replace.pipe'; +import { VarDirective } from '../../shared/utils/var.directive'; + +/** + * Renders a paginated table of audit records, either in overview mode for all the environemnt or tied to a specific DSpaceObject. + * Supports row expansion to show details. + */ + +@Component({ + selector: 'ds-audit-table', + templateUrl: './audit-table.component.html', + imports: [ + AsyncPipe, + DatePipe, + NgbCollapseModule, + NgClass, + NgTemplateOutlet, + PaginationComponent, + RouterLink, + StringReplacePipe, + TranslateModule, + VarDirective, + ], + standalone: true, +}) +export class AuditTableComponent { + /** + * The Audit items to be shown + */ + @Input() audits: PaginatedList; + + /** + * Config for pagination + */ + @Input() pageConfig: PaginationComponentOptions; + + /** + * Whether the table is used for a an overview of all the site's Audits + */ + @Input() isOverviewPage: boolean; + + /** + * The DSpaceObject used in case of a detail audit page + */ + @Input() object: DSpaceObject; + + /** + * Path for audit logs + */ + readonly auditPath = 'auditlogs'; + + protected readonly dataNotAvailable = AUDIT_PERSON_NOT_AVAILABLE; + + /** + * Date format to use for start and end time of audits + */ + protected readonly dateFormat = 'yyyy-MM-dd HH:mm:ss'; + + constructor( + private dsoNameService: DSONameService, + private changeDetectorRef: ChangeDetectorRef, + private dsoDataService: DSpaceObjectDataService, + ) {} + + + toggleCollapse(audit: Audit) { + audit.isCollapsed = !audit.isCollapsed; + this.changeDetectorRef.detectChanges(); + } + + getObjectRoute$(id: string): Observable { + return this.dsoDataService.findById(id).pipe( + getFirstSucceededRemoteDataPayload(), + map(resolvedDso => new URLCombiner(getDSORoute(resolvedDso), this.auditPath).toString()), + ); + } + + getDsoName(dso: DSpaceObject): string { + return this.dsoNameService.getName(dso); + } +} diff --git a/src/app/audit-page/object-audit-overview/object-audit-logs.component.html b/src/app/audit-page/object-audit-overview/object-audit-logs.component.html new file mode 100644 index 00000000000..1d29477a884 --- /dev/null +++ b/src/app/audit-page/object-audit-overview/object-audit-logs.component.html @@ -0,0 +1,30 @@ +
+
+

{{'audit.object.overview.title' | translate}}

+
+ + @if (objectId$ | async) { +
+ + {{ 'audit.object.logs.label' | translate}} + {{objectName}} + +
+ @if ((auditsRD$ | async)?.payload; as audits) { + + } @else { +
{{ 'audit.data.not-found' | translate }}
+ } + + @if ((auditsRD$ | async)?.statusCode === 404) { +

{{'audit.object.overview.disabled.message' | translate}}

+ } + } + +
diff --git a/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts b/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts new file mode 100644 index 00000000000..9171be3e02a --- /dev/null +++ b/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts @@ -0,0 +1,124 @@ +import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, + RouterLink, +} from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Audit } from '@dspace/core/audit/model/audit.model'; +import { AuditDataService } from '@dspace/core/data/audit-data.service'; +import { CollectionDataService } from '@dspace/core/data/collection-data.service'; +import { DSpaceObjectDataService } from '@dspace/core/data/dspace-object-data.service'; +import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { MockActivatedRoute } from '@dspace/core/testing/active-router.mock'; +import { AuditMock } from '@dspace/core/testing/audit.mock'; +import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; +import { RouterMock } from '@dspace/core/testing/router.mock'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { AuditTableComponent } from '../audit-table/audit-table.component'; +import { ObjectAuditLogsComponent } from './object-audit-logs.component'; + +describe('ObjectAuditLogsComponent', () => { + let component: ObjectAuditLogsComponent; + let fixture: ComponentFixture; + + let auditService: AuditDataService; + let audits: Audit[]; + let dSpaceObjectDataService: DSpaceObjectDataService; + let collectionService; + let activatedRoute; + let locationStub: Location; + const mockItem = new Item(); + const mockItemId = '1234'; + mockItem.id = mockItemId; + + function init() { + audits = [ AuditMock ]; + auditService = jasmine.createSpyObj('auditService', { + findByObject: createSuccessfulRemoteDataObject$(createPaginatedList(audits)), + getEpersonName: of('Eperson Name'), + auditHasDetails: false, + getOtherObject: of(new Audit()), + }); + dSpaceObjectDataService = jasmine.createSpyObj('DSpaceObjectDataService', { findById: createSuccessfulRemoteDataObject$(mockItem) }); + collectionService = jasmine.createSpyObj('CollectionDataService', + { findOwningCollectionFor: createSuccessfulRemoteDataObject$(createPaginatedList([{ id : 'collectionId' }])) }, + ); + activatedRoute = new MockActivatedRoute({ objectId: mockItemId }); + activatedRoute.paramMap = of({ + get: () => mockItemId, + }); + locationStub = jasmine.createSpyObj('location', { + back: jasmine.createSpy('back'), + }); + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + AuditTableComponent, + ObjectAuditLogsComponent, + RouterLink, + ], + providers: [ + { provide: AuditDataService, useValue: auditService }, + { provide: PaginationService, useValue: new PaginationServiceStub() }, + { provide: DSpaceObjectDataService, useValue: dSpaceObjectDataService }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: new RouterMock() }, + { provide: CollectionDataService, useValue: collectionService }, + { provide: APP_DATA_SERVICES_MAP, useValue: new Map() }, + { provide: Location, useValue: locationStub }, + provideMockStore({}), + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(ObjectAuditLogsComponent, { + remove: { + imports: [AuditTableComponent], + }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ObjectAuditLogsComponent); + component = fixture.componentInstance; + spyOn(component, 'setAudits').and.callThrough(); + fixture.detectChanges(); + }); + + describe('object detail data setting', () => { + it('should set audits on init', fakeAsync(() => { + tick(); + fixture.detectChanges(); + expect(component.setAudits).toHaveBeenCalled(); + })); + + it('should set object id', (done) => { + component.objectId$.subscribe((id) => { + expect(id).toEqual(mockItemId); + expect(component.objectId).toEqual(id); + done(); + }); + }); + }); +}); diff --git a/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts b/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts new file mode 100644 index 00000000000..f03ebc1613e --- /dev/null +++ b/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts @@ -0,0 +1,183 @@ +import { + AsyncPipe, + Location, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + ActivatedRoute, + ParamMap, + Router, + RouterLink, +} from '@angular/router'; +import { + AUDIT_PERSON_NOT_AVAILABLE, + AuditDataService, +} from '@dspace/core/data/audit-data.service'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { getDSORoute } from '@dspace/core/router/utils/dso-route.utils'; +import { TranslateModule } from '@ngx-translate/core'; +import { + forkJoin, + Observable, +} from 'rxjs'; +import { + filter, + map, + mergeMap, + switchMap, + tap, +} from 'rxjs/operators'; + +import { Audit } from '../../core/audit/model/audit.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { CollectionDataService } from '../../core/data/collection-data.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { + getFirstCompletedRemoteData, + getFirstSucceededRemoteDataPayload, +} from '../../core/shared/operators'; +import { AuditTableComponent } from '../audit-table/audit-table.component'; +/** + * Component displaying a list of all audit about a object in a paginated table + */ +@Component({ + selector: 'ds-object-audit-logs', + templateUrl: './object-audit-logs.component.html', + imports: [ + AsyncPipe, + AuditTableComponent, + RouterLink, + TranslateModule, + ], + standalone: true, +}) +export class ObjectAuditLogsComponent implements OnInit { + + /** + * The object extracted from the route. + */ + object: DSpaceObject; + + /** + * List of all audits + */ + auditsRD$: Observable>>; + + /** + * The current pagination configuration for the page used by the FindAll method + */ + config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 10, + sort: { + field: 'timeStamp', + direction: SortDirection.DESC, + }, + }); + + /** + * The current pagination configuration for the page + */ + pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'oop', + pageSize: 10, + }); + + /** + * Date format to use for start and end time of audits + */ + dateFormat = 'yyyy-MM-dd HH:mm:ss'; + + objectId$: Observable; + + objectId: string; + + objectName: string; + + objectRoute: string; + + dataNotAvailable = AUDIT_PERSON_NOT_AVAILABLE; + + constructor(protected route: ActivatedRoute, + protected router: Router, + protected auditService: AuditDataService, + protected paginationService: PaginationService, + protected collectionDataService: CollectionDataService, + protected dsoNameService: DSONameService, + protected dSpaceObjectDataService: DSpaceObjectDataService, + protected location: Location, + ) {} + + ngOnInit(): void { + this.objectId$ = this.route.paramMap.pipe( + map((paramMap: ParamMap) => paramMap.get('id')), + switchMap((id: string) => this.dSpaceObjectDataService.findById(id, true, true)), + getFirstSucceededRemoteDataPayload(), + tap((object) => { + this.objectRoute = getDSORoute(object); + this.objectId = object.id; + this.objectName = this.dsoNameService.getName(object); + this.setAudits(); + }), + map(dso => dso.id), + ); + } + + /** + * Send a request to fetch all audits for the current page + */ + setAudits() { + const config$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config); + + this.auditsRD$ = config$.pipe( + switchMap((config) => + this.auditService.findByObject(this.objectId, config, false).pipe( + getFirstCompletedRemoteData(), + ), + ), + filter(data => data && data?.payload?.page?.length > 0), + map((audits) => { + audits.payload?.page.forEach((audit) => { + audit.hasDetails = this.auditService.auditHasDetails(audit); + }); + + return audits; + }), + mergeMap(auditsRD => { + const updatedAudits$ = auditsRD.payload.page.map(audit => { + return forkJoin({ + epersonName: this.auditService.getEpersonName(audit), + otherAuditObject: this.auditService.getOtherObject(audit, this.objectId), + }).pipe( + map(({ epersonName, otherAuditObject }) => + Object.assign(new Audit(), audit, { epersonName, otherAuditObject }), + ), + ); + }); + return forkJoin(updatedAudits$).pipe( + map(updatedAudits => Object.assign(new RemoteData( + auditsRD.timeCompleted, + auditsRD.msToLive, + auditsRD.lastUpdated, + auditsRD.state, + auditsRD.errorMessage, + Object.assign(new PaginatedList(), { ...auditsRD.payload, page: updatedAudits }), + auditsRD.statusCode, + ))), + ); + }), + ); + } + + goBack(): void { + this.location.back(); + } +} diff --git a/src/app/audit-page/overview/audit-overview.component.html b/src/app/audit-page/overview/audit-overview.component.html new file mode 100644 index 00000000000..a5290ad6a2b --- /dev/null +++ b/src/app/audit-page/overview/audit-overview.component.html @@ -0,0 +1,17 @@ +
+
+

{{'audit.overview.title' | translate}}

+
+ + @if ((auditsRD$ | async)?.payload; as audits) { + + } @else { +
{{ 'audit.data.not-found' | translate }}
+ } + +
diff --git a/src/app/audit-page/overview/audit-overview.component.spec.ts b/src/app/audit-page/overview/audit-overview.component.spec.ts new file mode 100644 index 00000000000..d1b6bdfced5 --- /dev/null +++ b/src/app/audit-page/overview/audit-overview.component.spec.ts @@ -0,0 +1,76 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Audit } from '@dspace/core/audit/model/audit.model'; +import { AuditDataService } from '@dspace/core/data/audit-data.service'; +import { PaginationService } from '@dspace/core/pagination/pagination.service'; +import { AuditMock } from '@dspace/core/testing/audit.mock'; +import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; +import { createPaginatedList } from '@dspace/core/testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { AuditTableComponent } from '../audit-table/audit-table.component'; +import { AuditOverviewComponent } from './audit-overview.component'; + +describe('AuditOverviewComponent', () => { + let component: AuditOverviewComponent; + let fixture: ComponentFixture; + + let auditService: AuditDataService; + let audits: Audit[]; + const paginationService = new PaginationServiceStub(); + + function init() { + audits = [ AuditMock ]; + auditService = jasmine.createSpyObj('auditService', { + findAll: createSuccessfulRemoteDataObject$(createPaginatedList(audits)), + getEpersonName: of('Eperson Name'), + auditHasDetails: false, + }); + } + + beforeEach(waitForAsync(() => { + init(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), AuditTableComponent, AuditOverviewComponent], + providers: [ + { provide: AuditDataService, useValue: auditService }, + { provide: PaginationService, useValue: paginationService }, + provideMockStore({}), + ], + schemas: [NO_ERRORS_SCHEMA], + }) + .overrideComponent(AuditOverviewComponent, { + remove: { + imports: [AuditTableComponent], + }, + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AuditOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('audit page overview data settings', () => { + it('should set audits on init', (done) => { + component.auditsRD$.subscribe(auditsRD => { + expect(auditsRD).toBeTruthy(); + expect(auditsRD.payload.page.length).toBe(1); + const audit = auditsRD.payload.page[0]; + expect(audit.epersonName).toEqual('Eperson Name'); + expect(audit.hasDetails).toBeFalsy(); + done(); + }); + }); + }); +}); diff --git a/src/app/audit-page/overview/audit-overview.component.ts b/src/app/audit-page/overview/audit-overview.component.ts new file mode 100644 index 00000000000..b400bd258fa --- /dev/null +++ b/src/app/audit-page/overview/audit-overview.component.ts @@ -0,0 +1,124 @@ +import { AsyncPipe } from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { AuditDataService } from '@dspace/core/data/audit-data.service'; +import { PaginationComponentOptions } from '@dspace/core/pagination/pagination-component-options.model'; +import { followLink } from '@dspace/core/shared/follow-link-config.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { + forkJoin, + Observable, + switchMap, +} from 'rxjs'; +import { + filter, + map, + mergeMap, +} from 'rxjs/operators'; + +import { Audit } from '../../core/audit/model/audit.model'; +import { SortDirection } from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/find-list-options.model'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginationService } from '../../core/pagination/pagination.service'; +import { AuditTableComponent } from '../audit-table/audit-table.component'; + +/** + * Component displaying a list of all audit in a paginated table + */ +@Component({ + selector: 'ds-audit-overview', + templateUrl: './audit-overview.component.html', + imports: [ + AsyncPipe, + AuditTableComponent, + TranslateModule, + ], + standalone: true, +}) +export class AuditOverviewComponent implements OnInit { + + /** + * List of all audits + */ + auditsRD$: Observable>>; + + /** + * The current pagination configuration for the page used by the FindAll method + */ + config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 10, + sort: { + field: 'timeStamp', + direction: SortDirection.DESC, + }, + }); + + /** + * The pagination id + */ + pageId = 'aop'; + + /** + * The current pagination configuration for the page + */ + pageConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: this.pageId, + pageSize: 10, + }); + + /** + * Date format to use for start and end time of audits + */ + dateFormat = 'yyyy-MM-dd HH:mm:ss'; + + constructor(protected auditService: AuditDataService, + protected paginationService: PaginationService) { + } + + ngOnInit(): void { + this.setAudits(); + } + + /** + * Send a request to fetch all audits for the current page + */ + setAudits() { + this.auditsRD$ = this.paginationService.getFindListOptions(this.pageId, this.config).pipe( + switchMap((config) => { + return this.auditService.findAll(config, false, true, followLink('eperson')); + }), + filter(data => data && data?.payload?.page?.length > 0), + map((audits) => { + audits.payload?.page.forEach((audit) => { + audit.hasDetails = this.auditService.auditHasDetails(audit); + }); + + return audits; + }), + mergeMap(auditsRD => { + const updatedAudits$ = auditsRD.payload.page.map(audit => { + return this.auditService.getEpersonName(audit).pipe( + map(name => Object.assign(new Audit(), audit, { epersonName: name })), + ); + }); + + return forkJoin(updatedAudits$).pipe( + map(updatedAudits => Object.assign(new RemoteData( + auditsRD.timeCompleted, + auditsRD.msToLive, + auditsRD.lastUpdated, + auditsRD.state, + auditsRD.errorMessage, + Object.assign(new PaginatedList(), { ...auditsRD.payload, page: updatedAudits }), + auditsRD.statusCode, + ))), + ); + }), + ); + } + +} diff --git a/src/app/collection-page/collection-page-routes.ts b/src/app/collection-page/collection-page-routes.ts index 2dd6f5efe8b..217b222fb83 100644 --- a/src/app/collection-page/collection-page-routes.ts +++ b/src/app/collection-page/collection-page-routes.ts @@ -4,6 +4,7 @@ import { collectionBreadcrumbResolver } from '@dspace/core/breadcrumbs/collectio import { communityBreadcrumbResolver } from '@dspace/core/breadcrumbs/community-breadcrumb.resolver'; import { i18nBreadcrumbResolver } from '@dspace/core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ObjectAuditLogsComponent } from '../audit-page/object-audit-overview/object-audit-logs.component'; import { browseByGuard } from '../browse-by/browse-by-guard'; import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; @@ -62,6 +63,14 @@ export const ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [collectionPageAdministratorGuard], }, + { + path: 'auditlogs', + component: ObjectAuditLogsComponent, + data: { title: 'audit.object.title', breadcrumbKey: 'audit.object' }, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + }, { path: 'delete', pathMatch: 'full', diff --git a/src/app/community-page/community-page-routes.ts b/src/app/community-page/community-page-routes.ts index 791bfa6f9b0..d944194ed72 100644 --- a/src/app/community-page/community-page-routes.ts +++ b/src/app/community-page/community-page-routes.ts @@ -3,6 +3,7 @@ import { authenticatedGuard } from '@dspace/core/auth/authenticated.guard'; import { communityBreadcrumbResolver } from '@dspace/core/breadcrumbs/community-breadcrumb.resolver'; import { i18nBreadcrumbResolver } from '@dspace/core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ObjectAuditLogsComponent } from '../audit-page/object-audit-overview/object-audit-logs.component'; import { browseByGuard } from '../browse-by/browse-by-guard'; import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver'; import { ComcolBrowseByComponent } from '../shared/comcol/sections/comcol-browse-by/comcol-browse-by.component'; @@ -65,6 +66,14 @@ export const ROUTES: Route[] = [ component: DeleteCommunityPageComponent, canActivate: [authenticatedGuard], }, + { + path: 'auditlogs', + component: ObjectAuditLogsComponent, + data: { title: 'audit.object.title', breadcrumbKey: 'audit.object' }, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + }, { path: '', component: ThemedCommunityPageComponent, diff --git a/src/app/core/audit/model/audit.model.ts b/src/app/core/audit/model/audit.model.ts new file mode 100644 index 00000000000..9fa1c6a9e9a --- /dev/null +++ b/src/app/core/audit/model/audit.model.ts @@ -0,0 +1,185 @@ +import { + autoserialize, + deserialize, +} from 'cerialize'; +import { Observable } from 'rxjs'; + +import { + link, + typedObject, +} from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { RemoteData } from '../../data/remote-data'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ITEM } from '../../shared/item.resource-type'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { AUDIT } from './audit.resource-type'; + +/** + * Object representing an Audit. + */ +@typedObject +export class Audit implements CacheableObject { + static type = AUDIT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The identifier for this audit + */ + @autoserialize + id: string; + + /** + * The eperson UUID for this audit + */ + @autoserialize + epersonUUID: string; + + /** + * The subject UUID for this audit + */ + @autoserialize + subjectUUID: string; + + /** + * The subject type for this audit + */ + @autoserialize + subjectType: string; + + /** + * The object UUID for this audit + */ + @autoserialize + objectUUID: string; + + /** + * The object type for this audit + */ + @autoserialize + objectType: string; + + /** + * The detail for this audit + */ + @autoserialize + detail: string; + + /** + * The eventType for this audit + */ + @autoserialize + eventType: string; + + /** + * The timestamp for this audit + */ + @autoserialize + timeStamp: string; + + /** + * The audited metadata + */ + @autoserialize + metadataField: string; + + /** + * The audited value + */ + @autoserialize + value: string; + + /** + * The related authority + */ + @autoserialize + authority: string; + + /** + * The confidence of the audit + */ + @autoserialize + confidence: number; + + /** + * The place of the audit + */ + @autoserialize + place: number; + + /** + * The action type of the audit + */ + @autoserialize + action: string; + + /** + * The checksum of the audit + */ + @autoserialize + checksum: string; + + /** + * Property to expand details section + */ + @autoserialize + isCollapsed = true; + + /** + * Property to check if audit has details + */ + @autoserialize + hasDetails: boolean; + + /** + * The {@link HALLink}s for this Audit + */ + @deserialize + _links: { + self: HALLink; + eperson: HALLink; + subject: HALLink; + object: HALLink; + }; + + /** + * The EPerson for this audit + * Will be undefined unless the eperson {@link HALLink} has been resolved. + */ + @link(EPERSON, false) + eperson?: Observable>; + + /** + * The Subject for this audit + * Will be undefined unless the subject {@link HALLink} has been resolved. + */ + @link(ITEM) + subject?: Observable>; + + /** + * The Object for this audit + * Will be undefined unless the object {@link HALLink} has been resolved. + */ + @link(ITEM) + object?: Observable>; + + /** + * The name of the person who performed the action + */ + epersonName?: string; + + /** + * A different object connected to the current audited object + */ + otherAuditObject?: DSpaceObject; +} diff --git a/src/app/core/audit/model/audit.resource-type.ts b/src/app/core/audit/model/audit.resource-type.ts new file mode 100644 index 00000000000..27fb6378891 --- /dev/null +++ b/src/app/core/audit/model/audit.resource-type.ts @@ -0,0 +1,9 @@ +/** + * The resource type for Process + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +import { ResourceType } from '../../shared/resource-type'; + +export const AUDIT = new ResourceType('auditevent'); diff --git a/src/app/core/data-services-map.ts b/src/app/core/data-services-map.ts index f4ba7a1fe9b..77f39e080dd 100644 --- a/src/app/core/data-services-map.ts +++ b/src/app/core/data-services-map.ts @@ -1,3 +1,4 @@ +import { AUDIT } from './audit/model/audit.resource-type'; import { LDN_SERVICE, LDN_SERVICE_CONSTRAINT_FILTERS, @@ -136,4 +137,5 @@ export const LAZY_DATA_SERVICES: LazyDataServicesMap = new Map([ [SUGGESTION_TARGET.value, () => import('./notifications/suggestions/target/suggestion-target-data.service').then(m => m.SuggestionTargetDataService)], [DUPLICATE.value, () => import('./submission/submission-duplicate-data.service').then(m => m.SubmissionDuplicateDataService)], [CorrectionType.type.value, () => import('./submission/correctiontype-data.service').then(m => m.CorrectionTypeDataService)], + [AUDIT.value, () => import('./data/audit-data.service').then(m => m.AuditDataService)], ]); diff --git a/src/app/core/data/audit-data.service.spec.ts b/src/app/core/data/audit-data.service.spec.ts new file mode 100644 index 00000000000..485bb818fd5 --- /dev/null +++ b/src/app/core/data/audit-data.service.spec.ts @@ -0,0 +1,220 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + Store, + StoreModule, +} from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { RequestParam } from '../cache/models/request-param.model'; +import { CoreState } from '../core-state.model'; +import { followLink } from '../shared/follow-link-config.model'; +import { AuditMock } from '../testing/audit.mock'; +import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub'; +import { getMockRequestService } from '../testing/request.service.mock'; +import { TranslateLoaderMock } from '../testing/translate-loader.mock'; +import { + createPaginatedList, + createRequestEntry$, +} from '../testing/utils.test'; +import { createSuccessfulRemoteDataObject$ } from '../utilities/remote-data.utils'; +import { + AUDIT_FIND_BY_OBJECT_SEARCH_METHOD, + AuditDataService, +} from './audit-data.service'; +import { FindListOptions } from './find-list-options.model'; +import { RequestService } from './request.service'; + +describe('AuditDataService', () => { + let service: AuditDataService; + let store: Store; + let requestService: RequestService; + + let audit; + let audits; + + let restEndpointURL; + let auditsEndpoint; + let halService: any; + let paginatedAudits$; + let audit$; + + function initTestService() { + return new AuditDataService( + requestService, + null, + null, + halService, + null, + null, + ); + } + + function init() { + restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/system/auditevents'; + auditsEndpoint = `${restEndpointURL}/auditevents`; + audit = AuditMock; + audits = [AuditMock]; + audit$ = createSuccessfulRemoteDataObject$(audit); + paginatedAudits$ = createSuccessfulRemoteDataObject$(createPaginatedList(audits)); + halService = new HALEndpointServiceStub(restEndpointURL); + + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + providers: [ + provideMockStore(), + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + } + + beforeEach(() => { + init(); + requestService = getMockRequestService(createRequestEntry$(audits)); + store = TestBed.inject(Store); // Use TestBed.inject to get the mock store + service = initTestService(); + spyOn(store, 'dispatch'); + }); + + describe('findByObject', () => { + beforeEach(() => { + spyOn((service as any).searchData, 'searchBy').and.returnValue(paginatedAudits$); + }); + + it('should call searchBy with the objectId and follow eperson link', (done) => { + const objectId = 'objectId'; + const options = new FindListOptions(); + options.searchParams = [new RequestParam('object', objectId)]; + service.findByObject(objectId).subscribe((result) => { + expect(result.payload.page).toEqual(audits); + expect((service as any).searchData.searchBy).toHaveBeenCalledWith( + AUDIT_FIND_BY_OBJECT_SEARCH_METHOD, + options, + true, + true, + followLink('eperson'), + ); + done(); + }); + }); + }); + + + describe('findById', () => { + beforeEach(() => { + spyOn(service, 'findById').and.returnValue(audit$); + }); + + it('should call findById with id and linksToFollow', (done) => { + const linksToFollow: any = 'linksToFollow'; + service.findById(audit.id, true, true, linksToFollow).subscribe((result) => { + expect(result.payload).toEqual(audit); + expect(service.findById).toHaveBeenCalledWith(audit.id, true, true, linksToFollow); + done(); + }); + }); + }); + + describe('findAll', () => { + beforeEach(() => { + spyOn((service as any).findAllData, 'findAll').and.returnValue(paginatedAudits$); + }); + + it('should call findAll with with paginated options and followLinks', (done) => { + const linksToFollow: any = 'linksToFollow'; + const options = new FindListOptions(); + service.findAll(options, true, true, linksToFollow).subscribe((result) => { + expect(result.payload.page).toEqual(audits); + expect((service as any).findAllData.findAll).toHaveBeenCalledWith(options, true, true, linksToFollow); + done(); + }); + }); + }); + + describe('getOtherObject', () => { + beforeEach(() => { + spyOn(service, 'findByHref').and.returnValue(audit$); + }); + + it('should call findByHref it otherObjectHref exists', (done) => { + spyOn(service, 'getOtherObjectHref').and.returnValue('otherObjectHref'); + service.getOtherObject(audit, 'contextObjectId').subscribe((result) => { + expect(service.getOtherObjectHref).toHaveBeenCalledWith(audit, 'contextObjectId'); + expect(service.findByHref).toHaveBeenCalledWith('otherObjectHref'); + expect(result).toBe(audit); + done(); + }); + }); + + it('should return observable null if otherObjectHref not exists', (done) => { + spyOn(service, 'getOtherObjectHref').and.returnValue(null); + service.getOtherObject(audit, 'contextObjectId').subscribe((result) => { + expect(service.getOtherObjectHref).toHaveBeenCalledWith(audit, 'contextObjectId'); + expect(service.findByHref).not.toHaveBeenCalled(); + expect(result).toBe(null); + done(); + }); + }); + }); + + describe('getOtherObjectHref', () => { + + it('should return the proper other object href if exists', () => { + let otherObjectHref; + let testAudit; + const contextObject = 'contextObject'; + + // if audit.objectUUID has no value return null + testAudit = { + objectUUID: null, + }; + otherObjectHref = service.getOtherObjectHref(testAudit, contextObject); + expect(otherObjectHref).toBe(null); + + // if contextObject equals to audit.objectUUID return subjectHref + testAudit = { + objectUUID: 'contextObject', + subjectUUID: 'subjectUUID', + _links: { subject: { href: 'subjectHref' } }, + }; + otherObjectHref = service.getOtherObjectHref(testAudit, contextObject); + expect(otherObjectHref).toBe('subjectHref'); + + // if contextObject equals to audit.subjectUUID return objectHref + testAudit = { + objectUUID: 'objectUUID', + subjectUUID: 'contextObject', + _links: { object: { href: 'objectHref' } }, + }; + otherObjectHref = service.getOtherObjectHref(testAudit, contextObject); + expect(otherObjectHref).toBe('objectHref'); + + // if contextObject not equals to audit.subjectUUID and audit.objectUUID return null; + testAudit = { + objectUUID: 'objectUUID', + subjectUUID: 'subjectUUID', + _links: { subject: { href: 'subjectHref' } }, + }; + otherObjectHref = service.getOtherObjectHref(testAudit, contextObject); + expect(otherObjectHref).toBe(null); + + }); + }); + + +}); + diff --git a/src/app/core/data/audit-data.service.ts b/src/app/core/data/audit-data.service.ts new file mode 100644 index 00000000000..bebcbddcd6f --- /dev/null +++ b/src/app/core/data/audit-data.service.ts @@ -0,0 +1,154 @@ +import { Injectable } from '@angular/core'; +import { NotificationsService } from '@dspace/core/notification-system/notifications.service'; +import { + followLink, + FollowLinkConfig, +} from '@dspace/core/shared/follow-link-config.model'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + Observable, + of, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { Audit } from '../audit/model/audit.model'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { RequestParam } from '../cache/models/request-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { EPerson } from '../eperson/models/eperson.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { DeleteDataImpl } from './base/delete-data'; +import { + FindAllData, + FindAllDataImpl, +} from './base/find-all-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchDataImpl } from './base/search-data'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; + +export const AUDIT_PERSON_NOT_AVAILABLE = 'n/a'; + +export const AUDIT_FIND_BY_OBJECT_SEARCH_METHOD = 'findByObject'; + +@Injectable({ providedIn: 'root' }) +export class AuditDataService extends IdentifiableDataService{ + + private searchData: SearchDataImpl; + private findAllData: FindAllData; + private deleteData: DeleteDataImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected dsoNameService: DSONameService, + ) { + super('auditevents', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } + + /** + * Get all audit event for the object. + * + * @param objectId The objectId id + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable + * @return Observable>> + */ + findByObject(objectId: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true): Observable>> { + const searchMethod = AUDIT_FIND_BY_OBJECT_SEARCH_METHOD; + const searchParams = [new RequestParam('object', objectId)]; + + const optionsWithObject = Object.assign(new FindListOptions(), options, { + searchParams, + }); + return this.searchData.searchBy(searchMethod, optionsWithObject, useCachedVersionIfAvailable, true, followLink('eperson')); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return super.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + findAll(options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Get the name of an EPerson by ID + * @param audit The audit object + */ + getEpersonName(audit: Audit): Observable { + if (!audit.eperson) { + return of(AUDIT_PERSON_NOT_AVAILABLE); + } + + return audit.eperson.pipe( + getFirstCompletedRemoteData(), + map((epersonRd: RemoteData) => epersonRd.payload ? this.dsoNameService.getName(epersonRd.payload) : AUDIT_PERSON_NOT_AVAILABLE), + ); + } + + /** + * + * @param audit + * @param contextObjectId + */ + getOtherObject(audit: Audit, contextObjectId: string): Observable { + const otherObjectHref = this.getOtherObjectHref(audit, contextObjectId); + + if (otherObjectHref) { + return this.findByHref(otherObjectHref).pipe( + getFirstCompletedRemoteData(), + map(rd => rd.payload ?? null), + ); + } + return of(null); + } + + getOtherObjectHref(audit: Audit, contextObjectId: string): string { + if (audit.objectUUID === null) { + return null; + } + if (contextObjectId === audit.objectUUID) { + // other object is on the subject field + return audit._links.subject.href; + } else if (contextObjectId === audit.subjectUUID) { + // other object is on the object field + return audit._links.object.href; + } else { + return null; + } + } + + auditHasDetails(audit: Audit): boolean { + return hasValue(audit.metadataField) + || hasValue(audit.authority) + || hasValue(audit.confidence) + || hasValue(audit.checksum) + || hasValue(audit.authority) + || hasValue(audit.place) + || hasValue(audit.value); + } + +} diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 5d74591bc24..0054f086d6d 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -282,9 +282,12 @@ export class CollectionDataService extends ComColDataService { /** * Returns {@link RemoteData} of {@link Collection} that is the owning collection of the given item * @param item Item we want the owning collection of + * @param useCachedVersionIfAvailable + * @param reRequestOnStale + * @param linksToFollow */ - findOwningCollectionFor(item: Item): Observable> { - return this.findByHref(item._links.owningCollection.href); + findOwningCollectionFor(item: Item, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return this.findByHref(item._links.owningCollection.href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 5307709ad4f..29181e4a0e8 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -5,6 +5,7 @@ import { } from '@angular/core'; import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { Audit } from './audit/model/audit.model'; import { AuthStatus } from './auth/models/auth-status.model'; import { ShortLivedToken } from './auth/models/short-lived-token.model'; import { Itemfilter } from './coar-notify/ldn-services/models/ldn-service-itemfilters'; @@ -209,6 +210,7 @@ export const models = NotifyRequestsStatus, SystemWideAlert, AdminNotifyMessage, + Audit, SubmissionAccessModel, SubmissionDefinitionModel, SubmissionFormModel, diff --git a/src/app/core/testing/audit.mock.ts b/src/app/core/testing/audit.mock.ts new file mode 100644 index 00000000000..e3d99fa73a3 --- /dev/null +++ b/src/app/core/testing/audit.mock.ts @@ -0,0 +1,86 @@ +import { Audit } from '@dspace/core/audit/model/audit.model'; +import { EPerson } from '@dspace/core/eperson/models/eperson.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; + +export const AuditEPersonMock: EPerson = Object.assign(new EPerson(), { + handle: null, + groups: [], + netid: 'test@test.com', + lastActive: '2018-05-14T12:25:42.411+0000', + canLogIn: true, + email: 'test@test.com', + requireCertificate: false, + selfRegistered: false, + _links: { + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/4eebf0fa-cb9a-463e-8d4c-8a63122c7658', + }, + groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/4eebf0fa-cb9a-463e-8d4c-8a63122c7658/groups' }, + }, + id: '4eebf0fa-cb9a-463e-8d4c-8a63122c7658', + uuid: '4eebf0fa-cb9a-463e-8d4c-8a63122c7658', + type: 'eperson', + metadata: { + 'dc.title': [ + { + language: null, + value: 'User Test', + }, + ], + 'eperson.firstname': [ + { + language: null, + value: 'User', + }, + ], + 'eperson.lastname': [ + { + language: null, + value: 'Test', + }, + ], + 'eperson.language': [ + { + language: null, + value: 'en', + }, + ], + }, +}); + +export const AuditMock: Audit = Object.assign(new Audit(), { + detail: null, + epersonUUID: '4eebf0fa-cb9a-463e-8d4c-8a63122c7658', + eventType: 'MODIFY', + id: '6fcd7329-8439-4492-bb72-0a4240b52da8', + objectType: 'ITEM', + objectUUID: 'objectUUID', + object: createSuccessfulRemoteDataObject$(new DSpaceObject()), + subjectType: 'ITEM', + subjectUUID: '3a74fe2c-d353-4e33-9887-d50184662dd4', + subject: createSuccessfulRemoteDataObject$(new DSpaceObject()), + timeStamp: '2020-11-13T10:41:06.223+0000', + epersonName: AuditEPersonMock.name, + type: 'auditevent', + _embedded: { + eperson: AuditEPersonMock, + }, + self: { + _links: { + eperson: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/system/auditevents/6fcd7329-8439-4492-bb72-0a4240b52da8/eperson', + }, + object: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/system/auditevents/6fcd7329-8439-4492-bb72-0a4240b52da8/object', + }, + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/system/auditevents/6fcd7329-8439-4492-bb72-0a4240b52da8', + }, + subject: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/system/auditevents/6fcd7329-8439-4492-bb72-0a4240b52da8/subject', + }, + }, + }, +}); + diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index bb3fb9ad1a8..09a45618962 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -1,9 +1,11 @@ import { Route } from '@angular/router'; import { accessTokenResolver } from '@dspace/core/auth/access-token.resolver'; import { authenticatedGuard } from '@dspace/core/auth/authenticated.guard'; +import { i18nBreadcrumbResolver } from '@dspace/core/breadcrumbs/i18n-breadcrumb.resolver'; import { itemBreadcrumbResolver } from '@dspace/core/breadcrumbs/item-breadcrumb.resolver'; import { REQUEST_COPY_MODULE_PATH } from '../app-routing-paths'; +import { ObjectAuditLogsComponent } from '../audit-page/object-audit-overview/object-audit-logs.component'; import { MenuRoute } from '../shared/menu/menu-route.model'; import { viewTrackerResolver } from '../statistics/angulartics/dspace/view-tracker.resolver'; import { BitstreamRequestACopyPageComponent } from './bitstreams/request-a-copy/bitstream-request-a-copy-page.component'; @@ -12,6 +14,7 @@ import { ThemedFullItemPageComponent } from './full/themed-full-item-page.compon import { itemPageResolver } from './item-page.resolver'; import { ITEM_ACCESS_BY_TOKEN_PATH, + ITEM_AUDIT_LOGS_PATH, ITEM_EDIT_PATH, ORCID_PATH, UPLOAD_BITSTREAM_PATH, @@ -53,6 +56,14 @@ export const ROUTES: Route[] = [ tracking: viewTrackerResolver, }, }, + { + path: ITEM_AUDIT_LOGS_PATH, + component: ObjectAuditLogsComponent, + data: { title: 'audit.object.title', breadcrumbKey: 'audit.object' }, + resolve: { + breadcrumb: i18nBreadcrumbResolver, + }, + }, { path: ITEM_EDIT_PATH, loadChildren: () => import('./edit-item-page/edit-item-page-routes') diff --git a/src/app/item-page/item-page-routing-paths.ts b/src/app/item-page/item-page-routing-paths.ts index f6b731d813d..a6e8ca98d64 100644 --- a/src/app/item-page/item-page-routing-paths.ts +++ b/src/app/item-page/item-page-routing-paths.ts @@ -26,6 +26,7 @@ export function getItemVersionRoute(versionId: string) { return new URLCombiner(getItemModuleRoute(), ITEM_VERSION_PATH, versionId).toString(); } +export const ITEM_AUDIT_LOGS_PATH = 'auditlogs'; export const ITEM_EDIT_PATH = 'edit'; export const ITEM_EDIT_VERSIONHISTORY_PATH = 'versionhistory'; export const ITEM_VERSION_PATH = 'version'; diff --git a/src/app/shared/menu/providers/audit-item.menu.spec.ts b/src/app/shared/menu/providers/audit-item.menu.spec.ts new file mode 100644 index 00000000000..df9a9db961f --- /dev/null +++ b/src/app/shared/menu/providers/audit-item.menu.spec.ts @@ -0,0 +1,76 @@ + +import { TestBed } from '@angular/core/testing'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { Collection } from '@dspace/core/shared/collection.model'; +import { COLLECTION } from '@dspace/core/shared/collection.resource-type'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization-service.stub'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { of } from 'rxjs'; + +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { AuditLogsMenuProvider } from './audit-item.menu'; + +describe('AuditLogsMenuProvider', () => { + + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'context-menu.actions.audit-item.btn', + link: new URLCombiner('/collections/test-uuid/auditlogs').toString(), + }, + icon: 'clipboard-check', + }, + ]; + + let provider: AuditLogsMenuProvider; + + const dso: Collection = Object.assign(new Collection(), { + type: COLLECTION.value, + uuid: 'test-uuid', + _links: { self: { href: 'self-link' } }, + }); + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'audit.context-menu-entry.enabled', + values: [ + 'true', + ], + })), + }); + + let authorizationServiceStub = new AuthorizationDataServiceStub(); + + beforeEach(() => { + spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue( + of(true), + ); + TestBed.configureTestingModule({ + providers: [ + AuditLogsMenuProvider, + { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + ], + }); + provider = TestBed.inject(AuditLogsMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + describe('getSectionsForContext', () => { + it('should return the expected sections', (done) => { + provider.getSectionsForContext(dso).subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); + }); +}); diff --git a/src/app/shared/menu/providers/audit-item.menu.ts b/src/app/shared/menu/providers/audit-item.menu.ts new file mode 100644 index 00000000000..3e014e7ef9f --- /dev/null +++ b/src/app/shared/menu/providers/audit-item.menu.ts @@ -0,0 +1,78 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Injectable } from '@angular/core'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { getDSORoute } from '@dspace/core/router/utils/dso-route.utils'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { + combineLatest, + map, + Observable, + of, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { LinkMenuItemModel } from '../menu-item/models/link.model'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { DSpaceObjectPageMenuProvider } from './helper-providers/dso.menu'; + +/** + * Menu provider to create the "Audit" option in the DSO audit menu + */ +@Injectable() +export class AuditLogsMenuProvider extends DSpaceObjectPageMenuProvider { + constructor( + protected authorizationDataService: AuthorizationDataService, + protected configurationDataService: ConfigurationDataService, + ) { + super(); + } + + public getSectionsForContext(dso: DSpaceObject): Observable { + return this.configurationDataService.findByPropertyName('audit.enabled').pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => this.isPropertyEnabled(response)), + switchMap((isAuditEnabled: boolean) => { + if (isAuditEnabled) { + return combineLatest([ + this.authorizationDataService.isAuthorized(FeatureID.AdministratorOf), + this.configurationDataService.findByPropertyName('audit.context-menu-entry.enabled').pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => this.isPropertyEnabled(response)), + ), + ]).pipe( + map(([isAdmin, isAuditMenuEnabled]: [boolean, boolean]) => { + return [{ + model: { + type: MenuItemType.LINK, + text: 'context-menu.actions.audit-item.btn', + link: new URLCombiner(getDSORoute(dso), 'auditlogs').toString(), + } as LinkMenuItemModel, + icon: 'clipboard-check', + visible: isAdmin && isAuditMenuEnabled, + }] as PartialMenuSection[]; + }), + ); + } else { + return of([]); + } + }), + ); + } + + private isPropertyEnabled(property: RemoteData): boolean { + return property.hasSucceeded ? (property.payload.values.length > 0 && property.payload.values[0] === 'true') : false; + } +} diff --git a/src/app/shared/menu/providers/audit-overview.menu.spec.ts b/src/app/shared/menu/providers/audit-overview.menu.spec.ts new file mode 100644 index 00000000000..b622ed82544 --- /dev/null +++ b/src/app/shared/menu/providers/audit-overview.menu.spec.ts @@ -0,0 +1,73 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { TestBed } from '@angular/core/testing'; +import { APP_CONFIG } from '@dspace/config/app-config.interface'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { AuthorizationDataServiceStub } from '@dspace/core/testing/authorization-service.stub'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { of } from 'rxjs'; + +import { environment } from '../../../../environments/environment'; +import { MenuItemType } from '../menu-item-type.model'; +import { PartialMenuSection } from '../menu-provider.model'; +import { AuditOverviewMenuProvider } from './audit-overview.menu'; + +describe('AuditOverviewMenuProvider', () => { + const expectedSections: PartialMenuSection[] = [ + { + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.audit', + link: '/auditlogs', + }, + icon: 'clipboard-check', + }, + ]; + + let provider: AuditOverviewMenuProvider; + let authorizationServiceStub = new AuthorizationDataServiceStub(); + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), { + name: 'audit.enabled', + values: [ + 'true', + ], + })), + }); + + beforeEach(() => { + spyOn(authorizationServiceStub, 'isAuthorized').and.returnValue( + of(true), + ); + + TestBed.configureTestingModule({ + providers: [ + AuditOverviewMenuProvider, + { provide: AuthorizationDataService, useValue: authorizationServiceStub }, + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: APP_CONFIG, useValue: environment }, + ], + }); + provider = TestBed.inject(AuditOverviewMenuProvider); + }); + + it('should be created', () => { + expect(provider).toBeTruthy(); + }); + + it('getSections should return expected menu sections', (done) => { + provider.getSections().subscribe((sections) => { + expect(sections).toEqual(expectedSections); + done(); + }); + }); +}); diff --git a/src/app/shared/menu/providers/audit-overview.menu.ts b/src/app/shared/menu/providers/audit-overview.menu.ts new file mode 100644 index 00000000000..59db0297e16 --- /dev/null +++ b/src/app/shared/menu/providers/audit-overview.menu.ts @@ -0,0 +1,73 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { + Inject, + Injectable, +} from '@angular/core'; +import { + APP_CONFIG, + AppConfig, +} from '@dspace/config/app-config.interface'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { AuthorizationDataService } from '@dspace/core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { ConfigurationProperty } from '@dspace/core/shared/configuration-property.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { + combineLatest, + map, + Observable, +} from 'rxjs'; + +import { MenuItemType } from '../menu-item-type.model'; +import { + AbstractMenuProvider, + PartialMenuSection, +} from '../menu-provider.model'; + +/** + * Menu provider to create the "Health" menu in the admin sidebar + */ +@Injectable() +export class AuditOverviewMenuProvider extends AbstractMenuProvider { + constructor( + protected authorizationService: AuthorizationDataService, + protected configurationDataService: ConfigurationDataService, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + ) { + super(); + } + + public getSections(): Observable { + return combineLatest([ + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + this.configurationDataService.findByPropertyName('audit.enabled').pipe( + getFirstCompletedRemoteData(), + map((response: RemoteData) => { + return response.hasSucceeded ? (response.payload.values.length > 0 && response.payload.values[0] === 'true') : false; + }), + ), + ]).pipe( + map(([isSiteAdmin, isAuditEnabled]) => { + return [ + { + visible: isSiteAdmin && isAuditEnabled, + model: { + type: MenuItemType.LINK, + text: 'menu.section.audit', + link: '/auditlogs', + }, + icon: 'clipboard-check', + }, + ] as PartialMenuSection[]; + }), + ); + } +} diff --git a/src/app/shared/utils/string-replace.pipe.ts b/src/app/shared/utils/string-replace.pipe.ts new file mode 100644 index 00000000000..9030459a90f --- /dev/null +++ b/src/app/shared/utils/string-replace.pipe.ts @@ -0,0 +1,18 @@ +import { + Pipe, + PipeTransform, +} from '@angular/core'; +import { hasValue } from '@dspace/shared/utils/empty.util'; + + +@Pipe({ + name: 'dsStringReplace', + standalone: true, +}) +export class StringReplacePipe implements PipeTransform { + + transform(value: string, regexValue: string, replaceValue: string): string { + const regex = new RegExp(regexValue, 'g'); + return hasValue(value) ? value.replace(regex, replaceValue) : value; + } +} diff --git a/src/app/shared/utils/string-replace.spec.ts b/src/app/shared/utils/string-replace.spec.ts new file mode 100644 index 00000000000..f6f6e20cc44 --- /dev/null +++ b/src/app/shared/utils/string-replace.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; + +import { StringReplacePipe } from './string-replace.pipe'; + +describe('StringReplacePipe Pipe', () => { + + let stringReplacePipe: StringReplacePipe; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + StringReplacePipe, + ], + }).compileComponents(); + + stringReplacePipe = TestBed.inject(StringReplacePipe); + }); + + it('should replace the character specified in the regex parameter', async () => { + testTransform( + 'This_is_a_test', '_', ' ', 'This is a test', + ); + }); + + it('should not transform empty value', () => { + testTransform( + '', '_', ' ', '', + ); + }); + + function testTransform(input: string, regex: string, replaceValue: string, output: string) { + expect( + stringReplacePipe.transform(input, regex, replaceValue), + ).toMatch( + output, + ); + } +}); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c0b620d24d7..d39a824b799 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -899,6 +899,54 @@ "admin.batch-import.page.remove": "remove", + "audit.data.not-found": "No audits found.", + + "audit.detail.metadata.field": "Metadata field:", + + "audit.detail.metadata.value": "Value:", + + "audit.detail.metadata.authority": "Authority:", + + "audit.detail.metadata.confidence": "Confidence:", + + "audit.detail.metadata.place": "Place:", + + "audit.detail.metadata.action": "Action:", + + "audit.detail.metadata.checksum": "Checksum:", + + "audit.overview.title": "All Audit Logs", + + "audit.overview.table.id": "Audit ID", + + "audit.overview.table.objectUUID": "Object ID", + + "audit.overview.table.objectType": "Object Type", + + "audit.overview.table.subjectUUID": "Subject ID", + + "audit.overview.table.subjectType": "Subject Type", + + "audit.overview.table.entityType": "Audit Type", + + "audit.overview.table.eperson": "EPerson", + + "audit.overview.table.other": "Other Object", + + "audit.overview.table.timestamp": "Time (UTC)", + + "audit.overview.breadcrumbs": "Audit Logs", + + "audit.object.back": "Back", + + "audit.object.breadcrumbs": "Object Audit Logs", + + "audit.object.overview.title": "Object Audit Logs", + + "audit.object.logs.label": "Logs for object: ", + + "audit.object.overview.disabled.message": "Audit feature is currently disabled", + "auth.errors.invalid-user": "Invalid email address or password.", "auth.messages.expired": "Your session has expired. Please log in again.", @@ -1611,6 +1659,8 @@ "community.sub-community-list.head": "Communities in this Community", + "context-menu.actions.audit-item.btn": "Audit", + "cookies.consent.accept-all": "Accept all", "cookies.consent.accept-selected": "Accept selected", @@ -3439,6 +3489,8 @@ "menu.section.access_control_people": "People", + "menu.section.audit": "Audit Logs", + "menu.section.reports": "Reports", "menu.section.reports.collections": "Filtered Collections",