feat: implementation des logs de connexion et correction du chemin de stockage des documents

This commit is contained in:
jeremy bayse
2026-04-19 17:28:13 +02:00
parent f3d630d741
commit 205c24182d
12 changed files with 245 additions and 1 deletions

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use App\Models\LoginLog;
#[Signature('app:cleanup-login-logs')]
#[Description('Supprime les logs de connexion datant de plus de 1 mois')]
class CleanupLoginLogs extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
$count = LoginLog::where('login_at', '<', now()->subMonth())->delete();
$this->info("{$count} logs de connexion supprimés.");
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\LoginLog;
use Inertia\Inertia;
class LoginLogController extends Controller
{
public function index()
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized. Super Admin only.');
}
$logs = LoginLog::with('user.tenant')
->orderBy('login_at', 'desc')
->paginate(50);
return Inertia::render('Admin/Logs', [
'logs' => $logs
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Listeners;
use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use App\Models\LoginLog;
use Illuminate\Http\Request;
class LogSuccessfulLogin
{
protected $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function handle(Login $event): void
{
LoginLog::create([
'user_id' => $event->user->id,
'ip_address' => $this->request->ip(),
'user_agent' => $this->request->userAgent(),
'login_at' => now(),
]);
}
}

16
app/Models/LoginLog.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['user_id', 'ip_address', 'user_agent', 'login_at'])]
class LoginLog extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -32,7 +32,7 @@ return [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'root' => storage_path('app'),
'serve' => true,
'throw' => false,
'report' => false,

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('login_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('login_at')->useCurrent();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('login_logs');
}
};

11
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"marked": "^17.0.4"
},
"devDependencies": {
@@ -1422,6 +1423,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",

View File

@@ -23,6 +23,7 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.19",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"marked": "^17.0.4"
}
}

View File

@@ -112,6 +112,18 @@ const isSidebarOpen = ref(true);
</svg>
<span v-if="isSidebarOpen" class="truncate">Équipe SaaS</span>
</Link>
<Link
v-if="$page.props.auth.user.role === 'super_admin'"
:href="route('admin.logs.index')"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-300 font-subtitle font-bold text-sm"
:class="[route().current('admin.logs.*') ? 'bg-highlight text-[#3a2800] shadow-md shadow-highlight/20' : 'text-white/70 hover:bg-white/10 hover:text-white']"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span v-if="isSidebarOpen" class="truncate">Logs de connexion</span>
</Link>
</nav>
<div class="p-4 border-t border-white/10 bg-primary/80">

View File

@@ -0,0 +1,88 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
const props = defineProps({
logs: Object // Paginated object
});
const formatDate = (dateString) => {
return format(new Date(dateString), 'PPP à HH:mm', { locale: fr });
};
</script>
<template>
<Head title="Logs de connexion" />
<AdminLayout>
<template #header>Logs de connexion</template>
<div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-serif font-black text-primary capitalize tracking-tight flex items-center gap-3">
<div class="w-1.5 h-8 bg-highlight rounded-full"></div>
Historique des Connexions
</h1>
</div>
<div class="bg-white rounded-3xl shadow-sm border border-anthracite/5 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead class="bg-neutral/50 border-b border-anthracite/5">
<tr>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Utilisateur</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Structure</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Adresse IP</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Date & Heure</th>
<th class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40">Appareil / Navigateur</th>
</tr>
</thead>
<tbody class="divide-y divide-anthracite/5">
<tr v-for="log in logs.data" :key="log.id" class="hover:bg-sand/30 transition-colors">
<td class="px-8 py-5">
<div class="font-bold text-primary">{{ log.user.name }}</div>
<div class="text-xs text-anthracite/50">{{ log.user.email }}</div>
</td>
<td class="px-8 py-5 text-xs font-bold text-anthracite">
{{ log.user.tenant ? log.user.tenant.name : 'Super Admin / Global' }}
</td>
<td class="px-8 py-5 text-xs font-mono text-anthracite/70">
{{ log.ip_address }}
</td>
<td class="px-8 py-5 text-xs font-bold text-anthracite">
{{ formatDate(log.login_at) }}
</td>
<td class="px-8 py-5 text-[10px] text-anthracite/50 max-w-xs truncate" :title="log.user_agent">
{{ log.user_agent }}
</td>
</tr>
<tr v-if="logs.data.length === 0">
<td colspan="5" class="px-8 py-16 text-center text-anthracite/40 italic">
Aucun log de connexion trouvé.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Simple Pagination -->
<div v-if="logs.links.length > 3" class="px-8 py-4 bg-neutral/30 border-t border-anthracite/5 flex justify-center gap-2">
<Link
v-for="link in logs.links"
:key="link.label"
:href="link.url || '#'"
class="px-3 py-1 rounded-lg text-xs font-bold transition-all"
:class="[
link.active ? 'bg-primary text-white' : 'bg-white text-primary hover:bg-highlight hover:text-white',
!link.url ? 'opacity-50 cursor-not-allowed' : ''
]"
v-html="link.label"
/>
</div>
</div>
<div class="mt-6 p-4 bg-highlight/10 border border-highlight/20 rounded-2xl text-xs text-[#3a2800]/60 font-medium">
<p><strong>Note :</strong> Les logs sont conservés pendant une période de 1 mois. Un nettoyage automatique est effectué quotidiennement.</p>
</div>
</AdminLayout>
</template>

View File

@@ -3,6 +3,10 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::command('app:cleanup-login-logs')->daily();

View File

@@ -100,6 +100,7 @@ Route::middleware('auth')->group(function () {
Route::get('/backup', [\App\Http\Controllers\BackupController::class, 'download'])->name('backup');
Route::delete('/attempts/{attempt}', [\App\Http\Controllers\AttemptController::class, 'destroy'])->name('attempts.destroy');
Route::patch('/answers/{answer}/score', [\App\Http\Controllers\AttemptController::class, 'updateAnswerScore'])->name('answers.update-score');
Route::get('/logs', [\App\Http\Controllers\Admin\LoginLogController::class, 'index'])->name('logs.index');
});
// Candidate Routes