Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions app/Listeners/AdminActivityListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

namespace App\Listeners;

use App\Facades\Activity;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Str;

class AdminActivityListener
{
protected const REDACTED_FIELDS = [
'password',
'password_confirmation',
'current_password',
'token',
'secret',
'api_key',
'daemon_token',
'_token',
];

public function handle(RecordCreated|RecordUpdated $event): void
{
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}

$record = $event->getRecord();
$page = $event->getPage();
$data = $event->getData();

$resourceClass = $page::getResource();
$modelClass = $resourceClass::getModel();
$slug = Str::kebab(class_basename($modelClass));

$action = $event instanceof RecordCreated ? 'create' : 'update';

$properties = $this->redactSensitiveFields($data);

Activity::event("admin:$slug.$action")
->subject($record)
->property($properties)
->log();
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function redactSensitiveFields(array $data): array
{
$redacted = [];

foreach ($data as $key => $value) {
if (in_array($key, self::REDACTED_FIELDS, true)) {
$redacted[$key] = '[REDACTED]';

continue;
}

if (is_array($value)) {
$redacted[$key] = $this->redactSensitiveFields($value);
} else {
$redacted[$key] = $value;
}
}

return $redacted;
}
}
5 changes: 5 additions & 0 deletions app/Providers/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace App\Providers;

use App\Listeners\AdminActivityListener;
use App\Listeners\DispatchWebhooks;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
Expand All @@ -15,5 +18,7 @@ class EventServiceProvider extends ServiceProvider
'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class],
RecordCreated::class => [AdminActivityListener::class],
RecordUpdated::class => [AdminActivityListener::class],
Comment on lines +21 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Find AdminActivityListener and check its handle method
echo "=== AdminActivityListener location and handle signature ==="
find . -name "*AdminActivityListener*" -type f

echo ""
echo "=== AdminActivityListener handle method signature ==="
rg -n "class AdminActivityListener|function handle|public function handle" app/ --type php -A 3 | grep -A 10 "AdminActivityListener"

Repository: pelican-dev/panel

Length of output: 1470


🏁 Script executed:

#!/bin/bash

# Search for RecordCreated and RecordUpdated event definitions
echo "=== Searching for RecordCreated event definition ==="
rg -n "class RecordCreated" --type php -A 10

echo ""
echo "=== Searching for RecordUpdated event definition ==="
rg -n "class RecordUpdated" --type php -A 10

Repository: pelican-dev/panel

Length of output: 167


🏁 Script executed:

#!/bin/bash

# Check how RecordCreated/RecordUpdated are dispatched to see actual parameters
echo "=== How RecordCreated is dispatched ==="
rg -n "RecordCreated::dispatch|dispatch.*RecordCreated" --type php -B 2 -A 2

echo ""
echo "=== How RecordUpdated is dispatched ==="
rg -n "RecordUpdated::dispatch|dispatch.*RecordUpdated" --type php -B 2 -A 2

Repository: pelican-dev/panel

Length of output: 141


🌐 Web query:

Filament RecordCreated RecordUpdated event listener signature parameters

💡 Result:

Filament doesn’t ship Laravel events named RecordCreated / RecordUpdated for resource CRUD. Instead, you typically hook into:

1) Filament resource page hooks (recommended)

Create page (CreateRecord)

After a record is created, you can use the page’s $record property (the created Eloquent model) and $data (form state). (filamentphp.com)

use Filament\Resources\Pages\CreateRecord;

class CreateUser extends CreateRecord
{
    protected function afterCreate(): void
    {
        $record = $this->record; // Eloquent model
        $data   = $this->data;   // validated form data array
    }
}

Edit page (EditRecord)

Updates happen via handleRecordUpdate(Model $record, array $data) (and you can use $this->record as well). (filamentphp.com)

use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;

class EditUser extends EditRecord
{
    protected function afterSave(): void
    {
        $record = $this->record; // updated model
    }

    protected function handleRecordUpdate(Model $record, array $data): Model
    {
        // $record = model being updated, $data = form data
        return parent::handleRecordUpdate($record, $data);
    }
}

2) Laravel / Eloquent model events (framework-level)

