feat: add impersonation log viewer in filament dashboard

This commit is contained in:
idevakk
2025-11-17 12:22:26 -08:00
parent a7029b5f57
commit 52a59eb143
5 changed files with 471 additions and 1 deletions

View File

@@ -0,0 +1,383 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Enum\UserLevel;
use App\Models\ImpersonationLog;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use UnitEnum;
class ImpersonationLogViewer extends Page implements HasForms, HasTable
{
use InteractsWithActions;
use InteractsWithForms;
use InteractsWithTable;
protected static string|null|\BackedEnum $navigationIcon = 'heroicon-o-eye';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static ?int $navigationSort = 10;
protected string $view = 'filament.pages.impersonation-log-viewer';
public function mount(): void
{
$this->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 . '"',
]
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Widgets;
use App\Models\ImpersonationLog;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class ImpersonationStatsOverview extends StatsOverviewWidget
{
protected function getStats(): array
{
$totalSessions = ImpersonationLog::count();
$activeSessions = ImpersonationLog::active()->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'),
];
}
}

View File

@@ -41,7 +41,7 @@ final class ImpersonationLog extends Model
return $this->belongsTo(User::class, 'target_user_id'); return $this->belongsTo(User::class, 'target_user_id');
} }
public function getDurationInSecondsAttribute(): ?int public function getDurationInSecondsAttribute(): ?float
{ {
if (! $this->end_time) { if (! $this->end_time) {
return null; return null;

View File

@@ -0,0 +1,19 @@
<div class="p-4">
<pre class="text-xs bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto border border-gray-200 dark:border-gray-700"><code class="text-gray-700 dark:text-gray-300 font-mono">{
"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' }}"
}</code></pre>
</div>

View File

@@ -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.
--}}
<x-filament-panels::page>
<div class="space-y-6">
<!-- Table Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
{{ $this->table }}
</div>
</div>
</x-filament-panels::page>