Audit log UI
This commit is contained in:
parent
07dc41a59d
commit
d8be1b2bb5
@ -119,6 +119,7 @@ import { NgxFilesizeModule } from 'ngx-filesize'
|
|||||||
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||||
|
import { AuditLogComponent } from './components/audit-log/audit-log.component'
|
||||||
import {
|
import {
|
||||||
airplane,
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
@ -472,6 +473,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
RotateConfirmDialogComponent,
|
RotateConfirmDialogComponent,
|
||||||
MergeConfirmDialogComponent,
|
MergeConfirmDialogComponent,
|
||||||
SplitConfirmDialogComponent,
|
SplitConfirmDialogComponent,
|
||||||
|
AuditLogComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
50
src-ui/src/app/components/audit-log/audit-log.component.html
Normal file
50
src-ui/src/app/components/audit-log/audit-log.component.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<ul class="list-group list-group-">
|
||||||
|
@for (entry of entries; track entry.id) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
{{ entry.timestamp | customDate:'longDate' }}
|
||||||
|
{{ entry.timestamp | date:'shortTime' }}
|
||||||
|
@if (entry.actor) {
|
||||||
|
<span class="ms-3 fst-italic">{{ entry.actor.username }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="ms-3 fst-italic">System</span>
|
||||||
|
}
|
||||||
|
<span class="badge bg-secondary ms-3">{{ entry.action | titlecase }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ml-auto btn btn-link" (click)="toggleEntry(entry)">
|
||||||
|
<i-bs class="me-2" name="info-circle"></i-bs>
|
||||||
|
<ng-container i18n>Details</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div #collapse="ngbCollapse" [ngbCollapse]="!openEntries.has(entry.id)">
|
||||||
|
<table class="table table-borderless m-0 ms-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th i18n>Field</th>
|
||||||
|
<th i18n>Change</th>
|
||||||
|
</tr>
|
||||||
|
<tbody>
|
||||||
|
@for (change of entry.changes | keyvalue; track change.key) {
|
||||||
|
@if (change.value["type"] === 'm2m') {
|
||||||
|
<tr>
|
||||||
|
<td>{{ change.key | titlecase }}:</td>
|
||||||
|
<td>
|
||||||
|
{{ change.value["operation"] | titlecase }}
|
||||||
|
<span class="fst-italic">{{ change.value["objects"].join(', ') }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@else if (change.key !== 'modified') {
|
||||||
|
<tr>
|
||||||
|
<td>{{ change.key | titlecase }}:</td>
|
||||||
|
<td>{{ change.value[1] }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
@ -0,0 +1,3 @@
|
|||||||
|
table {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { AuditLogComponent } from './audit-log.component'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { AuditLogAction } from 'src/app/data/auditlog-entry'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { DatePipe } from '@angular/common'
|
||||||
|
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
describe('AuditLogComponent', () => {
|
||||||
|
let component: AuditLogComponent
|
||||||
|
let fixture: ComponentFixture<AuditLogComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AuditLogComponent, CustomDatePipe],
|
||||||
|
providers: [DatePipe],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
NgbCollapseModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AuditLogComponent)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should get audit log entries on init', () => {
|
||||||
|
const getAuditLogSpy = jest.spyOn(documentService, 'getAuditLog')
|
||||||
|
getAuditLogSpy.mockReturnValue(
|
||||||
|
of([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
actor: {
|
||||||
|
id: 1,
|
||||||
|
username: 'user1',
|
||||||
|
},
|
||||||
|
action: AuditLogAction.Create,
|
||||||
|
timestamp: '2021-01-01T00:00:00Z',
|
||||||
|
remote_addr: '1.2.3.4',
|
||||||
|
changes: {
|
||||||
|
title: ['old title', 'new title'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
component.documentId = 1
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(getAuditLogSpy).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should toggle entry', () => {
|
||||||
|
const entry = {
|
||||||
|
id: 1,
|
||||||
|
actor: {
|
||||||
|
id: 1,
|
||||||
|
username: 'user1',
|
||||||
|
},
|
||||||
|
action: AuditLogAction.Create,
|
||||||
|
timestamp: '2021-01-01T00:00:00Z',
|
||||||
|
remote_addr: '1.2.3.4',
|
||||||
|
changes: {
|
||||||
|
title: ['old title', 'new title'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
component.toggleEntry(entry)
|
||||||
|
expect(component.openEntries.has(1)).toBe(true)
|
||||||
|
component.toggleEntry(entry)
|
||||||
|
expect(component.openEntries.has(1)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
43
src-ui/src/app/components/audit-log/audit-log.component.ts
Normal file
43
src-ui/src/app/components/audit-log/audit-log.component.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core'
|
||||||
|
import { AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-audit-log',
|
||||||
|
templateUrl: './audit-log.component.html',
|
||||||
|
styleUrl: './audit-log.component.scss',
|
||||||
|
})
|
||||||
|
export class AuditLogComponent implements OnInit {
|
||||||
|
_documentId: number
|
||||||
|
@Input()
|
||||||
|
set documentId(id: number) {
|
||||||
|
this._documentId = id
|
||||||
|
this.ngOnInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
public loading: boolean = true
|
||||||
|
public entries: AuditLogEntry[] = []
|
||||||
|
public openEntries: Set<number> = new Set()
|
||||||
|
|
||||||
|
constructor(private documentService: DocumentService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this._documentId) {
|
||||||
|
this.loading = true
|
||||||
|
this.documentService
|
||||||
|
.getAuditLog(this._documentId)
|
||||||
|
.subscribe((auditLogEntries) => {
|
||||||
|
this.entries = auditLogEntries
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEntry(entry: AuditLogEntry) {
|
||||||
|
if (this.openEntries.has(entry.id)) {
|
||||||
|
this.openEntries.delete(entry.id)
|
||||||
|
} else {
|
||||||
|
this.openEntries.add(entry.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -285,6 +285,15 @@
|
|||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<li *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.AuditLogEntry }" [ngbNavItem]="DocumentDetailNavIDs.History">
|
||||||
|
<a ngbNavLink i18n>History</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="mb-3">
|
||||||
|
<pngx-audit-log [documentId]="documentId"></pngx-audit-log>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
@if (showPermissions) {
|
@if (showPermissions) {
|
||||||
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
|
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
|
||||||
<a ngbNavLink i18n>Permissions</a>
|
<a ngbNavLink i18n>Permissions</a>
|
||||||
|
@ -77,6 +77,7 @@ enum DocumentDetailNavIDs {
|
|||||||
Preview = 4,
|
Preview = 4,
|
||||||
Notes = 5,
|
Notes = 5,
|
||||||
Permissions = 6,
|
Permissions = 6,
|
||||||
|
History = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ContentRenderType {
|
enum ContentRenderType {
|
||||||
|
16
src-ui/src/app/data/auditlog-entry.ts
Normal file
16
src-ui/src/app/data/auditlog-entry.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { User } from './user'
|
||||||
|
|
||||||
|
export enum AuditLogAction {
|
||||||
|
Create = 'create',
|
||||||
|
Update = 'update',
|
||||||
|
Delete = 'delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
action: AuditLogAction
|
||||||
|
changes: any
|
||||||
|
remote_addr: string
|
||||||
|
actor?: User
|
||||||
|
}
|
@ -19,6 +19,7 @@ export enum PermissionType {
|
|||||||
PaperlessTask = '%s_paperlesstask',
|
PaperlessTask = '%s_paperlesstask',
|
||||||
AppConfig = '%s_applicationconfiguration',
|
AppConfig = '%s_applicationconfiguration',
|
||||||
UISettings = '%s_uisettings',
|
UISettings = '%s_uisettings',
|
||||||
|
AuditLogEntry = '%s_logentry',
|
||||||
Note = '%s_note',
|
Note = '%s_note',
|
||||||
MailAccount = '%s_mailaccount',
|
MailAccount = '%s_mailaccount',
|
||||||
MailRule = '%s_mailrule',
|
MailRule = '%s_mailrule',
|
||||||
|
@ -266,6 +266,13 @@ describe(`DocumentService`, () => {
|
|||||||
)
|
)
|
||||||
expect(req.request.body.remove_inbox_tags).toEqual(true)
|
expect(req.request.body.remove_inbox_tags).toEqual(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call appropriate api endpoint for getting audit log', () => {
|
||||||
|
subscription = service.getAuditLog(documents[0].id).subscribe()
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/audit/`
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
} from '../permissions.service'
|
} from '../permissions.service'
|
||||||
import { SettingsService } from '../settings.service'
|
import { SettingsService } from '../settings.service'
|
||||||
import { SETTINGS, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||||
|
|
||||||
export const DOCUMENT_SORT_FIELDS = [
|
export const DOCUMENT_SORT_FIELDS = [
|
||||||
{ field: 'archive_serial_number', name: $localize`ASN` },
|
{ field: 'archive_serial_number', name: $localize`ASN` },
|
||||||
@ -222,6 +223,10 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAuditLog(id: number): Observable<AuditLogEntry[]> {
|
||||||
|
return this.http.get<AuditLogEntry[]>(this.getResourceUrl(id, 'audit'))
|
||||||
|
}
|
||||||
|
|
||||||
bulkDownload(
|
bulkDownload(
|
||||||
ids: number[],
|
ids: number[],
|
||||||
content = 'both',
|
content = 'both',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user