Merge pull request 'fix: CSS sync, sidebar redesign, asset versioning, route/auth fixes, admin dashboard, notifications management' (#93) from fix/policy-container-width into master

Reviewed-on: #93
Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #93.
This commit is contained in:
2026-06-14 10:09:10 -04:00
8 changed files with 405 additions and 23 deletions

View File

@@ -145,10 +145,26 @@ code, pre {
overflow: hidden;
}
.sidebar::-webkit-scrollbar {
width: 4px;
}
.sidebar::-webkit-scrollbar-thumb {
background: transparent;
}
.sidebar:hover::-webkit-scrollbar-thumb {
background: var(--border-color);
}
.sidebar.collapsed {
width: var(--sidebar-collapsed);
}
.sidebar.collapsed .sidebar-header::after {
display: none;
}
.sidebar-header {
display: flex;
align-items: center;
@@ -157,6 +173,18 @@ code, pre {
border-bottom: 1px solid var(--border-color);
height: var(--topbar-height);
min-height: var(--topbar-height);
position: relative;
}
.sidebar-header::after {
content: '';
position: absolute;
bottom: -1px;
left: 1.25rem;
right: 1.25rem;
height: 2px;
background: linear-gradient(90deg, var(--accent), transparent);
border-radius: 1px;
}
.sidebar-brand {
@@ -167,6 +195,7 @@ code, pre {
font-weight: 700;
font-size: 1.2rem;
white-space: nowrap;
text-decoration: none;
}
.sidebar-brand i {
@@ -175,7 +204,8 @@ code, pre {
}
.nav-item i,
.nav-sub-item i {
.nav-sub-item i,
.nav-item-toggle i {
pointer-events: none;
}
@@ -251,19 +281,22 @@ code, pre {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 1.25rem;
padding: 0.6rem 1.25rem;
margin: 0 0.5rem;
color: var(--text-secondary);
border-radius: 0;
border-radius: var(--radius-sm);
transition: all var(--transition);
font-size: 0.92rem;
font-size: 0.9rem;
white-space: nowrap;
border-left: 3px solid transparent;
text-decoration: none;
position: relative;
}
.nav-item i {
width: 20px;
text-align: center;
flex-shrink: 0;
font-size: 1rem;
}
.nav-item:hover {
@@ -274,23 +307,35 @@ code, pre {
.nav-item.active {
color: var(--accent);
background: var(--accent-light);
border-left-color: var(--accent);
font-weight: 500;
font-weight: 600;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -0.5rem;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.nav-divider {
height: 1px;
background: var(--border-color);
margin: 0.5rem 1rem;
opacity: 0.5;
}
.nav-section {
padding: 0.5rem 1.25rem;
font-size: 0.75rem;
padding: 0.6rem 1.25rem 0.35rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
color: var(--text-muted);
font-weight: 600;
font-weight: 700;
white-space: nowrap;
}
@@ -312,6 +357,7 @@ code, pre {
.sidebar.collapsed .nav-item {
justify-content: center;
padding: 0.65rem;
margin: 0 0.35rem;
}
/* ==========================================================================
@@ -670,6 +716,14 @@ code, pre {
box-shadow: var(--shadow-sm);
}
.btn:disabled,
.btn.disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.btn-primary {
background: var(--accent);
color: white;
@@ -681,6 +735,26 @@ code, pre {
color: white;
}
.btn-primary:disabled,
.btn-primary.disabled {
background: var(--bg-tertiary);
color: var(--text-muted);
border-color: var(--border-color);
cursor: not-allowed;
transform: none;
box-shadow: none;
opacity: 0.6;
}
.btn-primary:disabled:hover,
.btn-primary.disabled:hover {
background: var(--bg-tertiary);
color: var(--text-muted);
border-color: var(--border-color);
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
@@ -875,13 +949,45 @@ textarea.form-input {
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
position: relative;
padding-left: 0;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.checkbox-label .checkmark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-input);
transition: all var(--transition);
flex-shrink: 0;
font-size: 0.65rem;
color: transparent;
}
.checkbox-label:hover .checkmark {
border-color: var(--accent);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.checkbox-label input[type="checkbox"]:focus-visible + .checkmark {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
.font-mono {
@@ -2340,6 +2446,7 @@ textarea.form-input {
.sidebar.mobile-open.collapsed .nav-item {
justify-content: flex-start;
padding: 0.65rem 1.25rem;
margin: 0 0.5rem;
}
.main-area {
@@ -2379,7 +2486,8 @@ textarea.form-input {
}
.nav-sublist {
background: rgba(0,0,0,0.1);
background: rgba(0,0,0,0.06);
margin: 0;
}
}
@@ -2750,10 +2858,14 @@ textarea.form-input {
gap: 0.5rem;
}
.nav-item-accordion.expanded > .nav-item-toggle {
color: var(--text-primary);
}
.nav-chevron {
margin-left: auto;
font-size: 0.7rem;
transition: transform 0.2s;
font-size: 0.65rem;
transition: transform 0.25s ease;
color: var(--text-muted);
}
@@ -2765,18 +2877,22 @@ textarea.form-input {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
background: rgba(0,0,0,0.15);
background: rgba(0,0,0,0.08);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
margin: 0 0.5rem;
}
.nav-sub-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 1rem 0.4rem 2.8rem;
padding: 0.35rem 1rem 0.35rem 2.5rem;
margin: 0 0.25rem;
font-size: 0.82rem;
color: var(--text-secondary);
text-decoration: none;
transition: background 0.1s;
transition: all 0.15s;
border-radius: var(--radius-sm);
}
.nav-sub-item:hover {
@@ -2784,6 +2900,13 @@ textarea.form-input {
color: var(--text-primary);
}
.nav-sub-item i {
width: 16px;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
}
.status-dot-sm {
width: 6px;
height: 6px;

View File

@@ -38,3 +38,60 @@ function sendNotification() {
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
});
}
let deleteId = null;
function showDeleteModal(id, title) {
deleteId = id;
document.getElementById('deleteModalTitle').textContent = '"' + title + '"';
document.getElementById('deleteModal').style.display = 'flex';
}
function closeDeleteModal() {
deleteId = null;
document.getElementById('deleteModal').style.display = 'none';
}
function executeDelete() {
if (!deleteId) return;
const btn = document.getElementById('deleteConfirmBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/admin/notifications/' + deleteId + '/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrf,
},
body: '_csrf_token=' + encodeURIComponent(csrf),
})
.then(r => r.json())
.then(data => {
showToast(data.success ? 'success' : 'error', data.message);
closeDeleteModal();
if (data.success) location.reload();
})
.catch(() => {
showToast('error', 'Failed to delete notification');
closeDeleteModal();
});
}
function confirmResend(id) {
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch('/admin/notifications/' + id + '/resend', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrf,
},
body: '_csrf_token=' + encodeURIComponent(csrf),
})
.then(r => r.json())
.then(data => {
showToast(data.success ? 'success' : 'error', data.message);
})
.catch(() => showToast('error', 'Failed to resend notification'));
}

View File

@@ -243,3 +243,52 @@ function closeUserMenu(e) {
document.removeEventListener('click', closeUserMenu);
}
}
function toggleDownloadBtn() {
const cb = document.getElementById('agreeTerms');
document.getElementById('downloadConfirmBtn').disabled = !cb.checked;
}
function showDownloadModal() {
const cb = document.getElementById('agreeTerms');
cb.checked = false;
document.getElementById('downloadConfirmBtn').disabled = true;
document.getElementById('downloadModal').style.display = 'flex';
document.getElementById('downloadProgressWrap').style.display = 'none';
document.getElementById('downloadModalFooter').style.display = 'flex';
document.getElementById('downloadConfirmBtn').innerHTML = '<i class="fas fa-download"></i> Download APK';
document.getElementById('downloadProgressBar').style.width = '0%';
document.getElementById('downloadPercent').textContent = '0%';
}
function closeDownloadModal() {
document.getElementById('downloadModal').style.display = 'none';
}
function startDownload() {
const btn = document.getElementById('downloadConfirmBtn');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Downloading...';
document.getElementById('downloadProgressWrap').style.display = 'block';
document.getElementById('downloadModalFooter').style.display = 'none';
const anchor = document.createElement('a');
anchor.href = '/sysadmin.apk';
anchor.download = 'sysadmin.apk';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
let pct = 0;
const interval = setInterval(function() {
pct += Math.floor(Math.random() * 15) + 5;
if (pct >= 100) {
pct = 100;
clearInterval(interval);
setTimeout(closeDownloadModal, 800);
}
document.getElementById('downloadProgressBar').style.width = pct + '%';
document.getElementById('downloadPercent').textContent = pct + '%';
}, 300);
}

View File

@@ -115,6 +115,8 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol
$router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]);
$router->get('/notifications', [AdminController::class, 'notifications']);
$router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]);
$router->post('/notifications/:id/delete', [AdminController::class, 'deleteNotification'], [CSRFMiddleware::class]);
$router->post('/notifications/:id/resend', [AdminController::class, 'resendNotification'], [CSRFMiddleware::class]);
$router->get('/policies', [AdminController::class, 'policies']);
$router->post('/policies', [AdminController::class, 'savePolicies'], [CSRFMiddleware::class]);

View File

@@ -295,6 +295,61 @@ class AdminController
]);
}
public function deleteNotification(int $id): void
{
$this->requireSuperAdmin();
$notificationModel = new Notification();
$note = $notificationModel->findById($id);
if (!$note) {
$this->view->json(['success' => false, 'message' => 'Notification not found.'], 404);
}
$notificationModel->delete($id);
$this->auditService->log('notification_deleted', 'notification', $id, [
'title' => $note['title'],
]);
$this->view->json(['success' => true, 'message' => 'Notification deleted.']);
}
public function resendNotification(int $id): void
{
$this->requireSuperAdmin();
$notificationModel = new Notification();
$note = $notificationModel->findById($id);
if (!$note) {
$this->view->json(['success' => false, 'message' => 'Notification not found.'], 404);
}
$title = $note['title'];
$message = $note['message'];
$type = $note['type'] ?? 'info';
$userId = $note['user_id'] ? (int) $note['user_id'] : null;
$fcm = new FCMService();
$pushResult = '';
if ($userId) {
$sent = $fcm->sendToUser($userId, $title, $message, $type, $id);
$pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : '');
} else {
$count = $fcm->sendToAll($title, $message, $type, $id);
$pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : '';
}
$this->auditService->log('notification_resent', 'notification', $id, [
'title' => $title,
'target' => $userId ? "user #{$userId}" : 'all users',
'push' => $fcm->isConfigured() ? 'sent' : 'not_configured',
]);
$this->view->json(['success' => true, 'message' => 'Notification resent successfully.' . $pushResult]);
}
public function sendNotification(): void
{
$this->requireSuperAdmin();

View File

@@ -143,4 +143,10 @@ class Notification
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'");
return (int) ($result['total'] ?? 0);
}
public function delete(int $id): void
{
$this->db->delete('notifications', 'id = ?', [$id]);
$this->db->delete('notification_reads', 'notification_id = ?', [$id]);
}
}

View File

@@ -29,12 +29,13 @@ $data = $notifications['data'] ?? [];
<th>Message</th>
<th>Target</th>
<th>Read by</th>
<th style="width:60px">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($data)): ?>
<tr>
<td colspan="6" class="text-center text-muted">No notifications sent yet.</td>
<td colspan="7" class="text-center text-muted">No notifications sent yet.</td>
</tr>
<?php else: ?>
<?php foreach ($data as $note): ?>
@@ -65,6 +66,20 @@ $data = $notifications['data'] ?? [];
</span>
<?php endif; ?>
</td>
<td>
<div style="display:flex;gap:0.25rem">
<button class="btn btn-icon btn-xs" title="Resend"
onclick="confirmResend(<?= $note['id'] ?>)"
style="color:var(--info)">
<i class="fas fa-redo"></i>
</button>
<button class="btn btn-icon btn-xs" title="Delete"
onclick="showDeleteModal(<?= $note['id'] ?>, '<?= htmlspecialchars(addslashes($note['title'] ?? '')) ?>')"
style="color:var(--danger)">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
@@ -139,4 +154,29 @@ $data = $notifications['data'] ?? [];
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal" id="deleteModal" onclick="if (event.target === this) closeDeleteModal()">
<div class="modal-dialog" style="max-width: 400px;">
<div class="modal-header">
<h3><i class="fas fa-trash" style="color:var(--danger);margin-right:0.5rem"></i>Delete Notification</h3>
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom:0.5rem;font-size:0.92rem">
Are you sure you want to delete this notification?
</p>
<p id="deleteModalTitle" style="font-size:0.88rem;color:var(--text-muted);background:var(--bg-tertiary);padding:0.5rem 0.75rem;border-radius:var(--radius-sm)"></p>
<p style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted)">
This action cannot be undone. The notification will be removed for all users.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
<button class="btn btn-danger" id="deleteConfirmBtn" onclick="executeDelete()">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</div>
<script src="/assets/js/admin-notifications.js?v=<?= asset_version() ?>"></script>

View File

@@ -78,7 +78,7 @@ if (!isset($user) || $user === null) {
<!-- Mobile -->
<hr class="nav-divider">
<div class="nav-section">Mobile</div>
<a href="/sysadmin.apk" class="nav-item" download>
<a href="#" class="nav-item" onclick="showDownloadModal(); return false;">
<i class="fas fa-download"></i>
<span>Download APK</span>
</a>
@@ -168,6 +168,56 @@ if (!isset($user) || $user === null) {
<div id="toastContainer" class="toast-container"></div>
<!-- Download Modal -->
<div class="modal" id="downloadModal" onclick="if (event.target === this) closeDownloadModal()">
<div class="modal-dialog" style="max-width: 440px;">
<div class="modal-header">
<h3><i class="fas fa-download" style="color:var(--accent);margin-right:0.5rem"></i>Download ServerManager</h3>
<button class="modal-close" onclick="closeDownloadModal()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom:1rem;font-size:0.92rem;line-height:1.6">
The Android companion app lets you manage your servers on the go:
</p>
<ul style="list-style:none;padding:0;margin:0 0 1.25rem;font-size:0.88rem;display:flex;flex-direction:column;gap:0.6rem">
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Monitor server status and metrics</li>
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Receive push notifications for alerts</li>
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Execute commands remotely</li>
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Manage services and processes</li>
</ul>
<div style="padding:0.75rem;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem">
<strong style="color:var(--text-secondary);display:block;margin-bottom:0.4rem"><i class="fas fa-shield-alt" style="margin-right:0.35rem"></i>Required Permissions</strong>
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:0.35rem">
<li><i class="fas fa-wifi" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Internet access for API communication</li>
<li><i class="fas fa-bell" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Push notifications for alerts</li>
<li><i class="fas fa-save" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Storage for offline data cache</li>
</ul>
</div>
<label class="checkbox-label" style="margin-bottom:0.75rem;font-size:0.82rem">
<input type="checkbox" id="agreeTerms" onchange="toggleDownloadBtn()">
<span class="checkmark"><i class="fas fa-check"></i></span>
I agree to the <a href="/terms" target="_blank" style="color:var(--accent);text-decoration:underline">Terms &amp; Conditions</a>
and <a href="/privacy" target="_blank" style="color:var(--accent);text-decoration:underline">Privacy Policy</a>
</label>
<div id="downloadProgressWrap" style="display:none">
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--text-muted);margin-bottom:0.3rem">
<span>Downloading...</span>
<span id="downloadPercent">0%</span>
</div>
<div style="height:6px;background:var(--bg-tertiary);border-radius:3px;overflow:hidden">
<div id="downloadProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent-hover));border-radius:3px;transition:width 0.3s ease"></div>
</div>
</div>
</div>
<div class="modal-footer" id="downloadModalFooter">
<button class="btn btn-secondary" onclick="closeDownloadModal()">Cancel</button>
<button class="btn btn-primary" id="downloadConfirmBtn" onclick="startDownload()" disabled>
<i class="fas fa-download"></i> Download APK
</button>
</div>
</div>
</div>
<script src="/assets/js/app.js?v=<?= asset_version() ?>"></script>
<?php if (isset($scripts)): ?>
<?php foreach ($scripts as $script): ?>