feat(audit): add user activity logging with View Logs modal and CSV export on User Management page

This commit is contained in:
root
2026-05-30 23:18:49 +08:00
parent 48654e123d
commit c5270926c7
8 changed files with 300 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ use Illuminate\Validation\Rules\Password;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\StationExport;
use App\Imports\StationImport;
use App\Models\ActivityLog;
class AdminController extends Controller
{
@@ -90,6 +91,8 @@ class AdminController extends Controller
]);
});
ActivityLog::record('create_station', $validated['stationid'], 'station', ['name' => $validated['stationname']]);
return redirect()->back()->with('success',__('toast.stationsuccess'));
}
@@ -118,6 +121,8 @@ class AdminController extends Controller
]);
});
ActivityLog::record('create_user', (string) $validated['name'], 'user', ['name' => $validated['name'], 'access_level' => $validated['access_level']]);
return redirect()->back()->with('success', __('toast.usersuccess'));
} catch (ValidationException $e) {
@@ -163,6 +168,8 @@ class AdminController extends Controller
]);
ActivityLog::record('update_station', $stationid, 'station', ['name' => $validated['stationname']]);
return redirect()->back()->with('success',__('toast.stationupdated'));
}
@@ -208,6 +215,8 @@ class AdminController extends Controller
]);
});
ActivityLog::record('update_user', (string) $userid, 'user', ['name' => $validated['name'], 'access_level' => $validated['access_level']]);
return redirect()->back()->with('success',__('toast.userupdated'));
}
catch (ValidationException $e) {
@@ -243,6 +252,8 @@ class AdminController extends Controller
]);
ActivityLog::record('update_password', (string) $userid, 'user');
return redirect()->back()->with('success',__('toast.passwordupdated'));
@@ -263,6 +274,8 @@ class AdminController extends Controller
// Function Delete Station
public function deleteStation($stationid)
{
$station = DB::table('station')->where('stationid', $stationid)->first();
ActivityLog::record('delete_station', $stationid, 'station', ['name' => $station?->name]);
DB::table('station')->where('stationid',$stationid)->delete();
return redirect()->back()->with('success',__('toast.stationdeleted'));
}
@@ -270,6 +283,8 @@ class AdminController extends Controller
// Function Delete User
public function deleteUser ($userid)
{
$user = DB::table('users')->where('id', $userid)->first();
ActivityLog::record('delete_user', (string) $userid, 'user', ['name' => $user?->name]);
DB::table('users')->where('id',$userid)->delete();
return redirect()->back()->with('success',__('toast.userdeleted'));
}
@@ -277,9 +292,26 @@ class AdminController extends Controller
// Function Export Stations to CSV
public function exportStations()
{
ActivityLog::record('export_stations_csv');
return Excel::download(new StationExport, 'stations.csv', \Maatwebsite\Excel\Excel::CSV);
}
// Function Download Sample CSV
public function sampleCsv()
{
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="sample_stations.csv"',
];
$callback = function () {
$file = fopen('php://output', 'w');
fputcsv($file, ['Station ID', 'Name', 'District', 'Longitude', 'Latitude', 'Main River Basin', 'Sub River Basin', 'Rainfall', 'Water Level', 'Siren', 'CCTV Link']);
fputcsv($file, ['KBLG0001', 'Sg. Example at Kg. Example', 'Baling', '100.879344', '5.589411', 'Sg. Muda', 'Sg. Kupang', '1', '1', '0', '']);
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
// Function Import Stations from CSV
public function importStations(Request $request)
{
@@ -289,6 +321,7 @@ class AdminController extends Controller
try {
Excel::import(new StationImport, $request->file('csv_file'));
ActivityLog::record('import_stations_csv', null, 'station', ['file' => $request->file('csv_file')->getClientOriginalName()]);
return redirect()->route('stationmanagement')->with('success', __('toast.stationsimported'));
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
$failures = $e->failures();
@@ -303,4 +336,47 @@ class AdminController extends Controller
}
}
public function userLogs($userid)
{
$user = DB::table('users')->where('id', $userid)->first();
$logs = ActivityLog::where('user_id', $userid)
->orderByDesc('created_at')
->paginate(20);
return view('layout.admin.userlogs', compact('logs', 'user'));
}
public function exportUserLogs($userid)
{
$user = DB::table('users')->where('id', $userid)->first();
$logs = ActivityLog::where('user_id', $userid)
->orderByDesc('created_at')
->get();
$headers = [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="user_logs_' . ($user?->name ?? $userid) . '.csv"',
];
$callback = function () use ($logs) {
$file = fopen('php://output', 'w');
fputcsv($file, ['Timestamp', 'Action', 'Subject Type', 'Subject ID', 'Details', 'IP Address']);
foreach ($logs as $log) {
fputcsv($file, [
$log->created_at,
$log->action,
$log->subject_type ?? '-',
$log->subject_id ?? '-',
$log->properties ? json_encode($log->properties) : '-',
$log->ip_address ?? '-',
]);
}
fclose($file);
};
ActivityLog::record('export_user_logs', (string) $userid, 'user', ['name' => $user?->name]);
return response()->stream($callback, 200, $headers);
}
}

