From 78bf044a7acf39dfc91588099435cd27038b61b2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 19 Sep 2020 12:06:45 +0100 Subject: [PATCH] Added audit log interface - Displays the currently tracked activities in the system. Related to #2173 and #1167 --- app/Entities/Entity.php | 4 +- app/Http/Controllers/AuditLogController.php | 51 ++++++++ app/helpers.php | 6 +- ...2020_09_19_094251_add_activity_indexes.php | 34 ++++++ resources/js/components/index.js | 2 + resources/js/components/submit-on-change.js | 19 +++ resources/lang/en/settings.php | 14 +++ resources/sass/_layout.scss | 5 + resources/sass/styles.scss | 11 ++ resources/views/settings/audit.blade.php | 98 ++++++++++++++++ resources/views/settings/navbar.blade.php | 3 + routes/web.php | 3 + tests/AuditLogTest.php | 109 ++++++++++++++++++ 13 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/AuditLogController.php create mode 100644 database/migrations/2020_09_19_094251_add_activity_indexes.php create mode 100644 resources/js/components/submit-on-change.js create mode 100644 resources/views/settings/audit.blade.php create mode 100644 tests/AuditLogTest.php diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index 6a5894cac..120290d8f 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -238,10 +238,8 @@ class Entity extends Ownable /** * Gets a limited-length version of the entities name. - * @param int $length - * @return string */ - public function getShortName($length = 25) + public function getShortName(int $length = 25): string { if (mb_strlen($this->name) <= $length) { return $this->name; diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php new file mode 100644 index 000000000..a3ef01baa --- /dev/null +++ b/app/Http/Controllers/AuditLogController.php @@ -0,0 +1,51 @@ +checkPermission('settings-manage'); + $this->checkPermission('users-manage'); + + $listDetails = [ + 'order' => $request->get('order', 'desc'), + 'event' => $request->get('event', ''), + 'sort' => $request->get('sort', 'created_at'), + 'date_from' => $request->get('date_from', ''), + 'date_to' => $request->get('date_to', ''), + ]; + + $query = Activity::query() + ->with(['entity', 'user']) + ->orderBy($listDetails['sort'], $listDetails['order']); + + if ($listDetails['event']) { + $query->where('key', '=', $listDetails['event']); + } + + if ($listDetails['date_from']) { + $query->where('created_at', '>=', $listDetails['date_from']); + } + if ($listDetails['date_to']) { + $query->where('created_at', '<=', $listDetails['date_to']); + } + + $activities = $query->paginate(100); + $activities->appends($listDetails); + + $keys = DB::table('activities')->select('key')->distinct()->pluck('key'); + $this->setPageTitle(trans('settings.audit')); + return view('settings.audit', [ + 'activities' => $activities, + 'listDetails' => $listDetails, + 'activityKeys' => $keys, + ]); + } +} diff --git a/app/helpers.php b/app/helpers.php index 65da1853b..83017c37d 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string * Generate a url with multiple parameters for sorting purposes. * Works out the logic to set the correct sorting direction * Discards empty parameters and allows overriding. - * @param string $path - * @param array $data - * @param array $overrideData - * @return string */ function sortUrl(string $path, array $data, array $overrideData = []): string { @@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string // Change sorting direction is already sorted on current attribute if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) { $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc'; - } else { + } elseif (isset($overrideData['sort'])) { $queryData['order'] = 'asc'; } diff --git a/database/migrations/2020_09_19_094251_add_activity_indexes.php b/database/migrations/2020_09_19_094251_add_activity_indexes.php new file mode 100644 index 000000000..544b01e1f --- /dev/null +++ b/database/migrations/2020_09_19_094251_add_activity_indexes.php @@ -0,0 +1,34 @@ +index('key'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('activities', function(Blueprint $table) { + $table->dropIndex('key'); + $table->dropIndex('created_at'); + }); + } +} diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 9a5f2d7d7..87c496c91 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -42,6 +42,7 @@ import settingColorPicker from "./setting-color-picker.js" import shelfSort from "./shelf-sort.js" import sidebar from "./sidebar.js" import sortableList from "./sortable-list.js" +import submitOnChange from "./submit-on-change.js" import tabs from "./tabs.js" import tagManager from "./tag-manager.js" import templateManager from "./template-manager.js" @@ -94,6 +95,7 @@ const componentMapping = { "shelf-sort": shelfSort, "sidebar": sidebar, "sortable-list": sortableList, + "submit-on-change": submitOnChange, "tabs": tabs, "tag-manager": tagManager, "template-manager": templateManager, diff --git a/resources/js/components/submit-on-change.js b/resources/js/components/submit-on-change.js new file mode 100644 index 000000000..979967242 --- /dev/null +++ b/resources/js/components/submit-on-change.js @@ -0,0 +1,19 @@ +/** + * Submit on change + * Simply submits a parent form when this input is changed. + * @extends {Component} + */ +class SubmitOnChange { + + setup() { + this.$el.addEventListener('change', () => { + const form = this.$el.closest('form'); + if (form) { + form.submit(); + } + }); + } + +} + +export default SubmitOnChange; \ No newline at end of file diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 679d4b8a8..2bd314cf0 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -81,6 +81,20 @@ return [ 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + // Audit Log + 'audit' => 'Audit Log', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Event Filter', + 'audit_event_filter_no_filter' => 'No Filter', + 'audit_deleted_item' => 'Deleted Item', + 'audit_deleted_item_name' => 'Name: :name', + 'audit_table_user' => 'User', + 'audit_table_event' => 'Event', + 'audit_table_item' => 'Related Item', + 'audit_table_date' => 'Activity Date', + 'audit_date_from' => 'Date Range From', + 'audit_date_to' => 'Date Range To', + // Role Settings 'roles' => 'Roles', 'role_user_roles' => 'User Roles', diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 439bf8512..cf2a1630e 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -121,6 +121,11 @@ body.flexbox { position: relative; } +.flex-container-row { + display: flex; + flex-direction: row; +} + .flex-container-column { display: flex; flex-direction: column; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index f89fe039e..376541b5d 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -288,4 +288,15 @@ $btt-size: 40px; transform: rotate(180deg); } } +} + +table a.audit-log-user { + display: grid; + grid-template-columns: 42px 1fr; + align-items: center; +} +table a.icon-list-item { + display: grid; + grid-template-columns: 36px 1fr; + align-items: center; } \ No newline at end of file diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php new file mode 100644 index 000000000..9b97f060d --- /dev/null +++ b/resources/views/settings/audit.blade.php @@ -0,0 +1,98 @@ +@extends('simple-layout') + +@section('body') +
+ +
+
+ @include('settings.navbar', ['selected' => 'audit']) +
+
+ +
+

