16 KiB
16 KiB
Story 6.10: Audit Log Viewer
Epic Reference
Epic 6: Admin Dashboard
User Story
As an admin, I want to view admin action history, So that I can maintain accountability and track changes.
Dependencies
- Story 1.1: Project Setup & Database Schema (admin_logs table and AdminLog model)
- Story 1.2: Authentication & Role System (admin auth middleware)
- Story 6.1: Dashboard Overview & Statistics (admin layout, navigation patterns)
Navigation Context
- Accessible from admin dashboard sidebar/navigation
- Route:
/admin/audit-logs - Named route:
admin.audit-logs
Background Context
What Gets Logged
The admin_logs table captures significant admin actions for accountability:
- User Management: Create, update, delete, deactivate/reactivate users
- Consultation Management: Approve, reject, mark complete, mark no-show, cancel bookings
- Timeline Management: Create, update, archive timelines and timeline entries
- Posts Management: Create, update, delete posts
- Settings Changes: System configuration modifications
AdminLog Model Schema
Reference: PRD §16.1 Database Schema
admin_logs table:
- id: bigint (primary key)
- admin_id: bigint (nullable, foreign key to users - null for system actions)
- action: string (create, update, delete, approve, reject, etc.)
- target_type: string (user, consultation, timeline, timeline_update, post, setting)
- target_id: bigint (ID of the affected record)
- old_values: json (nullable - previous state for updates)
- new_values: json (nullable - new state for creates/updates)
- ip_address: string (IPv4/IPv6 address of admin)
- created_at: timestamp
Acceptance Criteria
Display Requirements
- Paginated table of audit log entries (25 per page)
- Each row displays:
- Timestamp (formatted per locale: d/m/Y H:i for AR, m/d/Y H:i for EN)
- Admin name (or "System" for automated actions)
- Action type badge (color-coded: green=create, blue=update, red=delete)
- Target type and ID (e.g., "User #123")
- IP address
- Details button to view old/new values
Filtering
- Filter by action type (dropdown with available actions)
- Filter by target type (dropdown with available targets)
- Filter by date range (date pickers for from/to)
- Filters apply without page reload (Livewire)
- Reset filters button
Search
- Search by target ID (exact match)
- Search updates results in real-time with debounce
Detail Modal
- Modal displays full log entry details
- Shows old values in readable format (JSON pretty-printed)
- Shows new values in readable format
- Highlights differences between old/new for updates
- Bilingual labels
CSV Export
- Export current filtered results to CSV
- CSV includes all displayed columns
- CSV filename includes export date
- Bilingual column headers based on admin's language preference
Empty State
- Friendly message when no logs exist
- Friendly message when filters return no results
Technical Notes
Files to Create/Modify
resources/views/livewire/admin/audit-logs.blade.php- Main Volt componentapp/Models/AdminLog.php- Eloquent model (if not already created in Story 1.1)routes/web.php- Add admin route (or admin routes file)lang/ar/audit.php- Arabic translationslang/en/audit.php- English translations
AdminLog Model
class AdminLog extends Model
{
protected $fillable = [
'admin_id',
'action',
'target_type',
'target_id',
'old_values',
'new_values',
'ip_address',
];
protected function casts(): array
{
return [
'old_values' => 'array',
'new_values' => 'array',
];
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}
Volt Component Structure
<?php
use App\Models\AdminLog;
use Livewire\Volt\Component;
use Livewire\WithPagination;
use Symfony\Component\HttpFoundation\StreamedResponse;
new class extends Component {
use WithPagination;
public string $actionFilter = '';
public string $targetFilter = '';
public string $dateFrom = '';
public string $dateTo = '';
public string $search = '';
public ?int $selectedLogId = null;
public function updatedActionFilter(): void { $this->resetPage(); }
public function updatedTargetFilter(): void { $this->resetPage(); }
public function updatedDateFrom(): void { $this->resetPage(); }
public function updatedDateTo(): void { $this->resetPage(); }
public function updatedSearch(): void { $this->resetPage(); }
public function resetFilters(): void
{
$this->reset(['actionFilter', 'targetFilter', 'dateFrom', 'dateTo', 'search']);
$this->resetPage();
}
public function showDetails(int $logId): void
{
$this->selectedLogId = $logId;
$this->dispatch('open-modal', name: 'log-details');
}
public function with(): array
{
return [
'logs' => $this->getFilteredQuery()->paginate(25),
'actionTypes' => AdminLog::distinct()->pluck('action'),
'targetTypes' => AdminLog::distinct()->pluck('target_type'),
'selectedLog' => $this->selectedLogId
? AdminLog::with('admin')->find($this->selectedLogId)
: null,
];
}
public function exportCsv(): StreamedResponse
{
$logs = $this->getFilteredQuery()->get();
return response()->streamDownload(function () use ($logs) {
$handle = fopen('php://output', 'w');
// Header row (bilingual based on locale)
fputcsv($handle, [
__('audit.timestamp'),
__('audit.admin'),
__('audit.action'),
__('audit.target_type'),
__('audit.target_id'),
__('audit.ip_address'),
]);
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at->format('Y-m-d H:i:s'),
$log->admin?->name ?? __('audit.system'),
$log->action,
$log->target_type,
$log->target_id,
$log->ip_address,
]);
}
fclose($handle);
}, 'audit-log-' . now()->format('Y-m-d') . '.csv');
}
private function getFilteredQuery()
{
return AdminLog::query()
->with('admin')
->when($this->actionFilter, fn($q) => $q->where('action', $this->actionFilter))
->when($this->targetFilter, fn($q) => $q->where('target_type', $this->targetFilter))
->when($this->dateFrom, fn($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn($q) => $q->whereDate('created_at', '<=', $this->dateTo))
->when($this->search, fn($q) => $q->where('target_id', $this->search))
->latest();
}
}; ?>
<div>
<!-- Filters Section -->
<!-- Table Section -->
<!-- Pagination -->
<!-- Detail Modal -->
</div>
Template Structure
<div class="space-y-6">
{{-- Filters --}}
<div class="flex flex-wrap gap-4">
<flux:select wire:model.live="actionFilter" placeholder="{{ __('audit.filter_action') }}">
<flux:select.option value="">{{ __('audit.all_actions') }}</flux:select.option>
@foreach($actionTypes as $type)
<flux:select.option value="{{ $type }}">{{ __("audit.action_{$type}") }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="targetFilter" placeholder="{{ __('audit.filter_target') }}">
<flux:select.option value="">{{ __('audit.all_targets') }}</flux:select.option>
@foreach($targetTypes as $type)
<flux:select.option value="{{ $type }}">{{ __("audit.target_{$type}") }}</flux:select.option>
@endforeach
</flux:select>
<flux:input type="date" wire:model.live="dateFrom" />
<flux:input type="date" wire:model.live="dateTo" />
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('audit.search_target_id') }}" />
<flux:button wire:click="resetFilters" variant="ghost">{{ __('audit.reset') }}</flux:button>
<flux:button wire:click="exportCsv">{{ __('audit.export_csv') }}</flux:button>
</div>
{{-- Table --}}
@if($logs->isEmpty())
<flux:callout>{{ __('audit.no_logs_found') }}</flux:callout>
@else
<table class="min-w-full">
<thead>
<tr>
<th>{{ __('audit.timestamp') }}</th>
<th>{{ __('audit.admin') }}</th>
<th>{{ __('audit.action') }}</th>
<th>{{ __('audit.target') }}</th>
<th>{{ __('audit.ip_address') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach($logs as $log)
<tr wire:key="log-{{ $log->id }}">
<td>{{ $log->created_at->translatedFormat(app()->getLocale() === 'ar' ? 'd/m/Y H:i' : 'm/d/Y H:i') }}</td>
<td>{{ $log->admin?->name ?? __('audit.system') }}</td>
<td>
<flux:badge
color="{{ match($log->action) {
'create' => 'green',
'update' => 'blue',
'delete' => 'red',
default => 'zinc'
} }}">
{{ __("audit.action_{$log->action}") }}
</flux:badge>
</td>
<td>{{ __("audit.target_{$log->target_type}") }} #{{ $log->target_id }}</td>
<td>{{ $log->ip_address }}</td>
<td>
<flux:button size="sm" wire:click="showDetails({{ $log->id }})">
{{ __('audit.details') }}
</flux:button>
</td>
</tr>
@endforeach
</tbody>
</table>
{{ $logs->links() }}
@endif
{{-- Detail Modal --}}
<flux:modal name="log-details">
@if($selectedLog)
<flux:heading>{{ __('audit.log_details') }}</flux:heading>
@if($selectedLog->old_values)
<div>
<strong>{{ __('audit.old_values') }}:</strong>
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@endif
@if($selectedLog->new_values)
<div>
<strong>{{ __('audit.new_values') }}:</strong>
<pre class="bg-zinc-100 p-2 rounded text-sm overflow-auto">{{ json_encode($selectedLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@endif
@endif
</flux:modal>
</div>
Action Badge Colors
| Action | Color | Meaning |
|---|---|---|
| create | green | New record created |
| update | blue | Record modified |
| delete | red | Record removed |
| approve | green | Booking approved |
| reject | red | Booking rejected |
| archive | zinc | Timeline archived |
Testing Requirements
Test Scenarios
// tests/Feature/Admin/AuditLogTest.php
test('audit log page requires admin authentication', function () {
$this->get(route('admin.audit-logs'))
->assertRedirect(route('login'));
});
test('audit log page loads for admin', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->get(route('admin.audit-logs'))
->assertOk()
->assertSeeLivewire('admin.audit-logs');
});
test('audit logs display in table', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->count(5)->create();
Volt::test('admin.audit-logs')
->actingAs($admin)
->assertSee('audit.timestamp');
});
test('can filter by action type', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->create(['action' => 'create']);
AdminLog::factory()->create(['action' => 'delete']);
Volt::test('admin.audit-logs')
->actingAs($admin)
->set('actionFilter', 'create')
->assertSee('create')
->assertDontSee('delete');
});
test('can filter by target type', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->create(['target_type' => 'user']);
AdminLog::factory()->create(['target_type' => 'consultation']);
Volt::test('admin.audit-logs')
->actingAs($admin)
->set('targetFilter', 'user')
->assertSee('user');
});
test('can filter by date range', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->create(['created_at' => now()->subDays(10)]);
AdminLog::factory()->create(['created_at' => now()]);
Volt::test('admin.audit-logs')
->actingAs($admin)
->set('dateFrom', now()->subDays(5)->format('Y-m-d'))
->set('dateTo', now()->format('Y-m-d'))
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
});
test('can search by target id', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->create(['target_id' => 123]);
AdminLog::factory()->create(['target_id' => 456]);
Volt::test('admin.audit-logs')
->actingAs($admin)
->set('search', '123')
->assertViewHas('logs', fn($logs) => $logs->count() === 1);
});
test('can view log details in modal', function () {
$admin = User::factory()->admin()->create();
$log = AdminLog::factory()->create([
'old_values' => ['name' => 'Old Name'],
'new_values' => ['name' => 'New Name'],
]);
Volt::test('admin.audit-logs')
->actingAs($admin)
->call('showDetails', $log->id)
->assertSet('selectedLogId', $log->id);
});
test('can export filtered logs to csv', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->count(3)->create();
Volt::test('admin.audit-logs')
->actingAs($admin)
->call('exportCsv')
->assertFileDownloaded();
});
test('displays empty state when no logs', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.audit-logs')
->actingAs($admin)
->assertSee(__('audit.no_logs_found'));
});
test('pagination works correctly', function () {
$admin = User::factory()->admin()->create();
AdminLog::factory()->count(50)->create();
Volt::test('admin.audit-logs')
->actingAs($admin)
->assertViewHas('logs', fn($logs) => $logs->count() === 25);
});
test('reset filters clears all filters', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.audit-logs')
->actingAs($admin)
->set('actionFilter', 'create')
->set('targetFilter', 'user')
->set('search', '123')
->call('resetFilters')
->assertSet('actionFilter', '')
->assertSet('targetFilter', '')
->assertSet('search', '');
});
Edge Cases & Error Handling
- Empty state: Display friendly message when no logs exist or filters return nothing
- Deleted admin: Show "System" or "Deleted User" if admin_id references deleted user
- Large JSON values: Truncate display in table, show full in modal with scroll
- IPv6 addresses: Ensure column width accommodates longer IP addresses
- Missing target: Handle gracefully if target record was deleted (show ID only)
- Pagination reset: Reset to page 1 when filters change
Definition of Done
- Audit logs page accessible at
/admin/audit-logs - Logs display with all required columns
- All filters work correctly (action, target, date range)
- Search by target ID works
- Pagination displays 25 items per page
- CSV export downloads with filtered data
- Detail modal shows old/new values formatted
- Empty state displays when appropriate
- Bilingual support (AR/EN) complete
- Admin-only access enforced
- All tests pass
- Code formatted with Pint
Estimation
Complexity: Medium | Effort: 3-4 hours