feat: add impersonation log viewer in filament dashboard
This commit is contained in:
383
app/Filament/Pages/ImpersonationLogViewer.php
Normal file
383
app/Filament/Pages/ImpersonationLogViewer.php
Normal 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 . '"',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Filament/Widgets/ImpersonationStatsOverview.php
Normal file
52
app/Filament/Widgets/ImpersonationStatsOverview.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user