{{ trans('settings.audit') }}

+

{{ trans('settings.audit_desc') }}

+ +
+ + + @foreach(['date_from', 'date_to'] as $filterKey) +
+ @foreach($listDetails as $param => $val) + @if(!empty($val) && $param !== $filterKey) + + @endif + @endforeach + + +
+ @endforeach +
+ +
+ + {{ $activities->links() }} + + + + + + + + + + @foreach($activities as $activity) + + + + + + + @endforeach + +
{{ trans('settings.audit_table_user') }} + {{ trans('settings.audit_table_event') }} + {{ trans('settings.audit_table_item') }} + {{ trans('settings.audit_table_date') }}
+ @if($activity->user) + +
{{ $activity->user->name }}
+
{{ $activity->user->name }}
+
+ @else + [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }} + @endif +
{{ $activity->key }} + @if($activity->entity) + + @icon($activity->entity->getType()) +
+ {{ $activity->entity->name }} +
+
+ @elseif($activity->extra) +
+ {{ trans('settings.audit_deleted_item') }}
+ {{ trans('settings.audit_deleted_item_name', ['name' => $activity->extra]) }} +
+ @endif +
{{ $activity->created_at }}
+ + {{ $activities->links() }} +
+ +
+@stop diff --git a/resources/views/settings/navbar.blade.php b/resources/views/settings/navbar.blade.php index 896de9d97..af8b2aaf7 100644 --- a/resources/views/settings/navbar.blade.php +++ b/resources/views/settings/navbar.blade.php @@ -4,6 +4,9 @@ @icon('settings'){{ trans('settings.settings') }} @icon('spanner'){{ trans('settings.maint') }} @endif + @if($currentUser->can('settings-manage') && $currentUser->can('users-manage')) + @icon('open-book'){{ trans('settings.audit') }} + @endif @if($currentUser->can('users-manage')) @icon('users'){{ trans('settings.users') }} @endif diff --git a/routes/web.php b/routes/web.php index 307c6f516..acbcb4e8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -166,6 +166,9 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages'); Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail'); + // Audit Log + Route::get('/audit', 'AuditLogController@index'); + // Users Route::get('/users', 'UserController@index'); Route::get('/users/create', 'UserController@create'); diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php new file mode 100644 index 000000000..a2cdc33ff --- /dev/null +++ b/tests/AuditLogTest.php @@ -0,0 +1,109 @@ +getViewer(); + $this->actingAs($viewer); + + $resp = $this->get('/settings/audit'); + $this->assertPermissionError($resp); + + $this->giveUserPermissions($viewer, ['settings-manage']); + $resp = $this->get('/settings/audit'); + $this->assertPermissionError($resp); + + $this->giveUserPermissions($viewer, ['users-manage']); + $resp = $this->get('/settings/audit'); + $resp->assertStatus(200); + $resp->assertSeeText('Audit Log'); + } + + public function test_shows_activity() + { + $admin = $this->getAdmin(); + $this->actingAs($admin); + $page = Page::query()->first(); + app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $activity = Activity::query()->orderBy('id', 'desc')->first(); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText($page->name); + $resp->assertSeeText('page_create'); + $resp->assertSeeText($activity->created_at->toDateTimeString()); + $resp->assertElementContains('.audit-log-user', $admin->name); + } + + public function test_shows_name_for_deleted_items() + { + $this->actingAs( $this->getAdmin()); + $page = Page::query()->first(); + $pageName = $page->name; + app(ActivityService::class)->add($page, 'page_create', $page->book->id); + + app(PageRepo::class)->destroy($page); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText('Deleted Item'); + $resp->assertSeeText('Name: ' . $pageName); + } + + public function test_shows_activity_for_deleted_users() + { + $viewer = $this->getViewer(); + $this->actingAs($viewer); + $page = Page::query()->first(); + app(ActivityService::class)->add($page, 'page_create', $page->book->id); + + $this->actingAs($this->getAdmin()); + app(UserRepo::class)->destroy($viewer); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); + } + + public function test_filters_by_key() + { + $this->actingAs($this->getAdmin()); + $page = Page::query()->first(); + app(ActivityService::class)->add($page, 'page_create', $page->book->id); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?event=page_delete'); + $resp->assertDontSeeText($page->name); + } + + public function test_date_filters() + { + $this->actingAs($this->getAdmin()); + $page = Page::query()->first(); + app(ActivityService::class)->add($page, 'page_create', $page->book->id); + + $yesterday = (Carbon::now()->subDay()->format('Y-m-d')); + $tomorrow = (Carbon::now()->addDay()->format('Y-m-d')); + + $resp = $this->get('settings/audit?date_from=' . $yesterday); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?date_from=' . $tomorrow); + $resp->assertDontSeeText($page->name); + + $resp = $this->get('settings/audit?date_to=' . $tomorrow); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?date_to=' . $yesterday); + $resp->assertDontSeeText($page->name); + } + +} \ No newline at end of file