If you want true “created/updated” listeners, use Eloquent events/observers (created(Model $model), updated(Model $model), etc.). (laravel.com)

If you meant a third-party Filament plugin that defines RecordCreated / RecordUpdated, tell me the package name/namespace and Filament major version (v2/v3/v4), and I’ll pull the exact event class + listener method signature.

Citations:


Remove or implement RecordCreated and RecordUpdated events—they do not exist in Filament core or this codebase.

The listener registrations on lines 21-22 reference RecordCreated and RecordUpdated events that are not defined anywhere in the codebase or Filament's core library. These events are never dispatched, so the AdminActivityListener will never be triggered. Either:

  1. Define and dispatch these custom events explicitly in the appropriate resource pages, or
  2. Use Laravel's Eloquent model events (created, updated) with an observer/listener, or
  3. Hook into Filament's page lifecycle methods (afterCreate(), afterSave()) directly on resource pages.

Without dispatching these events, admin activity logging will not function.

🤖 Prompt for AI Agents
In `@app/Providers/EventServiceProvider.php` around lines 21 - 22, The
EventServiceProvider registers non-existent events RecordCreated and
RecordUpdated for AdminActivityListener so the listener is never invoked; either
remove those registrations from EventServiceProvider and instead register a
model observer for the Eloquent events (created/updated) that points to your
AdminActivityListener (or create a dedicated AdminActivityObserver and register
it via the EventServiceProvider or in a service provider), or implement and
dispatch custom RecordCreated and RecordUpdated events where records are
created/updated (e.g., from your Filament resource pages) or move the logging
logic into Filament resource lifecycle hooks like afterCreate() / afterSave() on
the relevant Resource/Page classes to call the AdminActivityListener
functionality.

];
}
16 changes: 16 additions & 0 deletions app/Providers/Filament/FilamentServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

use App\Enums\CustomizationKey;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\View\ActionsIconAlias;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Field;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
Expand All @@ -29,8 +31,10 @@
use Filament\Tables\View\TablesIconAlias;
use Filament\View\PanelsIconAlias;
use Filament\View\PanelsRenderHook;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\Livewire;

Expand Down Expand Up @@ -132,6 +136,18 @@ public function boot(): void
$action->iconButton();
$action->iconSize(IconSize::ExtraLarge);
}

$action->before(function (Model $record) {
if (Filament::getCurrentPanel()?->getId() !== 'admin') {
return;
}

$slug = Str::kebab(class_basename($record));

Activity::event("admin:$slug.delete")
->subject($record)
->log();
});
});

CreateAction::configureUsing(function (CreateAction $action) {
Expand Down
47 changes: 47 additions & 0 deletions lang/en/activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,51 @@
],
'crashed' => 'Server crashed',
],
'admin' => [
'user' => [
'create' => 'Created user <b>:username</b>',
'update' => 'Updated user <b>:username</b>',
'delete' => 'Deleted user <b>:username</b>',
],
'server' => [
'create' => 'Created server <b>:name</b>',
'update' => 'Updated server <b>:name</b>',
'delete' => 'Deleted server <b>:name</b>',
],
'node' => [
'create' => 'Created node <b>:name</b>',
'update' => 'Updated node <b>:name</b>',
'delete' => 'Deleted node <b>:name</b>',
],
'egg' => [
'create' => 'Created egg <b>:name</b>',
'update' => 'Updated egg <b>:name</b>',
'delete' => 'Deleted egg <b>:name</b>',
],
'role' => [
'create' => 'Created role <b>:name</b>',
'update' => 'Updated role <b>:name</b>',
'delete' => 'Deleted role <b>:name</b>',
],
'database-host' => [
'create' => 'Created database host <b>:name</b>',
'update' => 'Updated database host <b>:name</b>',
'delete' => 'Deleted database host <b>:name</b>',
],
'mount' => [
'create' => 'Created mount <b>:name</b>',
'update' => 'Updated mount <b>:name</b>',
'delete' => 'Deleted mount <b>:name</b>',
],
'webhook-configuration' => [
'create' => 'Created webhook <b>:description</b>',
'update' => 'Updated webhook <b>:description</b>',
'delete' => 'Deleted webhook <b>:description</b>',
],
'api-key' => [
'create' => 'Created API key',
'update' => 'Updated API key',
'delete' => 'Deleted API key',
],
],
];
158 changes: 158 additions & 0 deletions tests/Filament/Admin/AdminActivityListenerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