View File

@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
use Illuminate\Support\Facades\DB;
use App\Models\ActivityLog;
class AuthenticatedSessionController extends Controller
{
@@ -90,7 +91,9 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
$loggedInUser = Auth::user(); // Always use this after Auth::attempt()
$loggedInUser = Auth::user();
ActivityLog::record('login', null, null, ['name' => $loggedInUser->name]);
return redirect()->intended('/')
->with('success', __('auth.success'). $loggedInUser->name . '.');
@@ -101,6 +104,19 @@ class AuthenticatedSessionController extends Controller
*/
public function destroy(Request $request): RedirectResponse
{
$userName = Auth::user()?->name;
$userId = Auth::id();
if ($userId) {
ActivityLog::create([
'user_id' => $userId,
'user_name' => $userName,
'action' => 'logout',
'ip_address' => $request->ip(),
'created_at' => now(),
]);
}
Auth::guard('web')->logout();
$request->session()->invalidate();

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\ActivityLog;
class cctvController extends Controller
{
@@ -11,11 +12,27 @@ class cctvController extends Controller
public function index()
{
$stationdata = DB::select("
SELECT name,district,cctv_link FROM station
SELECT stationid, name, district, cctv_link FROM station
WHERE cctv_link IS NOT NULL AND waterlevel = 1
ORDER BY name ASC
");
return view('layout.cctv',compact('stationdata'));
}
// Function Update CCTV Link
public function updateCctvLink(Request $request, $stationid)
{
$validated = $request->validate([
'cctv_link' => 'nullable|string|max:500',
]);
DB::table('station')->where('stationid', $stationid)->update([
'cctv_link' => $validated['cctv_link'] ?? '',
]);
ActivityLog::record('update_cctv_link', $stationid, 'station', ['cctv_link' => $validated['cctv_link']]);
return redirect()->route('cctv')->with('success', __('toast.linksupdated'));
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
class ActivityLog extends Model
{
protected $table = 'activity_log';
public $timestamps = false;
protected $fillable = [
'user_id',
'user_name',
'action',
'subject_type',
'subject_id',
'properties',
'ip_address',
'created_at',
];
protected $casts = [
'properties' => 'array',
'created_at' => 'datetime',
];
public static function record(string $action, ?string $subjectId = null, ?string $subjectType = null, ?array $properties = null): self
{
$user = Auth::user();
return self::create([
'user_id' => $user?->id,
'user_name' => $user?->name,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'properties' => $properties,
'ip_address' => request()->ip(),
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_log', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('user_name')->nullable();
$table->string('action');
$table->string('subject_type')->nullable();
$table->string('subject_id')->nullable();
$table->json('properties')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index('user_id');
$table->index('action');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('activity_log');
}
};

View File

@@ -0,0 +1,105 @@
@extends('layout.app')
@section('content1')
<section id="container">
<div class="container mt-3" id="table-container">
<nav style="--bs-breadcrumb-divider: '>';" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{route('dashboard')}}">@lang('messages.home')</a></li>
<li class="breadcrumb-item"><a href="{{route('usermgmt')}}">User Management</a></li>
<li class="breadcrumb-item active" aria-current="page">Activity Logs {{ $user->name ?? 'Unknown' }}</li>
</ol>
</nav>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Activity Logs: <strong>{{ $user->name ?? 'Unknown' }}</strong></h5>
<a href="{{ route('usermgmt.logs.export', $user->id) }}" class="btn btn-outline-success btn-sm"><i class='bx bx-export'></i> Export CSV</a>
</div>
@if($logs->isNotEmpty())
<div class="table-responsive">
<table class="table table-bordered table-sm">
<thead class="table-light">
<tr>
<th>No</th>
<th>Date &amp; Time</th>
<th>Action</th>
<th>Subject</th>
<th>Details</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
@foreach ($logs as $log)
@php
$actionLabel = match($log->action) {
'login' => '<span class="badge bg-success">Login</span>',
'logout' => '<span class="badge bg-secondary">Logout</span>',
'create_station' => '<span class="badge bg-info">Create Station</span>',
'update_station' => '<span class="badge bg-primary">Update Station</span>',
'delete_station' => '<span class="badge bg-danger">Delete Station</span>',
'import_stations_csv' => '<span class="badge bg-warning text-dark">Import CSV</span>',
'export_stations_csv' => '<span class="badge bg-warning text-dark">Export CSV</span>',
'update_cctv_link' => '<span class="badge bg-primary">Update CCTV Link</span>',
'create_user' => '<span class="badge bg-info">Create User</span>',
'update_user' => '<span class="badge bg-primary">Update User</span>',
'update_password' => '<span class="badge bg-warning text-dark">Update Password</span>',
'delete_user' => '<span class="badge bg-danger">Delete User</span>',
'export_user_logs' => '<span class="badge bg-warning text-dark">Export Logs</span>',
default => '<span class="badge bg-secondary">' . e($log->action) . '</span>',
};
$details = '';
if ($log->properties) {
$parts = [];
foreach ($log->properties as $key => $val) {
if ($key === 'password') continue;
$parts[] = "$key: $val";
}
$details = implode(', ', $parts);
}
@endphp
<tr>
<td>{{ $loop->iteration }}</td>
<td>{{ $log->created_at ? \Carbon\Carbon::parse($log->created_at)->format('d/m/Y H:i:s') : '-' }}</td>
<td>{!! $actionLabel !!}</td>
<td>{{ $log->subject_type ? ucfirst($log->subject_type) . ' ' . $log->subject_id : '-' }}</td>
<td>{{ $details }}</td>
<td>{{ $log->ip_address ?? '-' }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if ($logs->hasPages())
<nav aria-label="Page navigation" class="d-flex justify-content-center mt-3">
<ul class="pagination">
@if ($logs->onFirstPage())
<li class="page-item disabled"><span class="page-link">&laquo;</span></li>
@else
<li class="page-item"><a class="page-link" href="{{ $logs->previousPageUrl() }}" rel="prev">&laquo;</a></li>
@endif
@foreach ($logs->getUrlRange(1, $logs->lastPage()) as $page => $url)
<li class="page-item {{ $page == $logs->currentPage() ? 'active' : '' }}">
<a class="page-link" href="{{ $url }}">{{ $page }}</a>
</li>
@endforeach
@if ($logs->hasMorePages())
<li class="page-item"><a class="page-link" href="{{ $logs->nextPageUrl() }}" rel="next">&raquo;</a></li>
@else
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
@endif
</ul>
</nav>
@endif
@else
<div class="alert alert-light text-center mt-4" role="alert">
No activity logs found for this user.
</div>
@endif
</div>
</section>
@endsection

View File

@@ -89,6 +89,9 @@
data-bs-target="#userModal{{$row->id}}">
<i class='bx bx-edit'></i> Edit
</button>
<a href="{{ route('usermgmt.logs', $row->id) }}" class="btn btn-outline-secondary btn-sm me-1">
<i class='bx bx-file'></i> View Logs
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal"
data-bs-target="#DeleteModal{{$row->id}}">
<i class='bx bx-trash'></i> @lang('messages.delete')

View File

@@ -79,6 +79,7 @@ Route::middleware(['admin'])->group(function () {
// Station CSV Import/Export
Route::get('/stationmanagement/export-csv',[App\Http\Controllers\AdminController::class, 'exportStations'])->name('stationmanagement.export.csv');
Route::get('/stationmanagement/sample-csv',[App\Http\Controllers\AdminController::class, 'sampleCsv'])->name('stationmanagement.sample.csv');
Route::post('/stationmanagement/import-csv',[App\Http\Controllers\AdminController::class, 'importStations'])->name('stationmanagement.import.csv');
// User Management
@@ -87,6 +88,8 @@ Route::middleware(['admin'])->group(function () {
Route::post('/usermgmt/{userid}/update',[App\Http\Controllers\AdminController::class, 'updateUsers'])->name('usermgmt.update');
Route::post('/usermgmt/{userid}/updatePassword',[App\Http\Controllers\AdminController::class, 'updatePassword'])->name('usermgmt.updatePassword');
Route::delete('/usermgmt/{userid}/delete',[App\Http\Controllers\AdminController::class, 'deleteUser'])->name('usermgmt.delete');
Route::get('/usermgmt/{userid}/logs',[App\Http\Controllers\AdminController::class, 'userLogs'])->name('usermgmt.logs');
Route::get('/usermgmt/{userid}/logs/export-csv',[App\Http\Controllers\AdminController::class, 'exportUserLogs'])->name('usermgmt.logs.export');
});