- Installed p7h/nas-file-manager package via private VCS repo - Published config/nas-file-manager.php with super_admin middleware restriction - Added NAS env vars to .env.example - Created admin/nas-storage page with connection info panel and file browser widget - Added NAS Storage link to admin sidebar (super_admin only) - Added SuperAdminController@nasStorage method and admin.nas-storage route - Includes all accumulated branch changes: profile wall, 2FA, audit logs, settings panel, country/phone/timezone components, posts, slideshow, playlist shares, video downloads/shares, comment likes, notifications, social links, and more Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
386 lines
16 KiB
PHP
386 lines
16 KiB
PHP
@extends('admin.layout')
|
||
|
||
@section('title', 'Audit Logs')
|
||
|
||
@section('content')
|
||
<style>
|
||
.al-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
|
||
}
|
||
.al-title { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
||
.al-title i { color: var(--brand); }
|
||
|
||
/* Filter bar */
|
||
.al-filters {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 20px;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
.al-filter-group label {
|
||
display: block; font-size: 11px; font-weight: 700;
|
||
letter-spacing: .7px; text-transform: uppercase;
|
||
color: var(--text-muted); margin-bottom: 6px;
|
||
}
|
||
.al-filter-input {
|
||
width: 100%; background: var(--bg-body); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 8px 12px; color: var(--text);
|
||
font-size: 13px; line-height: 1;
|
||
}
|
||
.al-filter-input:focus { outline: none; border-color: var(--brand); }
|
||
.al-filter-input option { background: #1a1a1a; }
|
||
.al-filter-btn {
|
||
display: flex; gap: 8px;
|
||
}
|
||
.al-btn {
|
||
padding: 9px 16px; border-radius: 8px; font-size: 13px;
|
||
font-weight: 600; cursor: pointer; border: none; white-space: nowrap;
|
||
}
|
||
.al-btn-primary { background: var(--brand); color: #fff; }
|
||
.al-btn-ghost { background: var(--border-light); color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
|
||
.al-btn-ghost:hover { background: var(--border); color: var(--text); }
|
||
|
||
/* Stats strip */
|
||
.al-stats {
|
||
display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap;
|
||
}
|
||
.al-stat {
|
||
background: var(--bg-card); border: 1px solid var(--border);
|
||
border-radius: 10px; padding: 10px 18px; font-size: 13px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.al-stat strong { font-size: 18px; font-weight: 700; }
|
||
.al-stat-label { color: var(--text-muted); font-size: 12px; }
|
||
|
||
/* Table */
|
||
.al-table-wrap {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.al-table thead tr {
|
||
background: rgba(255,255,255,.03);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.al-table th {
|
||
padding: 11px 14px; text-align: left;
|
||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||
letter-spacing: .6px; color: var(--text-muted); white-space: nowrap;
|
||
}
|
||
.al-table td { padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.04); vertical-align: middle; }
|
||
.al-table tr:last-child td { border-bottom: none; }
|
||
.al-table tbody tr:hover { background: rgba(255,255,255,.02); }
|
||
|
||
/* Severity badges */
|
||
.al-badge {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 3px 10px; border-radius: 20px;
|
||
font-size: 11px; font-weight: 700; white-space: nowrap;
|
||
}
|
||
.al-badge-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.3); }
|
||
.al-badge-warning { background: rgba(251,191,36,.15); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
|
||
.al-badge-success { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.3); }
|
||
.al-badge-info { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
|
||
.al-badge-purple { background: rgba(167,139,250,.15);color: #a78bfa; border: 1px solid rgba(167,139,250,.3); }
|
||
.al-badge-orange { background: rgba(251,146,60,.15); color: #fb923c; border: 1px solid rgba(251,146,60,.3); }
|
||
.al-badge-muted { background: rgba(255,255,255,.06); color: var(--text-muted); border: 1px solid var(--border); }
|
||
.al-badge-default { background: rgba(255,255,255,.06); color: var(--text); border: 1px solid var(--border); }
|
||
|
||
/* Icon per severity */
|
||
.al-badge-danger i { color: #f87171; }
|
||
.al-badge-warning i { color: #fbbf24; }
|
||
.al-badge-success i { color: #4ade80; }
|
||
.al-badge-info i { color: #60a5fa; }
|
||
|
||
/* User cell */
|
||
.al-user { display: flex; align-items: center; gap: 8px; }
|
||
.al-user-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||
.al-user-name { font-weight: 600; }
|
||
.al-user-id { font-size: 11px; color: var(--text-muted); }
|
||
|
||
/* IP cell */
|
||
.al-ip { font-family: monospace; font-size: 12px; color: var(--text-muted); }
|
||
.al-ip a { color: inherit; text-decoration: underline dotted; }
|
||
|
||
/* Subject cell */
|
||
.al-subject { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
/* Details expand */
|
||
.al-details-btn {
|
||
background: none; border: none; color: var(--text-muted);
|
||
cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 12px;
|
||
}
|
||
.al-details-btn:hover { background: var(--border-light); color: var(--text); }
|
||
.al-details-row td {
|
||
padding: 0 !important;
|
||
border-bottom: 1px solid rgba(255,255,255,.04) !important;
|
||
}
|
||
.al-details-inner {
|
||
padding: 10px 14px 14px 44px;
|
||
font-family: monospace; font-size: 12px;
|
||
color: var(--text-muted); white-space: pre-wrap;
|
||
background: rgba(0,0,0,.15);
|
||
}
|
||
|
||
/* Pagination */
|
||
.al-pagination {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 20px; border-top: 1px solid var(--border);
|
||
font-size: 13px; color: var(--text-muted); flex-wrap: wrap; gap: 10px;
|
||
}
|
||
.al-page-links { display: flex; gap: 4px; }
|
||
.al-page-links a, .al-page-links span {
|
||
padding: 5px 10px; border-radius: 6px; font-size: 13px;
|
||
border: 1px solid var(--border); color: var(--text-muted); text-decoration: none;
|
||
}
|
||
.al-page-links a:hover { background: var(--border-light); color: var(--text); }
|
||
.al-page-links span.active { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||
.al-page-links span.disabled { opacity: .4; cursor: not-allowed; }
|
||
|
||
/* Empty state */
|
||
.al-empty {
|
||
padding: 60px 20px; text-align: center; color: var(--text-muted);
|
||
}
|
||
.al-empty i { font-size: 48px; display: block; margin-bottom: 12px; }
|
||
</style>
|
||
|
||
<div class="al-header">
|
||
<div class="al-title">
|
||
<i class="bi bi-shield-check"></i> Audit Logs
|
||
</div>
|
||
<div style="font-size:13px;color:var(--text-muted);">
|
||
{{ $logs->total() }} events found
|
||
@if(request()->hasAny(['action','user','ip','subject','date_from','date_to']))
|
||
— <a href="{{ route('admin.audit') }}" style="color:var(--brand);">clear filters</a>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Filters --}}
|
||
<form method="GET" action="{{ route('admin.audit') }}">
|
||
<div class="al-filters">
|
||
<div class="al-filter-group">
|
||
<label>Action Type</label>
|
||
<select name="action" class="al-filter-input">
|
||
<option value="">All Actions</option>
|
||
@foreach($actionTypes as $type)
|
||
<option value="{{ $type }}" {{ request('action') === $type ? 'selected' : '' }}>
|
||
{{ ucwords(str_replace(['.','_'], ' ', $type)) }}
|
||
</option>
|
||
@endforeach
|
||
</select>
|
||
</div>
|
||
<div class="al-filter-group">
|
||
<label>User / Email</label>
|
||
<input type="text" name="user" class="al-filter-input" value="{{ request('user') }}" placeholder="Name or email…">
|
||
</div>
|
||
<div class="al-filter-group">
|
||
<label>IP Address</label>
|
||
<input type="text" name="ip" class="al-filter-input" value="{{ request('ip') }}" placeholder="e.g. 1.2.3.4">
|
||
</div>
|
||
<div class="al-filter-group">
|
||
<label>Subject</label>
|
||
<input type="text" name="subject" class="al-filter-input" value="{{ request('subject') }}" placeholder="Video title, username…">
|
||
</div>
|
||
<div class="al-filter-group">
|
||
<label>From Date</label>
|
||
<input type="date" name="date_from" class="al-filter-input" value="{{ request('date_from') }}">
|
||
</div>
|
||
<div class="al-filter-group">
|
||
<label>To Date</label>
|
||
<input type="date" name="date_to" class="al-filter-input" value="{{ request('date_to') }}">
|
||
</div>
|
||
<div class="al-filter-group al-filter-btn">
|
||
<button type="submit" class="al-btn al-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
|
||
<a href="{{ route('admin.audit') }}" class="al-btn al-btn-ghost"><i class="bi bi-x-lg"></i></a>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
{{-- Table --}}
|
||
<div class="al-table-wrap">
|
||
@if($logs->isEmpty())
|
||
<div class="al-empty">
|
||
<i class="bi bi-shield-check"></i>
|
||
No audit events found.
|
||
</div>
|
||
@else
|
||
<table class="al-table" id="auditTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Action</th>
|
||
<th>User</th>
|
||
<th>Subject</th>
|
||
<th>IP Address</th>
|
||
<th>Device</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach($logs as $log)
|
||
@php
|
||
$severity = $log->severity;
|
||
$icon = match($severity) {
|
||
'danger' => 'bi-trash3-fill',
|
||
'warning' => 'bi-exclamation-triangle-fill',
|
||
'success' => 'bi-cloud-upload-fill',
|
||
'info' => 'bi-box-arrow-in-right',
|
||
'purple' => 'bi-shield-lock-fill',
|
||
'orange' => 'bi-person-badge-fill',
|
||
'muted' => 'bi-box-arrow-right',
|
||
default => 'bi-dot',
|
||
};
|
||
$ua = $log->user_agent ?? '';
|
||
$device = match(true) {
|
||
str_contains($ua, 'Mobile') || str_contains($ua, 'Android') => '📱 Mobile',
|
||
str_contains($ua, 'Tablet') || str_contains($ua, 'iPad') => '📟 Tablet',
|
||
default => '🖥️ Desktop',
|
||
};
|
||
$browser = match(true) {
|
||
str_contains($ua, 'Firefox') => 'Firefox',
|
||
str_contains($ua, 'Chrome') && !str_contains($ua, 'Chromium') && !str_contains($ua, 'Edg') => 'Chrome',
|
||
str_contains($ua, 'Safari') && !str_contains($ua, 'Chrome') => 'Safari',
|
||
str_contains($ua, 'Edg') => 'Edge',
|
||
str_contains($ua, 'curl') => 'cURL',
|
||
default => 'Unknown',
|
||
};
|
||
@endphp
|
||
<tr>
|
||
<td style="white-space:nowrap;">
|
||
<div style="font-weight:600;font-size:13px;">{{ $log->created_at->format('d M Y') }}</div>
|
||
<div style="font-size:11px;color:var(--text-muted);">{{ $log->created_at->format('H:i:s') }}</div>
|
||
<div style="font-size:10px;color:var(--text-muted);margin-top:1px;" title="{{ $log->created_at }}">{{ $log->created_at->diffForHumans() }}</div>
|
||
</td>
|
||
<td>
|
||
<span class="al-badge al-badge-{{ $severity }}">
|
||
<i class="bi {{ $icon }}"></i>
|
||
{{ $log->action_label }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div class="al-user">
|
||
@if($log->user)
|
||
<img src="{{ $log->user->avatar_url }}" alt="" class="al-user-avatar">
|
||
@else
|
||
<div class="al-user-avatar" style="background:var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;">
|
||
<i class="bi bi-person" style="color:var(--text-muted);"></i>
|
||
</div>
|
||
@endif
|
||
<div>
|
||
<div class="al-user-name">
|
||
@if($log->user)
|
||
<a href="{{ route('admin.users.edit', $log->user) }}" style="color:inherit;text-decoration:none;" onmouseover="this.style.textDecoration='underline'" onmouseout="this.style.textDecoration='none'">
|
||
{{ $log->user_name ?? 'Unknown' }}
|
||
</a>
|
||
@else
|
||
{{ $log->user_name ?? ($log->action === 'user.login.failed' ? 'Guest' : 'Unknown') }}
|
||
@endif
|
||
</div>
|
||
@if($log->user_id)
|
||
<div class="al-user-id">#{{ $log->user_id }}</div>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
@if($log->subject_label)
|
||
<div class="al-subject" title="{{ $log->subject_label }}">
|
||
@if($log->subject_type === 'Video' && $log->subject_id)
|
||
<a href="{{ route('videos.show', \App\Models\Video::encodeId((int)$log->subject_id)) }}" style="color:var(--brand);text-decoration:none;" target="_blank">
|
||
{{ $log->subject_label }}
|
||
</a>
|
||
@elseif($log->subject_type === 'User' && $log->subject_id)
|
||
<a href="{{ route('admin.users.edit', $log->subject_id) }}" style="color:var(--brand);text-decoration:none;">
|
||
{{ $log->subject_label }}
|
||
</a>
|
||
@else
|
||
{{ $log->subject_label }}
|
||
@endif
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text-muted);">{{ $log->subject_type }}</div>
|
||
@else
|
||
<span style="color:var(--text-muted);">—</span>
|
||
@endif
|
||
</td>
|
||
<td>
|
||
<div class="al-ip">
|
||
@if($log->ip_address)
|
||
<a href="{{ route('admin.audit', ['ip' => $log->ip_address]) }}" title="Filter by this IP">
|
||
{{ $log->ip_address }}
|
||
</a>
|
||
@else
|
||
<span>—</span>
|
||
@endif
|
||
</div>
|
||
</td>
|
||
<td style="font-size:12px;color:var(--text-muted);white-space:nowrap;">
|
||
{{ $device }} · {{ $browser }}
|
||
</td>
|
||
<td>
|
||
@if($log->details)
|
||
<button class="al-details-btn" onclick="toggleDetails({{ $log->id }})" title="View details">
|
||
<i class="bi bi-code-slash"></i>
|
||
</button>
|
||
@endif
|
||
</td>
|
||
</tr>
|
||
@if($log->details)
|
||
<tr class="al-details-row" id="details-{{ $log->id }}" style="display:none;">
|
||
<td colspan="7">
|
||
<div class="al-details-inner">{{ json_encode($log->details, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</div>
|
||
</td>
|
||
</tr>
|
||
@endif
|
||
@endforeach
|
||
</tbody>
|
||
</table>
|
||
|
||
{{-- Pagination --}}
|
||
@if($logs->hasPages())
|
||
<div class="al-pagination">
|
||
<div>
|
||
Showing {{ $logs->firstItem() }}–{{ $logs->lastItem() }} of {{ $logs->total() }} events
|
||
</div>
|
||
<div class="al-page-links">
|
||
@if($logs->onFirstPage())
|
||
<span class="disabled"><i class="bi bi-chevron-left"></i></span>
|
||
@else
|
||
<a href="{{ $logs->previousPageUrl() }}"><i class="bi bi-chevron-left"></i></a>
|
||
@endif
|
||
|
||
@foreach($logs->getUrlRange(max(1, $logs->currentPage()-2), min($logs->lastPage(), $logs->currentPage()+2)) as $page => $url)
|
||
@if($page == $logs->currentPage())
|
||
<span class="active">{{ $page }}</span>
|
||
@else
|
||
<a href="{{ $url }}">{{ $page }}</a>
|
||
@endif
|
||
@endforeach
|
||
|
||
@if($logs->hasMorePages())
|
||
<a href="{{ $logs->nextPageUrl() }}"><i class="bi bi-chevron-right"></i></a>
|
||
@else
|
||
<span class="disabled"><i class="bi bi-chevron-right"></i></span>
|
||
@endif
|
||
</div>
|
||
</div>
|
||
@endif
|
||
@endif
|
||
</div>
|
||
|
||
<script>
|
||
function toggleDetails(id) {
|
||
const row = document.getElementById('details-' + id);
|
||
if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
|
||
}
|
||
</script>
|
||
@endsection
|