diff --git a/app/Filament/Admin/Resources/ApiKeys/ApiKeyResource.php b/app/Filament/Admin/Resources/ApiKeys/ApiKeyResource.php index c8a26de50b..966c8a75cf 100644 --- a/app/Filament/Admin/Resources/ApiKeys/ApiKeyResource.php +++ b/app/Filament/Admin/Resources/ApiKeys/ApiKeyResource.php @@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\ApiKeys\Pages\CreateApiKey; use App\Filament\Admin\Resources\ApiKeys\Pages\ListApiKeys; use App\Filament\Admin\Resources\Users\Pages\EditUser; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Models\ApiKey; use App\Traits\Filament\CanCustomizePages; @@ -15,7 +16,6 @@ use BackedEnum; use Exception; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteAction; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\ToggleButtons; @@ -99,7 +99,7 @@ public static function defaultTable(Table $table): Table ->url(fn (ApiKey $apiKey) => user()?->can('update', $apiKey->user) ? EditUser::getUrl(['record' => $apiKey->user]) : null), ]) ->recordActions([ - DeleteAction::make(), + LoggedDeleteAction::make(), ]) ->toolbarActions([ CreateAction::make(), diff --git a/app/Filament/Admin/Resources/ApiKeys/Pages/CreateApiKey.php b/app/Filament/Admin/Resources/ApiKeys/Pages/CreateApiKey.php index 0b4b382636..451c1be092 100644 --- a/app/Filament/Admin/Resources/ApiKeys/Pages/CreateApiKey.php +++ b/app/Filament/Admin/Resources/ApiKeys/Pages/CreateApiKey.php @@ -3,17 +3,17 @@ namespace App\Filament\Admin\Resources\ApiKeys\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\ApiKeys\ApiKeyResource; use App\Models\ApiKey; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; -class CreateApiKey extends CreateRecord +class CreateApiKey extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php b/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php index 4655696ee9..89cdd688ce 100644 --- a/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php +++ b/app/Filament/Admin/Resources/BackupHosts/Pages/CreateBackupHost.php @@ -3,14 +3,14 @@ namespace App\Filament\Admin\Resources\BackupHosts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\BackupHosts\BackupHostResource; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; -class CreateBackupHost extends CreateRecord +class CreateBackupHost extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/BackupHosts/Pages/EditBackupHost.php b/app/Filament/Admin/Resources/BackupHosts/Pages/EditBackupHost.php index d3871fcb57..33fcd3ebf8 100644 --- a/app/Filament/Admin/Resources/BackupHosts/Pages/EditBackupHost.php +++ b/app/Filament/Admin/Resources/BackupHosts/Pages/EditBackupHost.php @@ -3,16 +3,16 @@ namespace App\Filament\Admin\Resources\BackupHosts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\BackupHosts\BackupHostResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\BackupHost; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; -class EditBackupHost extends EditRecord +class EditBackupHost extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -23,7 +23,7 @@ class EditBackupHost extends EditRecord protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->label(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0 ? trans('admin/backuphost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label')) ->disabled(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0) ->hidden(fn () => BackupHost::count() === 1), diff --git a/app/Filament/Admin/Resources/DatabaseHosts/DatabaseHostResource.php b/app/Filament/Admin/Resources/DatabaseHosts/DatabaseHostResource.php index 5afa55ae6f..0a5f2ce6b2 100644 --- a/app/Filament/Admin/Resources/DatabaseHosts/DatabaseHostResource.php +++ b/app/Filament/Admin/Resources/DatabaseHosts/DatabaseHostResource.php @@ -8,6 +8,7 @@ use App\Filament\Admin\Resources\DatabaseHosts\Pages\ListDatabaseHosts; use App\Filament\Admin\Resources\DatabaseHosts\Pages\ViewDatabaseHost; use App\Filament\Admin\Resources\DatabaseHosts\RelationManagers\DatabasesRelationManager; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Models\DatabaseHost; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; @@ -17,7 +18,6 @@ use Exception; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\Select; @@ -102,7 +102,7 @@ public static function defaultTable(Table $table): Table ->toolbarActions([ CreateAction::make(), BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete'), + LoggedDeleteBulkAction::make('exclude_bulk_delete'), ]), ]) ->emptyStateIcon(TablerIcon::Database) diff --git a/app/Filament/Admin/Resources/DatabaseHosts/Pages/CreateDatabaseHost.php b/app/Filament/Admin/Resources/DatabaseHosts/Pages/CreateDatabaseHost.php index 737bdfb3ff..642ee1d460 100644 --- a/app/Filament/Admin/Resources/DatabaseHosts/Pages/CreateDatabaseHost.php +++ b/app/Filament/Admin/Resources/DatabaseHosts/Pages/CreateDatabaseHost.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\DatabaseHosts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\DatabaseHosts\DatabaseHostResource; use App\Services\Databases\Hosts\HostCreationService; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -15,7 +16,6 @@ use Filament\Forms\Components\Toggle; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; -use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord\Concerns\HasWizard; use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Utilities\Get; @@ -29,7 +29,7 @@ use PDOException; use Throwable; -class CreateDatabaseHost extends CreateRecord +class CreateDatabaseHost extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/DatabaseHosts/Pages/EditDatabaseHost.php b/app/Filament/Admin/Resources/DatabaseHosts/Pages/EditDatabaseHost.php index a7672cf579..0e3d80e9c6 100644 --- a/app/Filament/Admin/Resources/DatabaseHosts/Pages/EditDatabaseHost.php +++ b/app/Filament/Admin/Resources/DatabaseHosts/Pages/EditDatabaseHost.php @@ -3,21 +3,21 @@ namespace App\Filament\Admin\Resources\DatabaseHosts\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\DatabaseHosts\DatabaseHostResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\DatabaseHost; use App\Services\Databases\Hosts\HostUpdateService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; -use Filament\Resources\Pages\EditRecord; use Filament\Support\Exceptions\Halt; use Illuminate\Database\Eloquent\Model; use PDOException; -class EditDatabaseHost extends EditRecord +class EditDatabaseHost extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -35,7 +35,7 @@ public function boot(HostUpdateService $hostUpdateService): void protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->tooltip(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0 ? trans('admin/databasehost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label')) ->disabled(fn (DatabaseHost $databaseHost) => $databaseHost->databases()->count() > 0), Action::make('save') diff --git a/app/Filament/Admin/Resources/DatabaseHosts/RelationManagers/DatabasesRelationManager.php b/app/Filament/Admin/Resources/DatabaseHosts/RelationManagers/DatabasesRelationManager.php index b319c94a55..7b44cacef3 100644 --- a/app/Filament/Admin/Resources/DatabaseHosts/RelationManagers/DatabasesRelationManager.php +++ b/app/Filament/Admin/Resources/DatabaseHosts/RelationManagers/DatabasesRelationManager.php @@ -3,10 +3,10 @@ namespace App\Filament\Admin\Resources\DatabaseHosts\RelationManagers; use App\Filament\Admin\Resources\Servers\Pages\EditServer; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Filament\Components\Actions\RotateDatabasePasswordAction; use App\Filament\Components\Tables\Columns\DateTimeColumn; use App\Models\Database; -use Filament\Actions\DeleteAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\TextInput; use Filament\Resources\RelationManagers\RelationManager; @@ -71,7 +71,7 @@ public function table(Table $table): Table ->recordActions([ ViewAction::make() ->color('primary'), - DeleteAction::make(), + LoggedDeleteAction::make(), ]); } } diff --git a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php index c3074b0af4..0b4babf1e1 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php @@ -6,6 +6,7 @@ use App\Filament\Admin\Resources\Eggs\EggResource; use App\Filament\Components\Actions\ExportEggAction; use App\Filament\Components\Actions\ImportEggAction; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Filament\Components\Actions\UpdateEggAction; use App\Filament\Components\Actions\UpdateEggBulkAction; use App\Filament\Components\Tables\Filters\TagsFilter; @@ -15,7 +16,6 @@ use Exception; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ReplicateAction; use Filament\Actions\ViewAction; @@ -88,7 +88,7 @@ public function table(Table $table): Table ->multiple(), CreateAction::make(), BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete') + LoggedDeleteBulkAction::make('exclude_bulk_delete') ->before(function (Collection &$records) { $eggsWithServers = $records->filter(fn (Egg $egg) => $egg->servers_count > 0); diff --git a/app/Filament/Admin/Resources/Mounts/MountResource.php b/app/Filament/Admin/Resources/Mounts/MountResource.php index 75e1836144..99758a4bf6 100644 --- a/app/Filament/Admin/Resources/Mounts/MountResource.php +++ b/app/Filament/Admin/Resources/Mounts/MountResource.php @@ -7,6 +7,7 @@ use App\Filament\Admin\Resources\Mounts\Pages\EditMount; use App\Filament\Admin\Resources\Mounts\Pages\ListMounts; use App\Filament\Admin\Resources\Mounts\Pages\ViewMount; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Models\Mount; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; @@ -16,7 +17,6 @@ use Exception; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\Select; @@ -110,7 +110,7 @@ public static function defaultTable(Table $table): Table ->toolbarActions([ CreateAction::make(), BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete'), + LoggedDeleteBulkAction::make('exclude_bulk_delete'), ]), ]) ->emptyStateIcon(TablerIcon::LayersLinked) diff --git a/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php b/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php index 7f09e9c58b..0f872d94e7 100644 --- a/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php @@ -5,6 +5,8 @@ use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Servers\Pages\CreateServer; use App\Filament\Admin\Resources\Servers\Pages\EditServer; +use App\Filament\Components\Actions\LoggedDeleteAction; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Filament\Components\Actions\UpdateNodeAllocations; use App\Models\Allocation; use App\Models\Node; @@ -13,8 +15,6 @@ use Exception; use Filament\Actions\Action; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteAction; -use Filament\Actions\DeleteBulkAction; use Filament\Forms\Components\Select; use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; @@ -96,7 +96,7 @@ public function table(Table $table): Table ]) ->emptyStateHeading(trans('admin/node.no_allocations')) ->recordActions([ - DeleteAction::make() + LoggedDeleteAction::make() ->visible(fn (Allocation $allocation) => $allocation->server_id === null), ]) ->toolbarActions([ @@ -158,7 +158,7 @@ public function table(Table $table): Table ->nodeRecord($this->getOwnerRecord()) ->authorize(fn () => user()?->can('update', $this->getOwnerRecord())) ->hidden(fn () => $this->isReadOnly()), - DeleteBulkAction::make() + LoggedDeleteBulkAction::make() ->hidden(fn () => $this->isReadOnly()), ]); } diff --git a/app/Filament/Admin/Resources/Roles/Pages/CreateRole.php b/app/Filament/Admin/Resources/Roles/Pages/CreateRole.php index f131d86f24..800c2cf948 100644 --- a/app/Filament/Admin/Resources/Roles/Pages/CreateRole.php +++ b/app/Filament/Admin/Resources/Roles/Pages/CreateRole.php @@ -3,13 +3,13 @@ namespace App\Filament\Admin\Resources\Roles\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Roles\RoleResource; use App\Models\Role; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Spatie\Permission\Models\Permission; @@ -17,7 +17,7 @@ /** * @property Role $record */ -class CreateRole extends CreateRecord +class CreateRole extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -70,5 +70,7 @@ protected function afterCreate(): void }); $this->record->syncPermissions($permissionModels); + + parent::afterCreate(); } } diff --git a/app/Filament/Admin/Resources/Roles/Pages/EditRole.php b/app/Filament/Admin/Resources/Roles/Pages/EditRole.php index 9f531ec747..7f3146aaa7 100644 --- a/app/Filament/Admin/Resources/Roles/Pages/EditRole.php +++ b/app/Filament/Admin/Resources/Roles/Pages/EditRole.php @@ -3,14 +3,14 @@ namespace App\Filament\Admin\Resources\Roles\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Roles\RoleResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\Role; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Spatie\Permission\Models\Permission; @@ -18,7 +18,7 @@ /** * @property Role $record */ -class EditRole extends EditRecord +class EditRole extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -42,6 +42,8 @@ protected function mutateFormDataBeforeSave(array $data): array protected function afterSave(): void { + $oldPermissions = $this->record->permissions()->pluck('name')->sort()->values(); + $permissionModels = collect(); $this->permissions->each(function ($permission) use ($permissionModels) { $permissionModels->push(Permission::firstOrCreate([ @@ -51,13 +53,27 @@ protected function afterSave(): void }); $this->record->syncPermissions($permissionModels); + + $newPermissions = $this->record->permissions()->pluck('name')->sort()->values(); + + // One combined update event covering attribute and permission changes, + // so parent::afterSave() is deliberately not called here. + $changes = static::buildDiff($this->attributesBeforeSave, $this->record->getChanges()); + + if ($oldPermissions->all() !== $newPermissions->all()) { + $changes['permissions'] = ['old' => $oldPermissions->all(), 'new' => $newPermissions->all()]; + } + + if ($changes !== []) { + static::logAdminActivity('update', $this->record, ['changes' => $changes]); + } } /** @return array */ protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make() + LoggedDeleteAction::make() ->tooltip(fn (Role $role) => $role->isRootAdmin() ? trans('admin/role.root_admin_delete') : ($role->users_count >= 1 ? trans('admin/role.in_use') : trans('filament-actions::delete.single.label'))) ->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1), Action::make('save') diff --git a/app/Filament/Admin/Resources/Roles/RoleResource.php b/app/Filament/Admin/Resources/Roles/RoleResource.php index 0988d1eda8..668021796b 100644 --- a/app/Filament/Admin/Resources/Roles/RoleResource.php +++ b/app/Filament/Admin/Resources/Roles/RoleResource.php @@ -8,6 +8,7 @@ use App\Filament\Admin\Resources\Roles\Pages\EditRole; use App\Filament\Admin\Resources\Roles\Pages\ListRoles; use App\Filament\Admin\Resources\Roles\Pages\ViewRole; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Models\Role; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; @@ -18,7 +19,6 @@ use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Forms\Components\CheckboxList; @@ -106,7 +106,7 @@ public static function defaultTable(Table $table): Table ->toolbarActions([ CreateAction::make(), BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete'), + LoggedDeleteBulkAction::make('exclude_bulk_delete'), ]), ]) ->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0); diff --git a/app/Filament/Admin/Resources/Users/UserResource.php b/app/Filament/Admin/Resources/Users/UserResource.php index 801c92021d..d339199624 100644 --- a/app/Filament/Admin/Resources/Users/UserResource.php +++ b/app/Filament/Admin/Resources/Users/UserResource.php @@ -11,6 +11,7 @@ use App\Filament\Admin\Resources\Users\Pages\ListUsers; use App\Filament\Admin\Resources\Users\Pages\ViewUser; use App\Filament\Admin\Resources\Users\RelationManagers\ServersRelationManager; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Models\ActivityLog; use App\Models\ApiKey; use App\Models\Role; @@ -28,7 +29,6 @@ use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Auth\Notifications\ResetPassword; @@ -148,7 +148,7 @@ public static function defaultTable(Table $table): Table ]) ->toolbarActions([ BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete'), + LoggedDeleteBulkAction::make('exclude_bulk_delete'), ]), CreateAction::make() ->hiddenLabel() diff --git a/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php b/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php index c93a529ccc..b8b4789b2c 100644 --- a/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php +++ b/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php @@ -4,16 +4,16 @@ use App\Enums\TablerIcon; use App\Enums\WebhookScope; +use App\Filament\Admin\Pages\BaseAdminCreateRecord; use App\Filament\Admin\Resources\Webhooks\WebhookResource; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\MutatesWebhookFormData; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Resources\Pages\CreateRecord; use Illuminate\Validation\ValidationException; -class CreateWebhookConfiguration extends CreateRecord +class CreateWebhookConfiguration extends BaseAdminCreateRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; diff --git a/app/Filament/Admin/Resources/Webhooks/Pages/EditWebhookConfiguration.php b/app/Filament/Admin/Resources/Webhooks/Pages/EditWebhookConfiguration.php index 9accc0d96b..50f389f198 100644 --- a/app/Filament/Admin/Resources/Webhooks/Pages/EditWebhookConfiguration.php +++ b/app/Filament/Admin/Resources/Webhooks/Pages/EditWebhookConfiguration.php @@ -3,17 +3,17 @@ namespace App\Filament\Admin\Resources\Webhooks\Pages; use App\Enums\TablerIcon; +use App\Filament\Admin\Pages\BaseAdminEditRecord; use App\Filament\Admin\Resources\Webhooks\WebhookResource; +use App\Filament\Components\Actions\LoggedDeleteAction; use App\Models\WebhookConfiguration; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\MutatesWebhookFormData; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; -use Filament\Resources\Pages\EditRecord; -class EditWebhookConfiguration extends EditRecord +class EditWebhookConfiguration extends BaseAdminEditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; @@ -25,7 +25,7 @@ class EditWebhookConfiguration extends EditRecord protected function getDefaultHeaderActions(): array { return [ - DeleteAction::make(), + LoggedDeleteAction::make(), Action::make('test_now') ->tooltip(trans('admin/webhook.test_now')) ->color('primary') @@ -58,6 +58,8 @@ protected function mutateFormDataBeforeFill(array $data): array protected function afterSave(): void { + parent::afterSave(); + $this->dispatch('refresh-widget'); } diff --git a/app/Filament/Admin/Resources/Webhooks/WebhookResource.php b/app/Filament/Admin/Resources/Webhooks/WebhookResource.php index 09b05967da..e604973429 100644 --- a/app/Filament/Admin/Resources/Webhooks/WebhookResource.php +++ b/app/Filament/Admin/Resources/Webhooks/WebhookResource.php @@ -10,6 +10,7 @@ use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations; use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration; +use App\Filament\Components\Actions\LoggedDeleteBulkAction; use App\Livewire\AlertBanner; use App\Models\Server; use App\Models\WebhookConfiguration; @@ -20,7 +21,6 @@ use BackedEnum; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ReplicateAction; use Filament\Actions\ViewAction; @@ -124,7 +124,7 @@ public static function defaultTable(Table $table): Table ->toolbarActions([ CreateAction::make(), BulkActionGroup::make([ - DeleteBulkAction::make('exclude_bulk_delete'), + LoggedDeleteBulkAction::make('exclude_bulk_delete'), ]), ]) ->emptyStateIcon(TablerIcon::Webhook) diff --git a/app/Filament/Components/Actions/LoggedDeleteBulkAction.php b/app/Filament/Components/Actions/LoggedDeleteBulkAction.php new file mode 100644 index 0000000000..f27287ee79 --- /dev/null +++ b/app/Filament/Components/Actions/LoggedDeleteBulkAction.php @@ -0,0 +1,21 @@ +after(fn (Collection $records) => $records->each(fn (Model $record) => static::logAdminActivity('delete', $record))); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7205b65b2b..f7f7c91119 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,16 +15,20 @@ use App\Models\Allocation; use App\Models\ApiKey; use App\Models\Backup; +use App\Models\BackupHost; use App\Models\Database; +use App\Models\DatabaseHost; use App\Models\Egg; use App\Models\EggVariable; use App\Models\Mount; use App\Models\Node; +use App\Models\Role; use App\Models\Schedule; use App\Models\Server; use App\Models\Task; use App\Models\User; use App\Models\UserSSHKey; +use App\Models\WebhookConfiguration; use App\Services\Helpers\PluginService; use App\Services\Helpers\SoftwareVersionService; use Dedoc\Scramble\Scramble; @@ -78,8 +82,12 @@ public function boot( 'ssh_key' => UserSSHKey::class, 'task' => Task::class, 'user' => User::class, + 'backup_host' => BackupHost::class, + 'database_host' => DatabaseHost::class, 'mount' => Mount::class, 'node' => Node::class, + 'role' => Role::class, + 'webhook' => WebhookConfiguration::class, ]); Http::macro( diff --git a/app/Traits/Filament/LogsAdminActivity.php b/app/Traits/Filament/LogsAdminActivity.php index 1fe5e17d2f..f9c3020f91 100644 --- a/app/Traits/Filament/LogsAdminActivity.php +++ b/app/Traits/Filament/LogsAdminActivity.php @@ -43,7 +43,7 @@ trait LogsAdminActivity * * @var string[] */ - protected static array $identifyingAttributes = ['id', 'uuid', 'name', 'username', 'email']; + protected static array $identifyingAttributes = ['id', 'uuid', 'name', 'username', 'email', 'identifier', 'endpoint', 'database', 'ip', 'port']; public static function activityKey(Model $record): string { @@ -68,7 +68,9 @@ public static function logAdminActivity(string $action, Model $record, array $pr */ public static function identify(Model $record): array { - return array_intersect_key($record->getAttributes(), array_flip(static::$identifyingAttributes)); + $attributes = array_intersect_key($record->getAttributes(), array_flip(static::$identifyingAttributes)); + + return collect($attributes)->map(fn ($value, $key) => static::redact($key, $value))->all(); } /** @@ -98,7 +100,28 @@ public static function buildDiff(array $old, array $new): array public static function redact(string $key, mixed $value): mixed { - if ($value !== null && $value !== '' && Str::is(static::$redactedAttributePatterns, strtolower($key))) { + if ($value === null || $value === '') { + return $value; + } + + // URLs can carry credentials in user-info or the query string. + if (strtolower($key) === 'endpoint' && is_string($value)) { + $parts = parse_url($value); + + if (!is_array($parts) || !isset($parts['host'])) { + return '********'; + } + + return sprintf( + '%s%s%s%s', + isset($parts['scheme']) ? $parts['scheme'] . '://' : '', + $parts['host'], + isset($parts['port']) ? ':' . $parts['port'] : '', + $parts['path'] ?? '', + ); + } + + if (Str::is(static::$redactedAttributePatterns, strtolower($key))) { return '********'; } diff --git a/lang/en/activity.php b/lang/en/activity.php index af32e73a95..a048906bfd 100644 --- a/lang/en/activity.php +++ b/lang/en/activity.php @@ -41,6 +41,26 @@ 'update' => 'Updated user :username', 'delete' => 'Deleted user :username', ], + 'allocation' => [ + 'delete' => 'Deleted allocation :ip::port', + ], + 'apiKey' => [ + 'create' => 'Created API key :identifier', + 'delete' => 'Deleted API key :identifier', + ], + 'backupHost' => [ + 'create' => 'Created backup host :name', + 'update' => 'Updated backup host :name', + 'delete' => 'Deleted backup host :name', + ], + 'database' => [ + 'delete' => 'Deleted database :database', + ], + 'databaseHost' => [ + 'create' => 'Created database host :name', + 'update' => 'Updated database host :name', + 'delete' => 'Deleted database host :name', + ], 'egg' => [ 'create' => 'Created egg :name', 'update' => 'Updated egg :name', @@ -56,6 +76,16 @@ 'update' => 'Updated node :name', 'delete' => 'Deleted node :name', ], + 'role' => [ + 'create' => 'Created role :name', + 'update' => 'Updated role :name', + 'delete' => 'Deleted role :name', + ], + 'webhook' => [ + 'create' => 'Created webhook :endpoint', + 'update' => 'Updated webhook :endpoint', + 'delete' => 'Deleted webhook :endpoint', + ], 'server' => [ 'console' => [ 'command' => 'Executed ":command" on the server', diff --git a/tests/Assertions/AssertsActivityLogged.php b/tests/Assertions/AssertsActivityLogged.php index aa8d75a029..60a6cfb7ab 100644 --- a/tests/Assertions/AssertsActivityLogged.php +++ b/tests/Assertions/AssertsActivityLogged.php @@ -5,6 +5,7 @@ use App\Events\ActivityLogged; use App\Models\ActivityLogSubject; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Assert; @@ -15,9 +16,13 @@ trait AssertsActivityLogged */ public function assertActivityFor(string $event, ?Model $actor, ...$subjects): void { - $this->assertActivityLogged($event); - $this->assertActivityActor($event, $actor); - $this->assertActivitySubjects($event, ...$subjects); + // One predicate, so the actor and subjects must match on the SAME event + // rather than being satisfied by two different events of the same name. + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) use ($event, $actor, $subjects) { + return $e->is($event) + && $this->activityActorMatches($e, $actor) + && $this->activitySubjectsMatch($e, $subjects); + }); } /** @@ -40,24 +45,49 @@ public function assertActivitySubjects(string $event, Model|array $subjects): vo $subjects = array_slice(func_get_args(), 1); } - Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) use ($event, $subjects) { - Assert::assertEquals($event, $e->model->event); - Assert::assertNotEmpty($e->model->subjects); + // Filter rather than assert inside the closure, so a test that logged + // several events matches the one carrying all the expected subjects. + Event::assertDispatched(ActivityLogged::class, fn (ActivityLogged $e) => $e->is($event) && $this->activitySubjectsMatch($e, $subjects)); + } + + private function activitySubjectsMatch(ActivityLogged $e, array $subjects): bool + { + // Callers pass models variadically or as a single array; accept both. + $normalized = []; + foreach ($subjects as $subject) { + array_push($normalized, ...Arr::wrap($subject)); + } + $subjects = $normalized; + + if ($subjects === []) { + return true; + } + + if ($e->model->subjects->isEmpty()) { + return false; + } - foreach ($subjects as $subject) { - $match = $e->model->subjects->first(function (ActivityLogSubject $model) use ($subject) { - return $model->subject_type === $subject->getMorphClass() - && $model->subject_id = $subject->getKey(); - }); + foreach ($subjects as $subject) { + $match = $e->model->subjects->first(function (ActivityLogSubject $model) use ($subject) { + return $model->subject_type === $subject->getMorphClass() + && $model->subject_id === $subject->getKey(); + }); - Assert::assertNotNull( - $match, - sprintf('Failed asserting that event "%s" includes a %s[%d] subject', $event, get_class($subject), $subject->getKey()) - ); + if (is_null($match)) { + return false; } + } - return true; - }); + return true; + } + + private function activityActorMatches(ActivityLogged $e, ?Model $actor): bool + { + if (is_null($actor)) { + return is_null($e->actor()); + } + + return !is_null($e->actor()) && $e->actor()->is($actor); } /** @@ -66,17 +96,6 @@ public function assertActivitySubjects(string $event, Model|array $subjects): vo */ public function assertActivityActor(string $event, ?Model $actor = null): void { - Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) use ($event, $actor) { - Assert::assertEquals($event, $e->model->event); - - if (is_null($actor)) { - Assert::assertNull($e->actor()); - } else { - Assert::assertNotNull($e->actor()); - Assert::assertTrue($e->actor()->is($actor)); - } - - return true; - }); + Event::assertDispatched(ActivityLogged::class, fn (ActivityLogged $e) => $e->is($event) && $this->activityActorMatches($e, $actor)); } } diff --git a/tests/Filament/Admin/AdminActivityLoggingRestTest.php b/tests/Filament/Admin/AdminActivityLoggingRestTest.php new file mode 100644 index 0000000000..16e1d4a4cc --- /dev/null +++ b/tests/Filament/Admin/AdminActivityLoggingRestTest.php @@ -0,0 +1,284 @@ +admin] = generateTestAccount(); + $this->admin->syncRoles(Role::getRootAdmin()); + $this->actingAs($this->admin); +}); +afterEach(fn () => Filament::setCurrentPanel(null)); + +// Same trick as DatabaseHostCrudTest: the host services confirm access via +// getPdo(), so fake the remote connection but keep the panel's own. +function fakeRemoteDbForActivityTests(): void +{ + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('getPdo')->andReturn(Mockery::mock(PDO::class)); + + $manager = Mockery::mock(app('db')); + $manager->shouldReceive('build')->andReturn($connection); + DB::swap($manager); + app()->instance('db', $manager); +} + +it('logs role create', function () { + livewire(CreateRole::class) + ->fillForm(['name' => 'Audited Role', 'user_list' => [RolePermissionModels::User->viewAny()]]) + ->call('create') + ->assertHasNoFormErrors(); + + $role = Role::findByName('Audited Role'); + $this->assertActivityFor('role:create', $this->admin, $role); +}); + +it('logs role rename with a diff', function () { + $role = Role::factory()->create(['name' => 'Old Name', 'guard_name' => 'web']); + + livewire(EditRole::class, ['record' => $role->getKey()]) + ->fillForm(['name' => 'New Name']) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertActivityFor('role:update', $this->admin, $role); +}); + +it('logs a permissions-only role change', function () { + $role = Role::factory()->create(['name' => 'Static Name', 'guard_name' => 'web']); + + livewire(EditRole::class, ['record' => $role->getKey()]) + ->fillForm(['user_list' => [RolePermissionModels::User->viewAny()]]) + ->call('save') + ->assertHasNoFormErrors(); + + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('role:update')) { + return false; + } + $changes = $e->model->properties['changes']; + + return in_array(RolePermissionModels::User->viewAny(), $changes['permissions']['new']); + }); +}); + +it('logs one combined role update event for name and permission changes', function () { + $role = Role::factory()->create(['name' => 'Old Name', 'guard_name' => 'web']); + + livewire(EditRole::class, ['record' => $role->getKey()]) + ->fillForm(['name' => 'New Name', 'user_list' => [RolePermissionModels::User->viewAny()]]) + ->call('save') + ->assertHasNoFormErrors(); + + $updates = Event::dispatched(ActivityLogged::class, fn (ActivityLogged $e) => $e->is('role:update')); + expect($updates)->toHaveCount(1); + + $changes = $updates->first()[0]->model->properties['changes']; + expect($changes['name']['new'])->toBe('New Name') + ->and($changes['permissions']['new'])->toContain(RolePermissionModels::User->viewAny()); +}); + +it('logs role delete', function () { + $role = Role::factory()->create(['name' => 'Doomed Role', 'guard_name' => 'web']); + + livewire(EditRole::class, ['record' => $role->getKey()]) + ->callAction(DeleteAction::class); + + $this->assertActivityFor('role:delete', $this->admin, $role); +}); + +it('logs api key create and delete', function () { + livewire(CreateApiKey::class) + ->fillForm(['memo' => 'audited key']) + ->call('create') + ->assertHasNoFormErrors(); + + $apiKey = ApiKey::query()->where('memo', 'audited key')->firstOrFail(); + $this->assertActivityFor('apiKey:create', $this->admin, $apiKey); + + livewire(ListApiKeys::class) + ->callAction(TestAction::make('delete')->table($apiKey)); + + $this->assertActivityFor('apiKey:delete', $this->admin, $apiKey); +}); + +it('logs backup host create, update, and delete with secrets masked', function () { + livewire(CreateBackupHost::class) + ->fillForm([ + 'name' => 'S3 Host', + 'schema' => 's3', + 'configuration.region' => 'us-east-1', + 'configuration.key' => 'test-key', + 'configuration.secret' => 'test-secret', + 'configuration.bucket' => 'test-bucket', + 'configuration.endpoint' => 'https://s3.example.com', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $backupHost = BackupHost::query()->where('name', 'S3 Host')->firstOrFail(); + $this->assertActivityFor('backupHost:create', $this->admin, $backupHost); + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + return $e->is('backupHost:create') && !str_contains(json_encode($e->model->properties), 'test-secret'); + }); + + // The delete action hides when only one backup host exists. + $other = BackupHost::factory()->create(); + + livewire(EditBackupHost::class, ['record' => $backupHost->getKey()]) + ->fillForm(['name' => 'Renamed Host']) + ->call('save') + ->assertHasNoFormErrors(); + $this->assertActivityFor('backupHost:update', $this->admin, $backupHost); + + livewire(EditBackupHost::class, ['record' => $backupHost->getKey()]) + ->callAction(DeleteAction::class); + $this->assertActivityFor('backupHost:delete', $this->admin, $backupHost); +}); + +it('logs database host create, update, and delete', function () { + fakeRemoteDbForActivityTests(); + + livewire(CreateDatabaseHost::class) + ->fillForm([ + 'host' => '127.0.0.1', + 'port' => 3306, + 'name' => 'audited-mysql', + 'username' => 'pelicanuser', + 'password' => 'secret1234', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $databaseHost = DatabaseHost::query()->where('name', 'audited-mysql')->firstOrFail(); + $this->assertActivityFor('databaseHost:create', $this->admin, $databaseHost); + + livewire(EditDatabaseHost::class, ['record' => $databaseHost->getKey()]) + ->fillForm(['name' => 'renamed-mysql']) + ->call('save') + ->assertHasNoFormErrors(); + $this->assertActivityFor('databaseHost:update', $this->admin, $databaseHost); + + livewire(EditDatabaseHost::class, ['record' => $databaseHost->getKey()]) + ->callAction(DeleteAction::class); + $this->assertActivityFor('databaseHost:delete', $this->admin, $databaseHost); +}); + +it('logs webhook create, update, and delete', function () { + livewire(CreateWebhookConfiguration::class) + ->fillForm([ + 'name' => 'Notifier', + 'description' => 'Notifies on new servers', + 'endpoint' => 'https://example.com/hook?token=abc123', + 'events' => ['eloquent.created: ' . Server::class], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $webhook = WebhookConfiguration::query()->where('name', 'Notifier')->firstOrFail(); + $this->assertActivityFor('webhook:create', $this->admin, $webhook); + // The logged endpoint is stripped to scheme://host/path, so query-string secrets stay out. + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + return $e->is('webhook:create') + && $e->model->properties['endpoint'] === 'https://example.com/hook' + && !str_contains(json_encode($e->model->properties), 'abc123'); + }); + + livewire(EditWebhookConfiguration::class, ['record' => $webhook->getKey()]) + ->fillForm(['name' => 'Renamed Hook', 'endpoint' => 'https://user:pass@example.org/hook2?token=newsecret']) + ->call('save') + ->assertHasNoFormErrors(); + $this->assertActivityFor('webhook:update', $this->admin, $webhook); + // buildDiff() must sanitize both sides of an endpoint change. + Event::assertDispatched(ActivityLogged::class, function (ActivityLogged $e) { + if (!$e->is('webhook:update')) { + return false; + } + $properties = json_encode($e->model->properties); + + return $e->model->properties['changes']['endpoint'] === ['old' => 'https://example.com/hook', 'new' => 'https://example.org/hook2'] + && !str_contains($properties, 'newsecret') + && !str_contains($properties, 'abc123') + && !str_contains($properties, 'user:pass'); + }); + + livewire(EditWebhookConfiguration::class, ['record' => $webhook->getKey()]) + ->callAction(DeleteAction::class); + $this->assertActivityFor('webhook:delete', $this->admin, $webhook); +}); + +it('logs allocation delete from the node relation manager', function () { + $node = Node::factory()->create(); + $allocation = Allocation::factory()->create(['node_id' => $node->id, 'server_id' => null]); + + livewire(AllocationsRelationManager::class, ['ownerRecord' => $node, 'pageClass' => EditNode::class]) + ->callAction(TestAction::make('delete')->table($allocation)); + + $this->assertActivityFor('allocation:delete', $this->admin, $allocation); +}); + +it('logs database delete from the database host relation manager', function () { + $server = Server::factory()->withNode()->create(); + $databaseHost = DatabaseHost::factory()->create(); + $database = Database::factory()->create(['database_host_id' => $databaseHost->id, 'server_id' => $server->id]); + + livewire(DatabasesRelationManager::class, ['ownerRecord' => $databaseHost, 'pageClass' => EditDatabaseHost::class]) + ->callAction(TestAction::make('delete')->table($database)); + + $this->assertActivityFor('database:delete', $this->admin, $database); +}); + +it('logs one event per record on bulk delete', function () { + $mounts = collect(['One', 'Two'])->map(fn ($name) => Mount::query()->create([ + 'uuid' => Str::uuid()->toString(), + 'name' => "Bulk Mount $name", + 'source' => "/mnt/bulk-$name", + 'target' => "/srv/bulk-$name", + 'read_only' => false, + 'user_mountable' => false, + ])); + + livewire(ListMounts::class) + ->selectTableRecords($mounts->pluck('id')->all()) + ->callAction(TestAction::make('exclude_bulk_delete')->table()->bulk()); + + $mounts->each(function (Mount $mount) { + $this->assertDatabaseMissing('mounts', ['id' => $mount->id]); + $this->assertActivityFor('mount:delete', $this->admin, $mount); + }); +}); diff --git a/tests/Unit/Traits/LogsAdminActivityTest.php b/tests/Unit/Traits/LogsAdminActivityTest.php index 4fa849d2e3..fd1326662b 100644 --- a/tests/Unit/Traits/LogsAdminActivityTest.php +++ b/tests/Unit/Traits/LogsAdminActivityTest.php @@ -54,6 +54,13 @@ public function test_redact_masks_matching_keys_and_leaves_others(): void $this->assertNull(self::redact('password', null)); } + public function test_redact_strips_credentials_and_query_from_endpoints(): void + { + $this->assertSame('https://example.com/hook', self::redact('endpoint', 'https://user:pass@example.com/hook?token=abc123#frag')); + $this->assertSame('https://example.com:8443/hook', self::redact('endpoint', 'https://example.com:8443/hook')); + $this->assertSame('********', self::redact('endpoint', 'not a url')); + } + public function test_identify_returns_identifying_attributes_only(): void { $node = new Node();