diff --git a/app/Filament/Admin/Resources/Activities/ActivityResource.php b/app/Filament/Admin/Resources/Activities/ActivityResource.php new file mode 100644 index 0000000000..8140b60580 --- /dev/null +++ b/app/Filament/Admin/Resources/Activities/ActivityResource.php @@ -0,0 +1,190 @@ +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() + // 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')) + ->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')), + ]), + ]) + ->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::with(['actor', 'apiKey']) + ->whereNotIn('event', ActivityLog::DISABLED_EVENTS); + } + + public static function canViewAny(): bool + { + return user()?->can('view activityLog') ?? false; + } + + 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 + { + 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..8435f812a0 --- /dev/null +++ b/tests/Filament/Admin/ListActivitiesTest.php @@ -0,0 +1,94 @@ + 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()); +}); + +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')); + [$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(); +});