diff --git a/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php b/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php new file mode 100644 index 00000000..a3bd8029 --- /dev/null +++ b/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php @@ -0,0 +1,38 @@ +schema->table('log_entries', function (Blueprint $table): void { + $table->unsignedInteger('user_id')->after('id')->nullable()->default(null); + $table->foreign('user_id') + ->references('id')->on('users') + ->onUpdate('cascade') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migration + */ + public function down(): void + { + $this->schema->table('log_entries', function (Blueprint $table): void { + $table->dropForeign('log_entries_user_id_foreign'); + $table->dropColumn('user_id'); + }); + } +} diff --git a/db/migrations/2023_12_06_000001_add_logs_all_permission.php b/db/migrations/2023_12_06_000001_add_logs_all_permission.php new file mode 100644 index 00000000..41a2df79 --- /dev/null +++ b/db/migrations/2023_12_06_000001_add_logs_all_permission.php @@ -0,0 +1,44 @@ +schema->getConnection(); + $db->table('privileges') + ->insert([ + ['name' => 'logs.all', 'description' => 'View all logs'], + ]); + + $logsAll = $db->table('privileges') + ->where('name', 'logs.all') + ->get(['id']) + ->first(); + + $bureaucrat = 80; + $db->table('group_privileges') + ->insertOrIgnore([ + ['group_id' => $bureaucrat, 'privilege_id' => $logsAll->id], + ]); + } + + /** + * Reverse the migration + */ + public function down(): void + { + $db = $this->schema->getConnection(); + $db->table('privileges') + ->where('name', 'logs.all') + ->delete(); + } +} diff --git a/db/migrations/Reference.php b/db/migrations/Reference.php index 7f502f3a..0a242e9b 100644 --- a/db/migrations/Reference.php +++ b/db/migrations/Reference.php @@ -30,7 +30,7 @@ trait Reference $table->primary($fromColumn); } - $this->addReference($table, $fromColumn, $targetTable, $targetColumn ?: 'id'); + $this->addReference($table, $fromColumn, $targetTable, $targetColumn); return $col; } diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index 2899b481..c9ca1961 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -1407,6 +1407,9 @@ msgstr "Du kannst hier Markdown verwenden" msgid "form.required" msgstr "Pflichtfeld" +msgid "form.user_select" +msgstr "Wähle einen User" + msgid "schedule.import" msgstr "Programm importieren" @@ -1531,6 +1534,9 @@ msgstr "Suchen" msgid "log.log" msgstr "Logs" +msgid "log.only_own" +msgstr "Du siehst hier deine eigenen Logs. Die logs anderer User können nur Bürokraten sehen." + msgid "log.time" msgstr "Zeit" diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index 06c97b45..f7891cb1 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -115,6 +115,9 @@ msgstr "Required" msgid "form.markdown" msgstr "You can use Markdown here" +msgid "form.user_select" +msgstr "Select a user" + msgid "schedule.import" msgstr "Import schedule" @@ -244,6 +247,9 @@ msgstr "Search" msgid "log.log" msgstr "Logs" +msgid "log.only_own" +msgstr "You can view your own logs. The logs of other users can be checked by bureaucrats." + msgid "log.time" msgstr "Time" diff --git a/resources/views/admin/log.twig b/resources/views/admin/log.twig index b4a76da8..87ea55cb 100644 --- a/resources/views/admin/log.twig +++ b/resources/views/admin/log.twig @@ -6,7 +6,7 @@ {% block content %}
-

{{ block('title') }}

+

{{ block('title') }} ({{ entries|length }})

