From 932fbbefcb79abdbefafdb8a52021106ba45c227 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 12 Sep 2026 15:03:07 -0400 Subject: [PATCH 1/3] Add admin-wide activity viewer behind new view activityLog permission --- .../Resources/Activities/ActivityResource.php | 170 ++++++++++++++++++ .../Activities/Pages/ListActivities.php | 21 +++ app/Models/Role.php | 1 + lang/en/admin/activity.php | 14 ++ tests/Filament/Admin/ListActivitiesTest.php | 66 +++++++ 5 files changed, 272 insertions(+) create mode 100644 app/Filament/Admin/Resources/Activities/ActivityResource.php create mode 100644 app/Filament/Admin/Resources/Activities/Pages/ListActivities.php create mode 100644 lang/en/admin/activity.php create mode 100644 tests/Filament/Admin/ListActivitiesTest.php diff --git a/app/Filament/Admin/Resources/Activities/ActivityResource.php b/app/Filament/Admin/Resources/Activities/ActivityResource.php new file mode 100644 index 0000000000..0db359de89 --- /dev/null +++ b/app/Filament/Admin/Resources/Activities/ActivityResource.php @@ -0,0 +1,170 @@ +paginated([25, 50]) + ->defaultPaginationPageOption(25) + ->columns([ + TextColumn::make('event') + ->label(trans('admin/activity.event')) + ->html() + ->description(fn ($state) => $state) + ->icon(fn (ActivityLog $activityLog) => $activityLog->getIcon()) + ->formatStateUsing(fn (ActivityLog $activityLog) => $activityLog->getLabel()), + TextColumn::make('user') + ->label(trans('admin/activity.user')) + ->state(fn (ActivityLog $activityLog) => self::actorName($activityLog)) + ->tooltip(fn (ActivityLog $activityLog) => $activityLog->getIp() ?? '') + ->url(fn (ActivityLog $activityLog) => $activityLog->actor instanceof User && user()?->can('update', $activityLog->actor) ? EditUser::getUrl(['record' => $activityLog->actor]) : '') + ->grow(false), + TextColumn::make('subjects') + ->label(trans('admin/activity.subject')) + ->state(fn (ActivityLog $activityLog) => $activityLog->subjects + ->map(fn (ActivityLogSubject $subject) => class_basename($subject->subject_type) . ' #' . $subject->subject_id) + ->unique() + ->join(', ')) + ->grow(false), + DateTimeColumn::make('timestamp') + ->label(trans('admin/activity.timestamp')) + ->since() + ->sortable() + ->grow(false), + ]) + ->defaultSort('timestamp', 'desc') + ->recordActions([ + ViewAction::make() + ->schema([ + TextEntry::make('event') + ->label(trans('admin/activity.event')) + ->state(fn (ActivityLog $activityLog) => new HtmlString($activityLog->getLabel())), + TextInput::make('user') + ->label(trans('admin/activity.user')) + ->formatStateUsing(function (ActivityLog $activityLog) { + $user = self::actorName($activityLog); + $ip = $activityLog->getIp(); + + return $ip ? "$user - $ip" : $user; + }), + DateTimePicker::make('timestamp') + ->label(trans('admin/activity.timestamp')), + KeyValue::make('properties') + ->label(trans('admin/activity.metadata')) + ->formatStateUsing(fn ($state) => Arr::dot($state)), + ]), + ]) + ->filters([ + SelectFilter::make('event') + ->label(trans('admin/activity.event')) + ->options(fn () => ActivityLog::whereNotIn('event', ActivityLog::DISABLED_EVENTS)->select('event')->distinct()->orderBy('event')->pluck('event', 'event')) + ->searchable() + ->preload(), + SelectFilter::make('actor_id') + ->label(trans('admin/activity.user')) + ->options(fn () => User::whereIn('id', ActivityLog::whereNotNull('actor_id')->select('actor_id'))->pluck('username', 'id')) + ->searchable() + ->preload(), + SelectFilter::make('subject_type') + ->label(trans('admin/activity.subject')) + ->options(fn () => ActivityLogSubject::select('subject_type')->distinct()->orderBy('subject_type')->pluck('subject_type', 'subject_type')->mapWithKeys(fn ($type) => [$type => class_basename($type)])) + ->query(fn (Builder $query, array $data) => $query->when($data['value'], fn (Builder $query, $value) => $query->whereHas('subjects', fn (Builder $query) => $query->where('subject_type', $value)))), + Filter::make('timestamp') + ->schema([ + DateTimePicker::make('from') + ->label(trans('admin/activity.from')), + DateTimePicker::make('until') + ->label(trans('admin/activity.until')), + ]) + ->query(fn (Builder $query, array $data) => $query + ->when($data['from'], fn (Builder $query, $value) => $query->where('timestamp', '>=', $value)) + ->when($data['until'], fn (Builder $query, $value) => $query->where('timestamp', '<=', $value))), + ]); + } + + /** @return Builder */ + public static function getEloquentQuery(): Builder + { + // Deliberately unscoped (and ignoring activity.hide_admin_activity): + // this is the panel-wide audit view for admins holding "view activityLog". + return ActivityLog::whereNotIn('event', ActivityLog::DISABLED_EVENTS); + } + + public static function canViewAny(): bool + { + return user()?->can('view activityLog') ?? false; + } + + public static function canAccess(): bool + { + return static::canViewAny(); + } + + /** @return array */ + public static function getDefaultPages(): array + { + return [ + 'index' => ListActivities::route('/'), + ]; + } + + public static function getNavigationLabel(): string + { + return trans('admin/activity.title'); + } + + public static function getNavigationGroup(): ?string + { + return trans('admin/dashboard.advanced'); + } + + private static function actorName(ActivityLog $activityLog): string + { + if (!$activityLog->actor instanceof User) { + return $activityLog->actor_id === null ? trans('admin/activity.system') : trans('admin/activity.deleted_user'); + } + + return "{$activityLog->actor->username} ({$activityLog->actor->email})"; + } +} diff --git a/app/Filament/Admin/Resources/Activities/Pages/ListActivities.php b/app/Filament/Admin/Resources/Activities/Pages/ListActivities.php new file mode 100644 index 0000000000..6657f80df9 --- /dev/null +++ b/app/Filament/Admin/Resources/Activities/Pages/ListActivities.php @@ -0,0 +1,21 @@ + [ + 'view', 'seeIps', ], 'panelLog' => [ diff --git a/lang/en/admin/activity.php b/lang/en/admin/activity.php new file mode 100644 index 0000000000..1e131453e5 --- /dev/null +++ b/lang/en/admin/activity.php @@ -0,0 +1,14 @@ + 'Activity', + 'event' => 'Event', + 'user' => 'User', + 'deleted_user' => 'Deleted User', + 'system' => 'System', + 'subject' => 'Subject', + 'timestamp' => 'Timestamp', + 'metadata' => 'Metadata', + 'from' => 'From', + 'until' => 'Until', +]; diff --git a/tests/Filament/Admin/ListActivitiesTest.php b/tests/Filament/Admin/ListActivitiesTest.php new file mode 100644 index 0000000000..d12aadb0fb --- /dev/null +++ b/tests/Filament/Admin/ListActivitiesTest.php @@ -0,0 +1,66 @@ + Filament::setCurrentPanel(Filament::getPanel('admin'))); +afterEach(fn () => Filament::setCurrentPanel(null)); + +it('root admin can see activity from every panel', function () { + [$admin, $server] = generateTestAccount([]); + $admin = $admin->syncRoles(Role::getRootAdmin()); + + Activity::event('auth:success')->actor($admin)->log(); + Activity::event('server:power.start')->subject($server)->log(); + + $this->actingAs($admin); + livewire(ListActivities::class) + ->assertSuccessful() + ->assertCountTableRecords(ActivityLog::count()) + ->assertCanSeeTableRecords(ActivityLog::all()); +}); + +it('event filter narrows the table', function () { + [$admin] = generateTestAccount([]); + $admin = $admin->syncRoles(Role::getRootAdmin()); + + Activity::event('auth:success')->actor($admin)->log(); + Activity::event('auth:fail')->log(); + + $this->actingAs($admin); + livewire(ListActivities::class) + ->filterTable('event', 'auth:fail') + ->assertCountTableRecords(1); +}); + +it('user without view activityLog is forbidden', function () { + $role = Role::factory()->create(['name' => 'IP Viewer', 'guard_name' => 'web']); + // seeIps alone must not grant access to the viewer. + $role->givePermissionTo(Permission::findOrCreate('seeIps activityLog', 'web')); + [$user] = generateTestAccount([]); + $user = $user->syncRoles($role); + + $this->actingAs($user); + livewire(ListActivities::class) + ->assertForbidden(); +}); + +it('user with view activityLog can see the viewer', function () { + $role = Role::factory()->create(['name' => 'Auditor', 'guard_name' => 'web']); + $role->givePermissionTo(Permission::findOrCreate('view activityLog', 'web')); + [$user] = generateTestAccount([]); + $user = $user->syncRoles($role); + + Activity::event('auth:success')->log(); + + $this->actingAs($user); + livewire(ListActivities::class) + ->assertSuccessful() + ->assertCountTableRecords(ActivityLog::count()); +}); From 7d5b0a3eccafffdd7c4630245f4f6ff935848268 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 12 Sep 2026 15:44:17 -0400 Subject: [PATCH 2/3] Address review: authorize the properties modal on view activityLog, eager-load actor and apiKey --- .../Resources/Activities/ActivityResource.php | 14 +++++++++++++- tests/Filament/Admin/ListActivitiesTest.php | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/Filament/Admin/Resources/Activities/ActivityResource.php b/app/Filament/Admin/Resources/Activities/ActivityResource.php index 0db359de89..2f28f04402 100644 --- a/app/Filament/Admin/Resources/Activities/ActivityResource.php +++ b/app/Filament/Admin/Resources/Activities/ActivityResource.php @@ -25,7 +25,9 @@ use Filament\Tables\Filters\Filter; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use Illuminate\Auth\Access\Response; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\HtmlString; @@ -128,7 +130,8 @@ public static function getEloquentQuery(): Builder { // Deliberately unscoped (and ignoring activity.hide_admin_activity): // this is the panel-wide audit view for admins holding "view activityLog". - return ActivityLog::whereNotIn('event', ActivityLog::DISABLED_EVENTS); + return ActivityLog::with(['actor', 'apiKey']) + ->whereNotIn('event', ActivityLog::DISABLED_EVENTS); } public static function canViewAny(): bool @@ -141,6 +144,15 @@ public static function canAccess(): bool return static::canViewAny(); } + /** + * ActivityLogPolicy::view() checks the server-panel subuser permission, + * which never applies here; the admin viewer is gated by "view activityLog". + */ + public static function getViewAuthorizationResponse(Model $record): Response + { + return static::canViewAny() ? Response::allow() : Response::deny(); + } + /** @return array */ public static function getDefaultPages(): array { diff --git a/tests/Filament/Admin/ListActivitiesTest.php b/tests/Filament/Admin/ListActivitiesTest.php index d12aadb0fb..eb99a017a4 100644 --- a/tests/Filament/Admin/ListActivitiesTest.php +++ b/tests/Filament/Admin/ListActivitiesTest.php @@ -4,6 +4,7 @@ use App\Filament\Admin\Resources\Activities\Pages\ListActivities; use App\Models\ActivityLog; use App\Models\Role; +use Filament\Actions\Testing\TestAction; use Filament\Facades\Filament; use Spatie\Permission\Models\Permission; @@ -64,3 +65,17 @@ ->assertSuccessful() ->assertCountTableRecords(ActivityLog::count()); }); + +it('user with view activityLog can open the properties modal', function () { + $role = Role::factory()->create(['name' => 'Modal Auditor', 'guard_name' => 'web']); + $role->givePermissionTo(Permission::findOrCreate('view activityLog', 'web')); + [$user] = generateTestAccount([]); + $user = $user->syncRoles($role); + + $log = Activity::event('auth:success')->log(); + + $this->actingAs($user); + livewire(ListActivities::class) + ->callAction(TestAction::make('view')->table($log)) + ->assertHasNoActionErrors(); +}); From 4d7ca3703dc4912dfd48cab209ec6a4a9e139bb9 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 12 Sep 2026 16:20:39 -0400 Subject: [PATCH 3/3] Flatten nested properties before the metadata modal fills KeyValue's state cast treats an assoc whose first value is an array as its own row format, so events like settings:update rendered an empty modal. Flatten via Arr::dot in mutateRecordDataUsing and stringify null/bool values. --- .../Admin/Resources/Activities/ActivityResource.php | 12 ++++++++++-- tests/Filament/Admin/ListActivitiesTest.php | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/Filament/Admin/Resources/Activities/ActivityResource.php b/app/Filament/Admin/Resources/Activities/ActivityResource.php index 2f28f04402..8140b60580 100644 --- a/app/Filament/Admin/Resources/Activities/ActivityResource.php +++ b/app/Filament/Admin/Resources/Activities/ActivityResource.php @@ -78,6 +78,15 @@ public static function defaultTable(Table $table): Table ->defaultSort('timestamp', 'desc') ->recordActions([ ViewAction::make() + // Flatten before the form fills: KeyValue's state cast mistakes a nested + // assoc (first value an array) for its own row format and blanks it. + ->mutateRecordDataUsing(function (array $data) { + $data['properties'] = collect(Arr::dot($data['properties'] ?? [])) + ->map(fn ($value) => is_bool($value) || is_null($value) ? var_export($value, true) : $value) + ->all(); + + return $data; + }) ->schema([ TextEntry::make('event') ->label(trans('admin/activity.event')) @@ -93,8 +102,7 @@ public static function defaultTable(Table $table): Table DateTimePicker::make('timestamp') ->label(trans('admin/activity.timestamp')), KeyValue::make('properties') - ->label(trans('admin/activity.metadata')) - ->formatStateUsing(fn ($state) => Arr::dot($state)), + ->label(trans('admin/activity.metadata')), ]), ]) ->filters([ diff --git a/tests/Filament/Admin/ListActivitiesTest.php b/tests/Filament/Admin/ListActivitiesTest.php index eb99a017a4..8435f812a0 100644 --- a/tests/Filament/Admin/ListActivitiesTest.php +++ b/tests/Filament/Admin/ListActivitiesTest.php @@ -66,6 +66,19 @@ ->assertCountTableRecords(ActivityLog::count()); }); +it('flattens nested properties for the metadata modal', function () { + [$admin] = generateTestAccount([]); + $admin = $admin->syncRoles(Role::getRootAdmin()); + + // Nested-only properties trip KeyValue's state cast without the flatten. + $log = Activity::event('settings:update')->property('changes', ['APP_NAME' => ['old' => 'A', 'new' => null]])->log(); + + $this->actingAs($admin); + livewire(ListActivities::class) + ->mountAction(TestAction::make('view')->table($log)) + ->assertActionDataSet(['properties' => ['changes.APP_NAME.old' => 'A', 'changes.APP_NAME.new' => 'NULL']]); +}); + it('user with view activityLog can open the properties modal', function () { $role = Role::factory()->create(['name' => 'Modal Auditor', 'guard_name' => 'web']); $role->givePermissionTo(Permission::findOrCreate('view activityLog', 'web'));