mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-11-23 11:22:33 +01:00
Added audit log interface
- Displays the currently tracked activities in the system. Related to #2173 and #1167
This commit is contained in:
parent
e5f0b4dd85
commit
78bf044a7a
@ -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;
|
||||
|
51
app/Http/Controllers/AuditLogController.php
Normal file
51
app/Http/Controllers/AuditLogController.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
@ -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';
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddActivityIndexes extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('activities', function(Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
19
resources/js/components/submit-on-change.js
Normal file
19
resources/js/components/submit-on-change.js
Normal file
@ -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;
|
@ -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',
|
||||
|
@ -121,6 +121,11 @@ body.flexbox {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flex-container-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-container-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -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;
|
||||
}
|
98
resources/views/settings/audit.blade.php
Normal file
98
resources/views/settings/audit.blade.php
Normal file
@ -0,0 +1,98 @@
|
||||
@extends('simple-layout')
|
||||
|
||||
@section('body')
|
||||
<div class="container">
|
||||
|
||||
<div class="grid left-focus v-center no-row-gap">
|
||||
<div class="py-m">
|
||||
@include('settings.navbar', ['selected' => 'audit'])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card content-wrap auto-height">
|
||||
<h2 class="list-heading">{{ trans('settings.audit') }}</h2>
|
||||
<p class="text-muted">{{ trans('settings.audit_desc') }}</p>
|
||||
|
||||
<div class="flex-container-row">
|
||||
<div component="dropdown" class="list-sort-type dropdown-container mr-m">
|
||||
<label for="">{{ trans('settings.audit_event_filter') }}</label>
|
||||
<button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
|
||||
<ul refs="dropdown@menu" class="dropdown-menu">
|
||||
<li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
|
||||
@foreach($activityKeys as $key)
|
||||
<li @if($key === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $key]) }}">{{ $key }}</a></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@foreach(['date_from', 'date_to'] as $filterKey)
|
||||
<form action="{{ url('/settings/audit') }}" method="get" class="block mr-m">
|
||||
@foreach($listDetails as $param => $val)
|
||||
@if(!empty($val) && $param !== $filterKey)
|
||||
<input type="hidden" name="{{ $param }}" value="{{ $val }}">
|
||||
@endif
|
||||
@endforeach
|
||||
<label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
|
||||
<input id="audit_filter_{{ $filterKey }}"
|
||||
component="submit-on-change"
|
||||
type="date"
|
||||
name="{{ $filterKey }}"
|
||||
value="{{ $listDetails[$filterKey] ?? '' }}">
|
||||
</form>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<hr class="mt-l mb-s">
|
||||
|
||||
{{ $activities->links() }}
|
||||
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{{ trans('settings.audit_table_user') }}</th>
|
||||
<th>
|
||||
<a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
|
||||
</th>
|
||||
<th>{{ trans('settings.audit_table_item') }}</th>
|
||||
<th>
|
||||
<a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
|
||||
</tr>
|
||||
@foreach($activities as $activity)
|
||||
<tr>
|
||||
<td>
|
||||
@if($activity->user)
|
||||
<a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
|
||||
<div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
|
||||
<div>{{ $activity->user->name }}</div>
|
||||
</a>
|
||||
@else
|
||||
[ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $activity->key }}</td>
|
||||
<td>
|
||||
@if($activity->entity)
|
||||
<a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
|
||||
<span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
|
||||
<div class="text-{{ $activity->entity->getType() }}">
|
||||
{{ $activity->entity->name }}
|
||||
</div>
|
||||
</a>
|
||||
@elseif($activity->extra)
|
||||
<div class="px-m">
|
||||
{{ trans('settings.audit_deleted_item') }} <br>
|
||||
{{ trans('settings.audit_deleted_item_name', ['name' => $activity->extra]) }}
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $activity->created_at }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{ $activities->links() }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@stop
|
@ -4,6 +4,9 @@
|
||||
<a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
|
||||
<a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
|
||||
@endif
|
||||
@if($currentUser->can('settings-manage') && $currentUser->can('users-manage'))
|
||||
<a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
|
||||
@endif
|
||||
@if($currentUser->can('users-manage'))
|
||||
<a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
|
||||
@endif
|
||||
|
@ -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');
|
||||
|
109
tests/AuditLogTest.php
Normal file
109
tests/AuditLogTest.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php namespace Tests;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use BookStack\Actions\ActivityService;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class AuditLogTest extends TestCase
|
||||
{
|
||||
|
||||
public function test_only_accessible_with_right_permissions()
|
||||
{
|
||||
$viewer = $this->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);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user