@@ -14,19 +14,39 @@
{{ csrf() }} - {{ f.input('search', __('form.search'), { - 'value': search, - 'hide_label': true, - }) }} +
+
+ {{ f.input('search', __('form.search'), { + 'value': search, + 'hide_label': true, + }) }} +
+ + {% if has_permission_to('logs.all') %} +
+ {{ f.select('search_user_id', __('general.user'), users, { + 'default_option': __('form.user_select'), + 'selected': search_user_id, + }) }} +
+ {% endif %} +
{{ f.submit(__('form.search'), {'icon_left': 'search'}) }}
+ {% if not has_permission_to('logs.all') %} +
+ {{ m.alert(__('log.only_own')) }} +
+ {% endif %} + + {% for entry in entries %} @@ -47,10 +67,11 @@ {%- endif %} - - + + {% endfor %} diff --git a/src/Controllers/Admin/LogsController.php b/src/Controllers/Admin/LogsController.php index 77469238..10f25240 100644 --- a/src/Controllers/Admin/LogsController.php +++ b/src/Controllers/Admin/LogsController.php @@ -5,9 +5,12 @@ declare(strict_types=1); namespace Engelsystem\Controllers\Admin; use Engelsystem\Controllers\BaseController; +use Engelsystem\Helpers\Authenticator; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Models\LogEntry; +use Engelsystem\Models\User\User; +use Illuminate\Support\Collection; class LogsController extends BaseController { @@ -16,18 +19,33 @@ class LogsController extends BaseController 'admin_log', ]; - public function __construct(protected LogEntry $log, protected Response $response) + public function __construct(protected LogEntry $log, protected Response $response, protected Authenticator $auth) { } public function index(Request $request): Response { + $searchUserId = (int) $request->input('search_user_id') ?: null; $search = $request->input('search'); - $entries = $this->log->filter($search); + $userId = $this->auth->user()?->id; + + if ($this->auth->can('logs.all')) { + $userId = $searchUserId; + } + + $entries = $this->log->filter($search, $userId); + + /** @var Collection $users */ + $users = User::with('personalData') + ->orderBy('name') + ->get() + ->mapWithKeys(function (User $u) { + return [$u->id => $u->displayName]; + }); return $this->response->withView( 'admin/log.twig', - ['entries' => $entries, 'search' => $search] + ['entries' => $entries, 'search' => $search, 'users' => $users, 'search_user_id' => $searchUserId] ); } } diff --git a/src/Logger/Logger.php b/src/Logger/Logger.php index 2b5bcdbe..f697a2a8 100644 --- a/src/Logger/Logger.php +++ b/src/Logger/Logger.php @@ -44,7 +44,7 @@ class Logger extends AbstractLogger $message .= $this->formatException($context['exception']); } - $this->log->create(['level' => $level, 'message' => $message]); + $this->createEntry(['level' => $level, 'message' => $message]); } /** @@ -81,4 +81,9 @@ class Logger extends AbstractLogger { return in_array($level, $this->allowedLevels); } + + protected function createEntry(array $data): void + { + $this->log->create($data); + } } diff --git a/src/Logger/UserAwareLogger.php b/src/Logger/UserAwareLogger.php index 7d40c0d7..dc4f187b 100644 --- a/src/Logger/UserAwareLogger.php +++ b/src/Logger/UserAwareLogger.php @@ -5,24 +5,21 @@ declare(strict_types=1); namespace Engelsystem\Logger; use Engelsystem\Helpers\Authenticator; -use Psr\Log\InvalidArgumentException; -use Stringable; class UserAwareLogger extends Logger { - protected Authenticator $auth; + protected ?Authenticator $auth; /** - * Logs with an arbitrary level and prepends the user - * @throws InvalidArgumentException + * Adds the authenticated user to the log message */ - public function log(mixed $level, string|Stringable $message, array $context = []): void + public function createEntry(array $data): void { if ($this->auth && ($user = $this->auth->user())) { - $message = sprintf('%s (%u): %s', $user->name, $user->id, $message); + $data['user_id'] = $user->id; } - parent::log($level, $message, $context); + parent::createEntry($data); } public function setAuth(Authenticator $auth): void diff --git a/src/Models/LogEntry.php b/src/Models/LogEntry.php index f8577f85..ce53f70c 100644 --- a/src/Models/LogEntry.php +++ b/src/Models/LogEntry.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Engelsystem\Models; use Carbon\Carbon; +use Engelsystem\Models\User\UsesUserModel; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Query\Builder as QueryBuilder; @@ -23,35 +24,48 @@ use Illuminate\Support\Collection as SupportCollection; */ class LogEntry extends BaseModel { + use UsesUserModel; + /** @var bool enable timestamps for created_at */ public $timestamps = true; // phpcs:ignore /** @var null Disable updated_at */ public const UPDATED_AT = null; + /** @var array */ + protected $casts = [ // phpcs:ignore + 'user_id' => 'integer', + ]; + /** * The attributes that are mass assignable. */ protected $fillable = [ // phpcs:ignore 'level', 'message', + 'user_id', ]; /** * @return Builder[]|Collection|SupportCollection|LogEntry[] */ - public static function filter(string $keyword = null): array|Collection|SupportCollection + public static function filter(?string $keyword = null, ?int $userId = null): array|Collection|SupportCollection { - $query = self::query() - ->select() + $query = self::with(['user', 'user.personalData', 'user.state']) ->orderByDesc('created_at') ->orderByDesc('id') ->limit(10000); + if (!empty($userId)) { + $query->where('user_id', $userId); + } + if (!empty($keyword)) { $query - ->where('level', '=', $keyword) - ->orWhere('message', 'LIKE', '%' . $keyword . '%'); + ->where(function (Builder $query) use ($keyword): void { + $query->where('level', '=', $keyword) + ->orWhere('message', 'LIKE', '%' . $keyword . '%'); + }); } return $query->get(); diff --git a/tests/Unit/Controllers/Admin/LogsControllerTest.php b/tests/Unit/Controllers/Admin/LogsControllerTest.php index 9034cd1c..9cc7f9ad 100644 --- a/tests/Unit/Controllers/Admin/LogsControllerTest.php +++ b/tests/Unit/Controllers/Admin/LogsControllerTest.php @@ -4,10 +4,13 @@ declare(strict_types=1); namespace Engelsystem\Test\Unit\Controllers\Admin; +use Engelsystem\Config\Config; use Engelsystem\Controllers\Admin\LogsController; +use Engelsystem\Helpers\Authenticator; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Models\LogEntry; +use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\TestCase; use Illuminate\Database\Eloquent\Collection; @@ -25,22 +28,36 @@ class LogsControllerTest extends TestCase { $log = new LogEntry(); $alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Alert test']); - $alert = $log->find($alert)->first(); + $alert = $log->with('user')->find($alert)->first(); $error = $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']); - $error = $log->find($error)->first(); + $error = $log->with('user')->find($error)->first(); + + $auth = $this->createMock(Authenticator::class); + $this->setExpects($auth, 'user', null, null, 2); + $this->setExpects($auth, 'can', ['logs.all'], true, 2); $response = $this->createMock(Response::class); $response->expects($this->exactly(2)) ->method('withView') ->withConsecutive( - ['admin/log.twig', ['entries' => new Collection([$error, $alert]), 'search' => null]], - ['admin/log.twig', ['entries' => new Collection([$error]), 'search' => 'error']] + ['admin/log.twig', [ + 'entries' => new Collection([$error, $alert]), + 'search' => null, + 'users' => new Collection(), + 'search_user_id' => null, + ]], + ['admin/log.twig', [ + 'entries' => new Collection([$error]), + 'search' => 'error', + 'users' => new Collection(), + 'search_user_id' => null, + ]] ) ->willReturn($response); $request = Request::create('/'); - $controller = new LogsController($log, $response); + $controller = new LogsController($log, $response, $auth); $controller->index($request); $request->request->set('search', 'error'); @@ -48,12 +65,48 @@ class LogsControllerTest extends TestCase } /** - * Setup the DB + * @covers \Engelsystem\Controllers\Admin\LogsController::index + */ + public function testIndexUser(): void + { + User::factory()->create(); + $user = User::with(['personalData', 'state'])->first(); + + $log = new LogEntry(); + $alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Users message', 'user_id' => $user->id]); + /** @var LogEntry $alert */ + $alert = $log->with('user')->find($alert)->first(); + $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']); + + $auth = $this->createMock(Authenticator::class); + $this->setExpects($auth, 'user', null, $user); + $this->setExpects($auth, 'can', ['logs.all'], false); + + $response = $this->createMock(Response::class); + $response->expects($this->once()) + ->method('withView') + ->willReturnCallback(function (string $view, array $data) use ($alert, $response) { + $this->assertEquals('admin/log.twig', $view); + $this->assertArrayHasKey('entries', $data); + $this->assertCount(1, $data['entries']); + $this->assertEquals($alert->message, $data['entries'][0]['message']); + return $response; + }); + + $request = Request::create('/'); + + $controller = new LogsController($log, $response, $auth); + $controller->index($request); + } + + /** + * Set up the DB */ public function setUp(): void { parent::setUp(); $this->initDatabase(); + $this->app->instance('config', new Config([])); } } diff --git a/tests/Unit/Logger/LoggerTest.php b/tests/Unit/Logger/LoggerTest.php index 329a8e1b..277b4ba3 100644 --- a/tests/Unit/Logger/LoggerTest.php +++ b/tests/Unit/Logger/LoggerTest.php @@ -45,6 +45,7 @@ class LoggerTest extends ServiceProviderTest /** * @covers \Engelsystem\Logger\Logger::log + * @covers \Engelsystem\Logger\Logger::createEntry * @dataProvider provideLogLevels */ public function testAllLevels(string $level): void diff --git a/tests/Unit/Logger/UserAwareLoggerTest.php b/tests/Unit/Logger/UserAwareLoggerTest.php index eeadeed0..30444547 100644 --- a/tests/Unit/Logger/UserAwareLoggerTest.php +++ b/tests/Unit/Logger/UserAwareLoggerTest.php @@ -8,18 +8,23 @@ use Engelsystem\Helpers\Authenticator; use Engelsystem\Logger\UserAwareLogger; use Engelsystem\Models\LogEntry; use Engelsystem\Models\User\User; -use Engelsystem\Test\Unit\ServiceProviderTest; +use Engelsystem\Test\Unit\HasDatabase; +use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LogLevel; -class UserAwareLoggerTest extends ServiceProviderTest +class UserAwareLoggerTest extends TestCase { + use HasDatabase; + /** - * @covers \Engelsystem\Logger\UserAwareLogger::log + * @covers \Engelsystem\Logger\UserAwareLogger::createEntry * @covers \Engelsystem\Logger\UserAwareLogger::setAuth */ public function testLog(): void { + $this->initDatabase(); // To be able to run the test by itself + $user = User::factory(['id' => 1, 'name' => 'admin'])->make(); /** @var LogEntry|MockObject $logEntry */ @@ -30,7 +35,7 @@ class UserAwareLoggerTest extends ServiceProviderTest ->method('create') ->withConsecutive( [['level' => LogLevel::INFO, 'message' => 'Some more informational foo']], - [['level' => LogLevel::INFO, 'message' => 'admin (1): Some even more informational bar']] + [['level' => LogLevel::INFO, 'message' => 'Some even more informational bar', 'user_id' => 1]] ); /** @var Authenticator|MockObject $auth */ diff --git a/tests/Unit/Models/LogEntryTest.php b/tests/Unit/Models/LogEntryTest.php index 4aca5d83..944d6ec3 100644 --- a/tests/Unit/Models/LogEntryTest.php +++ b/tests/Unit/Models/LogEntryTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Engelsystem\Test\Unit\Models; use Engelsystem\Models\LogEntry; +use Engelsystem\Models\User\User; use Psr\Log\LogLevel; class LogEntryTest extends ModelTest @@ -14,6 +15,8 @@ class LogEntryTest extends ModelTest */ public function testFilter(): void { + $user = User::factory()->create(); + (new LogEntry(['level' => LogLevel::DEBUG, 'message' => 'Some users fault', 'user_id' => $user->id]))->save(); foreach ( [ 'I\'m an info' => LogLevel::INFO, @@ -31,9 +34,10 @@ class LogEntryTest extends ModelTest (new LogEntry(['level' => $level, 'message' => $message]))->save(); } - $this->assertCount(10, LogEntry::filter()); + $this->assertCount(11, LogEntry::filter()); $this->assertCount(3, LogEntry::filter(LogLevel::INFO)); $this->assertCount(1, LogEntry::filter('Oops')); + $this->assertCount(1, LogEntry::filter(null, $user->id)); /** @var LogEntry $first */ $first = LogEntry::filter()->first();
{{ __('log.time') }} {{ __('log.level') }}{{ __('general.user') }} {{ __('log.message') }}
{{ entry.created_at.format(__('general.datetime')) }} + {{ entry.created_at.format(__('general.datetime')) }} {{ entry.level|capitalize }} {% if entry.user %}{{ m.user(entry.user) }}{% endif %} {{ entry.message|nl2br }}