From 52a59eb1437bdad9142a706c8d695dba5e5cb5e1 Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Mon, 17 Nov 2025 12:22:26 -0800 Subject: [PATCH] feat: add impersonation log viewer in filament dashboard --- app/Filament/Pages/ImpersonationLogViewer.php | 383 ++++++++++++++++++ .../Widgets/ImpersonationStatsOverview.php | 52 +++ app/Models/ImpersonationLog.php | 2 +- ...ersonation-log-details-slideover.blade.php | 19 + .../pages/impersonation-log-viewer.blade.php | 16 + 5 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 app/Filament/Pages/ImpersonationLogViewer.php create mode 100644 app/Filament/Widgets/ImpersonationStatsOverview.php create mode 100644 resources/views/filament/modals/impersonation-log-details-slideover.blade.php create mode 100644 resources/views/filament/pages/impersonation-log-viewer.blade.php diff --git a/app/Filament/Pages/ImpersonationLogViewer.php b/app/Filament/Pages/ImpersonationLogViewer.php new file mode 100644 index 0000000..c403c3a --- /dev/null +++ b/app/Filament/Pages/ImpersonationLogViewer.php @@ -0,0 +1,383 @@ +authorizeAccess(); + } + + protected function authorizeAccess(): void + { + $user = Auth::user(); + + if (! $user || ! $user->isSuperAdmin()) { + abort(403, 'Access denied. Only Super Admin can view impersonation logs.'); + } + } + + public function getTitle(): string + { + return 'Impersonation Logs'; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('export_csv') + ->label('Export CSV') + ->icon('heroicon-o-arrow-down-tray') + ->color('success') + ->action(fn () => $this->exportCsv()), + + Action::make('refresh') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->color('gray') + ->action(fn () => $this->resetTable()), + ]; + } + + protected function getHeaderWidgets(): array + { + return [ + \App\Filament\Widgets\ImpersonationStatsOverview::class, + ]; + } + + public function table(Table $table): Table + { + return $table + ->query( + ImpersonationLog::query() + ->with(['admin', 'targetUser']) + ->latest('start_time') + ) + ->columns([ + TextColumn::make('id') + ->label('ID') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('admin.name') + ->label('Admin') + ->description(fn (ImpersonationLog $record): string => $record->admin?->email ?? 'Unknown') + ->sortable() + ->searchable() + ->weight('medium'), + + TextColumn::make('targetUser.name') + ->label('Target User') + ->description(fn (ImpersonationLog $record): string => $record->targetUser?->email ?? 'Unknown') + ->sortable() + ->searchable() + ->weight('medium'), + + TextColumn::make('start_time') + ->label('Started') + ->dateTime('M j, Y g:i A') + ->sortable() + ->description(fn (ImpersonationLog $record): string => $record->start_time->diffForHumans()), + + TextColumn::make('end_time') + ->label('Ended') + ->dateTime('M j, Y g:i A') + ->sortable() + ->placeholder('Active session') + ->description(fn (ImpersonationLog $record): string => $record->end_time ? $record->end_time->diffForHumans() : 'Still active' + ), + + TextColumn::make('duration_in_minutes') + ->label('Duration') + ->formatStateUsing(function ($record) { + return match(true) { + !$record->duration_in_minutes => 'Active', + $record->duration_in_minutes < 60 => "{$record->duration_in_minutes}m", + default => round($record->duration_in_minutes / 60, 1) . 'h', + }; + }) + ->sortable() + ->alignCenter(), + + TextColumn::make('status') + ->label('Status') + ->formatStateUsing(function ($record) { + return match ($record->status) { + 'active' => 'Active', + 'completed' => 'Completed', + 'force_terminated' => 'Terminated', + 'expired' => 'Expired', + default => 'Unknown', + }; + }) + ->badge() + ->color(fn ($record) => match ($record->status) { + 'active' => 'success', + 'completed' => 'primary', + 'force_terminated' => 'danger', + 'expired' => 'warning', + default => 'gray', + }), + + TextColumn::make('ip_address') + ->label('IP Address') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true) + ->copyable() + ->fontFamily('mono'), + + TextColumn::make('pages_visited_count') + ->label('Pages') + ->formatStateUsing(fn (ImpersonationLog $record): int => is_array($record->pages_visited) ? count($record->pages_visited) : 0 + ) + ->alignCenter() + ->toggleable(), + + TextColumn::make('actions_taken_count') + ->label('Actions') + ->formatStateUsing(fn (ImpersonationLog $record): int => is_array($record->actions_taken) ? count($record->actions_taken) : 0 + ) + ->alignCenter() + ->toggleable(), + ]) + ->defaultSort('start_time', 'desc') + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->options([ + 'active' => 'Active', + 'completed' => 'Completed', + 'force_terminated' => 'Force Terminated', + 'expired' => 'Expired', + ]), + + SelectFilter::make('admin_id') + ->label('Admin') + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return User::where('level', UserLevel::SUPERADMIN->value) + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->limit(50) + ->pluck('name', 'id') + ->toArray(); + }) + ->getOptionLabelUsing(function ($value): string { + return User::find($value)?->name ?? 'Unknown'; + }), + + SelectFilter::make('target_user_id') + ->label('Target User') + ->searchable() + ->getSearchResultsUsing(function (string $search): array { + return User::where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->limit(50) + ->pluck('name', 'id') + ->toArray(); + }) + ->getOptionLabelUsing(function ($value): string { + return User::find($value)?->name ?? 'Unknown'; + }), + + Filter::make('date_range') + ->schema([ + DatePicker::make('start_date') + ->label('Start Date') + ->required(), + DatePicker::make('end_date') + ->label('End Date') + ->required(), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['start_date'], + fn (Builder $query, $date): Builder => $query->whereDate('start_time', '>=', $date) + ) + ->when( + $data['end_date'], + fn (Builder $query, $date): Builder => $query->whereDate('start_time', '<=', $date) + ); + }), + + Filter::make('ip_address') + ->schema([ + TextInput::make('ip') + ->label('IP Address') + ->placeholder('192.168.1.1') + ->required(), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query->when( + $data['ip'], + fn (Builder $query, $ip): Builder => $query->where('ip_address', 'like', "%{$ip}%") + ); + }), + ]) + ->recordActions([ + Action::make('view_details') + ->label('View Details') + ->icon('heroicon-o-eye') + ->color('primary') + ->slideOver() + ->modalContent(fn (ImpersonationLog $record): View => view( + 'filament.modals.impersonation-log-details-slideover', + ['record' => $record] + )) + ->modalHeading('Impersonation Session Details') + ->action(fn () => null), + + Action::make('terminate') + ->label('Terminate') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->modalHeading('Terminate Impersonation Session') + ->modalDescription('This will immediately terminate the active impersonation session.') + ->modalSubmitActionLabel('Terminate Session') + ->visible(fn (ImpersonationLog $record): bool => $record->isActive()) + ->action(function (ImpersonationLog $record) { + $record->update([ + 'end_time' => now(), + 'status' => 'force_terminated', + ]); + }), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('terminate_active') + ->label('Terminate Active Sessions') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->deselectRecordsAfterCompletion() + ->action(function (Collection $records) { + $records->filter(fn (ImpersonationLog $record) => $record->isActive()) + ->each(function (ImpersonationLog $record) { + $record->update([ + 'end_time' => now(), + 'status' => 'force_terminated', + ]); + }); + }), + + DeleteBulkAction::make(), + ]), + ]) + ->emptyStateHeading('No impersonation logs found') + ->emptyStateDescription('No impersonation sessions have been recorded yet.') + ->emptyStateActions([ + Action::make('refresh') + ->label('Refresh') + ->icon('heroicon-o-arrow-path') + ->action(fn () => $this->resetTable()), + ]); + } + + public function exportCsv() + { + $logs = ImpersonationLog::query() + ->with(['admin', 'targetUser']) + ->latest('start_time') + ->get(); + + $filename = 'impersonation_logs_' . now()->format('Y_m_d_H_i_s') . '.csv'; + + // Create a temporary file + $handle = fopen('php://temp', 'r+'); + + // Add BOM for Excel UTF-8 support + fwrite($handle, "\xEF\xBB\xBF"); + + // Write headers + $headers = [ + 'ID', 'Admin Name', 'Admin Email', 'Target Name', 'Target Email', + 'Start Time', 'End Time', 'Duration (minutes)', 'Status', 'IP Address', + 'User Agent', 'Pages Visited', 'Actions Taken', + ]; + fputcsv($handle, $headers); + + // Write data rows + foreach ($logs as $log) { + fputcsv($handle, [ + $log->id, + $log->admin?->name ?? 'Unknown', + $log->admin?->email ?? 'Unknown', + $log->targetUser?->name ?? 'Unknown', + $log->targetUser?->email ?? 'Unknown', + $log->start_time->toDateTimeString(), + $log->end_time?->toDateTimeString() ?? 'Active', + $log->duration_in_minutes ?? 'Active', + $log->status, + $log->ip_address, + $log->user_agent, + is_array($log->pages_visited) ? json_encode($log->pages_visited) : '[]', + is_array($log->actions_taken) ? json_encode($log->actions_taken) : '[]', + ]); + } + + // Rewind the file pointer + rewind($handle); + + // Get the CSV content + $csvContent = stream_get_contents($handle); + + // Close the file handle + fclose($handle); + + // Return a download response + return response()->streamDownload( + function () use ($csvContent) { + echo $csvContent; + }, + $filename, + [ + 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ] + ); + } +} diff --git a/app/Filament/Widgets/ImpersonationStatsOverview.php b/app/Filament/Widgets/ImpersonationStatsOverview.php new file mode 100644 index 0000000..d4cfdb2 --- /dev/null +++ b/app/Filament/Widgets/ImpersonationStatsOverview.php @@ -0,0 +1,52 @@ +count(); + $completedSessions = ImpersonationLog::completed()->count(); + + // Calculate average duration properly using the time difference + $completedSessionsWithDuration = ImpersonationLog::completed() + ->whereNotNull('end_time') + ->get(); + + $averageDuration = 0; + if ($completedSessionsWithDuration->count() > 0) { + $totalDuration = $completedSessionsWithDuration->sum(function ($session) { + return $session->duration_in_minutes ?? 0; + }); + $averageDuration = $totalDuration / $completedSessionsWithDuration->count(); + } + + return [ + Stat::make('Total Sessions', number_format($totalSessions)) + ->description('All impersonation sessions') + ->descriptionIcon('heroicon-o-document-text') + ->color('primary'), + + Stat::make('Active Sessions', number_format($activeSessions)) + ->description('Currently active') + ->descriptionIcon('heroicon-o-eye') + ->color($activeSessions > 0 ? 'success' : 'gray'), + + Stat::make('Completed Sessions', number_format($completedSessions)) + ->description('Successfully completed') + ->descriptionIcon('heroicon-o-check-circle') + ->color('primary'), + + Stat::make('Avg Duration', round($averageDuration, 1).' min') + ->description('Average session time') + ->descriptionIcon('heroicon-o-clock') + ->color('warning'), + ]; + } +} diff --git a/app/Models/ImpersonationLog.php b/app/Models/ImpersonationLog.php index 7a7cf74..a837848 100644 --- a/app/Models/ImpersonationLog.php +++ b/app/Models/ImpersonationLog.php @@ -41,7 +41,7 @@ final class ImpersonationLog extends Model return $this->belongsTo(User::class, 'target_user_id'); } - public function getDurationInSecondsAttribute(): ?int + public function getDurationInSecondsAttribute(): ?float { if (! $this->end_time) { return null; diff --git a/resources/views/filament/modals/impersonation-log-details-slideover.blade.php b/resources/views/filament/modals/impersonation-log-details-slideover.blade.php new file mode 100644 index 0000000..1b13cd3 --- /dev/null +++ b/resources/views/filament/modals/impersonation-log-details-slideover.blade.php @@ -0,0 +1,19 @@ +
+
{
+  "id": {{ $record->id }},
+  "admin_id": {{ $record->admin_id }},
+  "target_user_id": {{ $record->target_user_id }},
+  "admin_name": "{{ $record->admin?->name ?? 'Unknown' }}",
+  "admin_email": "{{ $record->admin?->email ?? 'Unknown email' }}",
+  "target_user_name": "{{ $record->targetUser?->name ?? 'Unknown' }}",
+  "target_user_email": "{{ $record->targetUser?->email ?? 'Unknown email' }}",
+  "ip_address": "{{ $record->ip_address }}",
+  "status": "{{ $record->status }}",
+  "start_time": "{{ $record->start_time->toIso8601String() }}",
+  @if($record->end_time)"end_time": "{{ $record->end_time->toIso8601String() }}",
+  @endif"duration_in_minutes": @if($record->duration_in_minutes){{ $record->duration_in_minutes }}@else null @endif,
+  "pages_visited": {!! json_encode($record->pages_visited, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) !!},
+  "actions_taken": {!! json_encode($record->actions_taken, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) !!},
+  "user_agent": "{{ $record->user_agent ?? 'Not recorded' }}"
+}
+
diff --git a/resources/views/filament/pages/impersonation-log-viewer.blade.php b/resources/views/filament/pages/impersonation-log-viewer.blade.php new file mode 100644 index 0000000..cb288f1 --- /dev/null +++ b/resources/views/filament/pages/impersonation-log-viewer.blade.php @@ -0,0 +1,16 @@ +{{-- + Impersonation Log Viewer Page + + This page displays comprehensive impersonation logs with statistics, + advanced filtering, and session management capabilities. + Access restricted to Super Admin users only. +--}} + + +
+ +
+ {{ $this->table }} +
+
+