All pages now use native Vuetify components directly. Flash messages are handled by the ToastStack component via Pinia store. Notifications use NotificationPanel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
259 lines
8.2 KiB
PHP
259 lines
8.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\AuditLog;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\Request;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class AuditLogController extends Controller
|
|
{
|
|
public function index(Request $request): Response
|
|
{
|
|
$query = $this->applyFilters($request);
|
|
|
|
$auditLogs = $query->paginate(25)->withQueryString();
|
|
|
|
// Get distinct actions for the filter dropdown
|
|
$actions = AuditLog::query()
|
|
->distinct()
|
|
->orderBy('action')
|
|
->pluck('action');
|
|
|
|
return Inertia::render('Admin/AuditLogs/Index', [
|
|
'auditLogs' => $auditLogs,
|
|
'actions' => $actions,
|
|
'filters' => [
|
|
'search' => $request->input('search', ''),
|
|
'action' => $request->input('action', ''),
|
|
'date_from' => $request->input('date_from', ''),
|
|
'date_to' => $request->input('date_to', ''),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function export(Request $request): StreamedResponse
|
|
{
|
|
$request->validate([
|
|
'format' => ['required', 'in:csv,json'],
|
|
'search' => ['nullable', 'string', 'max:255'],
|
|
'action' => ['nullable', 'string', 'max:255'],
|
|
'date_from' => ['nullable', 'date'],
|
|
'date_to' => ['nullable', 'date'],
|
|
]);
|
|
|
|
$format = $request->input('format', 'csv');
|
|
$query = $this->applyFilters($request);
|
|
|
|
if ($format === 'json') {
|
|
return $this->exportJson($query);
|
|
}
|
|
|
|
return $this->exportCsv($query);
|
|
}
|
|
|
|
/**
|
|
* @param Builder<AuditLog> $query
|
|
*/
|
|
private function exportCsv(Builder $query): StreamedResponse
|
|
{
|
|
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.csv';
|
|
|
|
return response()->streamDownload(function () use ($query): void {
|
|
$handle = fopen('php://output', 'w');
|
|
|
|
if ($handle === false) {
|
|
return;
|
|
}
|
|
|
|
fputcsv($handle, [
|
|
'ID',
|
|
'Date',
|
|
'User',
|
|
'User Email',
|
|
'Action',
|
|
'Resource Type',
|
|
'Resource ID',
|
|
'IP Address',
|
|
'User Agent',
|
|
'Changes Summary',
|
|
]);
|
|
|
|
$query->chunk(500, function ($logs) use ($handle): void {
|
|
foreach ($logs as $log) {
|
|
fputcsv($handle, [
|
|
$log->id,
|
|
$log->created_at->format('Y-m-d H:i:s'),
|
|
$log->user?->name ?? 'System',
|
|
$log->user?->email ?? '-',
|
|
$log->action,
|
|
$log->resource_type ?? '-',
|
|
$log->resource_id ?? '-',
|
|
$log->ip_address ?? '-',
|
|
$log->user_agent ?? '-',
|
|
$this->summarizeChanges($log->changes),
|
|
]);
|
|
}
|
|
});
|
|
|
|
fclose($handle);
|
|
}, $filename, [
|
|
'Content-Type' => 'text/csv',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param Builder<AuditLog> $query
|
|
*/
|
|
private function exportJson(Builder $query): StreamedResponse
|
|
{
|
|
$filename = 'audit-logs-'.now()->format('Y-m-d-His').'.json';
|
|
|
|
return response()->streamDownload(function () use ($query): void {
|
|
echo '[';
|
|
|
|
$first = true;
|
|
|
|
$query->chunk(500, function ($logs) use (&$first): void {
|
|
foreach ($logs as $log) {
|
|
if (! $first) {
|
|
echo ',';
|
|
}
|
|
|
|
echo json_encode([
|
|
'id' => $log->id,
|
|
'date' => $log->created_at->format('Y-m-d H:i:s'),
|
|
'user' => $log->user ? [
|
|
'id' => $log->user->id,
|
|
'name' => $log->user->name,
|
|
'email' => $log->user->email,
|
|
] : null,
|
|
'action' => $log->action,
|
|
'resource_type' => $log->resource_type,
|
|
'resource_id' => $log->resource_id,
|
|
'ip_address' => $log->ip_address,
|
|
'user_agent' => $log->user_agent,
|
|
'changes' => $log->changes,
|
|
'created_at' => $log->created_at->toIso8601String(),
|
|
], JSON_PRETTY_PRINT);
|
|
|
|
$first = false;
|
|
}
|
|
});
|
|
|
|
echo ']';
|
|
}, $filename, [
|
|
'Content-Type' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $changes
|
|
*/
|
|
private function summarizeChanges(?array $changes): string
|
|
{
|
|
if (! $changes || count($changes) === 0) {
|
|
return '-';
|
|
}
|
|
|
|
// Check for per-field old/new format: {"plan": {"old": "Basic", "new": "Pro"}, ...}
|
|
$hasPerFieldOldNew = false;
|
|
|
|
foreach ($changes as $value) {
|
|
if (is_array($value) && (array_key_exists('old', $value) || array_key_exists('new', $value))) {
|
|
$hasPerFieldOldNew = true;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($hasPerFieldOldNew) {
|
|
$changedFields = [];
|
|
|
|
foreach ($changes as $field => $value) {
|
|
if (is_array($value) && array_key_exists('old', $value) && array_key_exists('new', $value)) {
|
|
if ($value['old'] !== $value['new']) {
|
|
$changedFields[] = $field;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $changedFields ? 'Changed: '.implode(', ', $changedFields) : 'No changes';
|
|
}
|
|
|
|
// Top-level old/new structure: {"old": {...}, "new": {...}}
|
|
if (isset($changes['old']) || isset($changes['new'])) {
|
|
$fields = [];
|
|
|
|
if (isset($changes['new']) && is_array($changes['new'])) {
|
|
$fields = array_keys($changes['new']);
|
|
} elseif (isset($changes['old']) && is_array($changes['old'])) {
|
|
$fields = array_keys($changes['old']);
|
|
}
|
|
|
|
return 'Changed: '.implode(', ', $fields);
|
|
}
|
|
|
|
// Top-level before/after structure
|
|
if (isset($changes['before']) || isset($changes['after'])) {
|
|
$fields = [];
|
|
|
|
if (isset($changes['after']) && is_array($changes['after'])) {
|
|
$fields = array_keys($changes['after']);
|
|
} elseif (isset($changes['before']) && is_array($changes['before'])) {
|
|
$fields = array_keys($changes['before']);
|
|
}
|
|
|
|
return 'Changed: '.implode(', ', $fields);
|
|
}
|
|
|
|
// Flat key-value pairs
|
|
return 'Fields: '.implode(', ', array_keys($changes));
|
|
}
|
|
|
|
/**
|
|
* @return Builder<AuditLog>
|
|
*/
|
|
private function applyFilters(Request $request): Builder
|
|
{
|
|
$query = AuditLog::query()
|
|
->with('user:id,name,email')
|
|
->latest();
|
|
|
|
// Search by user name/email, action, or resource type
|
|
if ($search = $request->input('search')) {
|
|
$query->where(function ($q) use ($search): void {
|
|
$q->where('action', 'like', "%{$search}%")
|
|
->orWhere('resource_type', 'like', "%{$search}%")
|
|
->orWhere('ip_address', 'like', "%{$search}%")
|
|
->orWhereHas('user', function ($userQuery) use ($search): void {
|
|
$userQuery->where('name', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Filter by action
|
|
if ($action = $request->input('action')) {
|
|
$query->where('action', $action);
|
|
}
|
|
|
|
// Filter by date range
|
|
if ($dateFrom = $request->input('date_from')) {
|
|
$query->whereDate('created_at', '>=', $dateFrom);
|
|
}
|
|
|
|
if ($dateTo = $request->input('date_to')) {
|
|
$query->whereDate('created_at', '<=', $dateTo);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
}
|