use App\Events\ActivityLogged;
use App\Filament\Admin\Resources\Eggs\Pages\CreateEgg;
use App\Filament\Admin\Resources\Eggs\Pages\EditEgg;
use App\Filament\Admin\Resources\Nodes\Pages\CreateNode;
use App\Filament\Admin\Resources\Nodes\Pages\EditNode;
use App\Listeners\AdminActivityListener;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Resources\Events\RecordCreated;
use Filament\Resources\Events\RecordUpdated;
use Illuminate\Support\Facades\Event;

function pageInstance(string $class): object
{
return (new ReflectionClass($class))->newInstanceWithoutConstructor();
}

function createEvent(object $record, array $data, object $page): RecordCreated
{
return new RecordCreated($record, $data, $page);
}

function updateEvent(object $record, array $data, object $page): RecordUpdated
{
return new RecordUpdated($record, $data, $page);
}

beforeEach(function () {
[$this->admin] = generateTestAccount([]);
$this->admin = $this->admin->syncRoles(Role::getRootAdmin());
$this->actingAs($this->admin);

Filament::setCurrentPanel('admin');
});

it('logs create activity for an egg', function () {
$egg = Egg::first();

$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test Egg'], pageInstance(CreateEgg::class)));

$this->assertActivityLogged('admin:egg.create');
});

it('logs update activity for an egg', function () {
$egg = Egg::first();

$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, ['name' => 'Updated Egg'], pageInstance(EditEgg::class)));

$this->assertActivityLogged('admin:egg.update');
});

it('logs create activity for a node', function () {
$node = Node::first();

$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test Node'], pageInstance(CreateNode::class)));

$this->assertActivityLogged('admin:node.create');
});

it('logs update activity for a node', function () {
$node = Node::first();

$listener = new AdminActivityListener();
$listener->handle(updateEvent($node, ['name' => 'Updated Node'], pageInstance(EditNode::class)));

$this->assertActivityLogged('admin:node.update');
});

it('does not log activity for non-admin panels', function () {
Filament::setCurrentPanel('app');

$egg = Egg::first();

$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));

Event::assertNotDispatched(ActivityLogged::class);
});

it('sets the record as the activity subject', function () {
$egg = Egg::first();

$listener = new AdminActivityListener();
$listener->handle(createEvent($egg, ['name' => 'Test'], pageInstance(CreateEgg::class)));

$this->assertActivityFor('admin:egg.create', $this->admin, $egg);
});

it('redacts sensitive fields from activity properties', function () {
$egg = Egg::first();

$data = [
'name' => 'Visible',
'password' => 'should-be-redacted',
'password_confirmation' => 'should-be-redacted',
'token' => 'should-be-redacted',
'secret' => 'should-be-redacted',
'api_key' => 'should-be-redacted',
];

$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));

Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;

expect($properties)->toHaveKey('name', 'Visible')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('password_confirmation', '[REDACTED]')
->toHaveKey('token', '[REDACTED]')
->toHaveKey('secret', '[REDACTED]')
->toHaveKey('api_key', '[REDACTED]');

return true;
});
});

it('redacts sensitive fields in nested arrays', function () {
$egg = Egg::first();

$data = [
'name' => 'Visible',
'nested' => [
'safe' => 'value',
'password' => 'should-be-redacted',
'token' => 'should-be-redacted',
],
];

$listener = new AdminActivityListener();
$listener->handle(updateEvent($egg, $data, pageInstance(EditEgg::class)));

Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $event) {
$properties = $event->model->properties;

expect($properties['nested'])->toHaveKey('safe', 'value')
->toHaveKey('password', '[REDACTED]')
->toHaveKey('token', '[REDACTED]');

return true;
});
});

it('generates kebab-case event names from model class names', function () {
$node = Node::first();

$listener = new AdminActivityListener();
$listener->handle(createEvent($node, ['name' => 'Test'], pageInstance(CreateNode::class)));

$this->assertActivityLogged('admin:node.create');
});