463 lines
12 KiB
JavaScript
Executable File
463 lines
12 KiB
JavaScript
Executable File
/**
|
|
* MSPE Admin Panel JavaScript
|
|
*/
|
|
|
|
// Lock admin UI until auth verification completes (except login/reset pages)
|
|
document.documentElement.classList.add('auth-pending');
|
|
|
|
const authStyle = document.createElement('style');
|
|
authStyle.textContent = `
|
|
html.auth-pending body {
|
|
visibility: hidden;
|
|
}
|
|
html.auth-pending::after {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 99999;
|
|
background: #0f172a;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
html.auth-pending::before {
|
|
content: '';
|
|
position: fixed;
|
|
top: 50%; left: 50%;
|
|
z-index: 100000;
|
|
width: 36px; height: 36px;
|
|
margin: -18px 0 0 -18px;
|
|
border: 3px solid rgba(59,130,246,0.2);
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: authSpin .7s linear infinite;
|
|
}
|
|
@keyframes authSpin { to { transform: rotate(360deg) } }
|
|
`;
|
|
document.head.appendChild(authStyle);
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (isAuthExemptPage()) {
|
|
unlockAdminUi();
|
|
enforceSafeBlankLinks();
|
|
return;
|
|
}
|
|
|
|
// Initialize components
|
|
initSidebar();
|
|
initDropdowns();
|
|
initLogout();
|
|
enforceSafeBlankLinks();
|
|
|
|
checkAuth();
|
|
});
|
|
|
|
function enforceSafeBlankLinks(scope = document) {
|
|
scope.querySelectorAll('a[target="_blank"]').forEach(link => {
|
|
const currentRel = (link.getAttribute('rel') || '').toLowerCase();
|
|
const relParts = new Set(currentRel.split(/\s+/).filter(Boolean));
|
|
relParts.add('noopener');
|
|
relParts.add('noreferrer');
|
|
link.setAttribute('rel', Array.from(relParts).join(' '));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sidebar Toggle
|
|
*/
|
|
function initSidebar() {
|
|
const sidebar = document.getElementById('sidebar');
|
|
const mobileToggle = document.getElementById('mobile-toggle');
|
|
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
|
|
if (mobileToggle) {
|
|
mobileToggle.addEventListener('click', function() {
|
|
sidebar.classList.toggle('active');
|
|
});
|
|
}
|
|
|
|
if (sidebarToggle) {
|
|
sidebarToggle.addEventListener('click', function() {
|
|
sidebar.classList.toggle('active');
|
|
});
|
|
}
|
|
|
|
// Close sidebar when clicking outside on mobile
|
|
document.addEventListener('click', function(e) {
|
|
if (window.innerWidth <= 1024) {
|
|
if (!sidebar.contains(e.target) && !mobileToggle?.contains(e.target)) {
|
|
sidebar.classList.remove('active');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dropdowns
|
|
*/
|
|
function initDropdowns() {
|
|
const dropdowns = document.querySelectorAll('.user-dropdown');
|
|
|
|
dropdowns.forEach(dropdown => {
|
|
const toggle = dropdown.querySelector('.dropdown-toggle');
|
|
|
|
if (toggle) {
|
|
toggle.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
dropdown.classList.toggle('active');
|
|
});
|
|
}
|
|
});
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener('click', function() {
|
|
dropdowns.forEach(dropdown => {
|
|
dropdown.classList.remove('active');
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Logout Handler
|
|
*/
|
|
function initLogout() {
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
|
|
if (logoutBtn) {
|
|
logoutBtn.addEventListener('click', async function(e) {
|
|
e.preventDefault();
|
|
|
|
try {
|
|
await fetch('/api/auth.php', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ action: 'logout' })
|
|
});
|
|
} catch (error) {
|
|
console.log('Logout request failed, proceeding with local logout');
|
|
}
|
|
|
|
// Redirect to login
|
|
window.location.href = 'index.html';
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check Authentication
|
|
*/
|
|
function checkAuth() {
|
|
fetch('/api/auth.php', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ action: 'verify' })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
applyUserToHeader(data.user || {});
|
|
unlockAdminUi();
|
|
startSessionWatchdog();
|
|
} else {
|
|
window.location.href = 'index.html';
|
|
}
|
|
})
|
|
.catch(() => {
|
|
window.location.href = 'index.html';
|
|
});
|
|
}
|
|
|
|
function isAuthExemptPage() {
|
|
return window.location.pathname.includes('index.html') ||
|
|
window.location.pathname.endsWith('/admin/') ||
|
|
window.location.pathname.includes('reset-password.html');
|
|
}
|
|
|
|
function unlockAdminUi() {
|
|
document.documentElement.classList.remove('auth-pending');
|
|
}
|
|
|
|
/**
|
|
* Periodic Session Re-Verification (every 5 minutes)
|
|
* Catches expired / revoked sessions proactively.
|
|
*/
|
|
let _sessionInterval = null;
|
|
function startSessionWatchdog() {
|
|
if (_sessionInterval) return;
|
|
_sessionInterval = setInterval(() => {
|
|
fetch('/api/auth.php', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'verify' })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data.success) {
|
|
clearInterval(_sessionInterval);
|
|
showNotification('Session expired — redirecting to login.', 'warning');
|
|
setTimeout(() => { window.location.href = 'index.html'; }, 2000);
|
|
}
|
|
})
|
|
.catch(() => { /* network hiccup, retry next cycle */ });
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
function applyUserToHeader(user) {
|
|
const nameSpans = document.querySelectorAll('.user-name');
|
|
nameSpans.forEach(el => {
|
|
el.textContent = user.username || 'Admin';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* API Helper Functions
|
|
*/
|
|
const API = {
|
|
baseUrl: '/api',
|
|
|
|
async request(endpoint, options = {}) {
|
|
const defaultOptions = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
};
|
|
|
|
const response = await fetch(`${this.baseUrl}/${endpoint}`, {
|
|
...defaultOptions,
|
|
...options,
|
|
headers: {
|
|
...defaultOptions.headers,
|
|
...options.headers
|
|
}
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
window.location.href = 'index.html';
|
|
return;
|
|
}
|
|
|
|
if (response.status === 403) {
|
|
return response.json();
|
|
}
|
|
|
|
return response.json();
|
|
},
|
|
|
|
async get(endpoint) {
|
|
return this.request(endpoint);
|
|
},
|
|
|
|
async post(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data)
|
|
});
|
|
},
|
|
|
|
async put(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data)
|
|
});
|
|
},
|
|
|
|
async delete(endpoint) {
|
|
return this.request(endpoint, {
|
|
method: 'DELETE'
|
|
});
|
|
},
|
|
|
|
async upload(endpoint, formData) {
|
|
const response = await fetch(`${this.baseUrl}/${endpoint}`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
window.location.href = 'index.html';
|
|
return;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Notification Helper
|
|
*/
|
|
function showNotification(message, type = 'success') {
|
|
const iconMap = {
|
|
success: 'check-circle',
|
|
error: 'exclamation-circle',
|
|
info: 'info-circle',
|
|
warning: 'exclamation-triangle'
|
|
};
|
|
const icon = iconMap[type] || 'info-circle';
|
|
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification ${type}`;
|
|
|
|
const iconEl = document.createElement('i');
|
|
iconEl.className = `fas fa-${icon}`;
|
|
const spanEl = document.createElement('span');
|
|
spanEl.textContent = message;
|
|
notification.appendChild(iconEl);
|
|
notification.appendChild(spanEl);
|
|
|
|
// Add styles if not present
|
|
if (!document.querySelector('.notification-styles')) {
|
|
const styles = document.createElement('style');
|
|
styles.className = 'notification-styles';
|
|
styles.textContent = `
|
|
.notification {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 1rem 1.5rem;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-weight: 500;
|
|
z-index: 9999;
|
|
animation: slideIn 0.3s ease;
|
|
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
|
|
}
|
|
.notification.success {
|
|
background: #10b981;
|
|
color: white;
|
|
}
|
|
.notification.error {
|
|
background: #ef4444;
|
|
color: white;
|
|
}
|
|
.notification.info {
|
|
background: #3b82f6;
|
|
color: white;
|
|
}
|
|
.notification.warning {
|
|
background: #f59e0b;
|
|
color: white;
|
|
}
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(styles);
|
|
}
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.style.animation = 'slideIn 0.3s ease reverse';
|
|
setTimeout(() => notification.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
/**
|
|
* Format Date Helper
|
|
*/
|
|
function formatDate(dateString) {
|
|
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
|
return new Date(dateString).toLocaleDateString('en-US', options);
|
|
}
|
|
|
|
/**
|
|
* Confirm Dialog Helper
|
|
*/
|
|
function confirmDialog(message) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal active';
|
|
modal.innerHTML = `
|
|
<div class="modal-overlay"></div>
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h3>Confirm</h3>
|
|
<button class="modal-close">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="confirm-msg"></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" id="confirm-cancel">Cancel</button>
|
|
<button class="btn btn-danger" id="confirm-ok">Confirm</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
modal.querySelector('.confirm-msg').textContent = message;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector('#confirm-ok').addEventListener('click', () => {
|
|
modal.remove();
|
|
resolve(true);
|
|
});
|
|
|
|
modal.querySelector('#confirm-cancel').addEventListener('click', () => {
|
|
modal.remove();
|
|
resolve(false);
|
|
});
|
|
|
|
modal.querySelector('.modal-close').addEventListener('click', () => {
|
|
modal.remove();
|
|
resolve(false);
|
|
});
|
|
|
|
modal.querySelector('.modal-overlay').addEventListener('click', () => {
|
|
modal.remove();
|
|
resolve(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Debounce Helper
|
|
*/
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* HTML Escape Helper — prevents XSS when inserting dynamic values into innerHTML
|
|
*/
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.appendChild(document.createTextNode(String(str ?? '')));
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Export for use in other scripts
|
|
window.MSPE = {
|
|
API,
|
|
showNotification,
|
|
formatDate,
|
|
confirmDialog,
|
|
debounce,
|
|
escapeHtml
|
|
};
|