Security improvements: - Auto-logout after 30 minutes of inactivity - Session warning 5 minutes before expiry with option to extend - Activity-based session extension on user interaction - Session validation on page load and API calls - Periodic session validity checks every minute - Secure cleanup of tokens and timers on logout - Protection against expired session usage This prevents unauthorized access if admin leaves session open or if tokens are compromised.
538 lines
20 KiB
JavaScript
538 lines
20 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
let authToken = localStorage.getItem('adminToken');
|
|
let allLocations = [];
|
|
let editingRowId = null;
|
|
let sessionTimeout = null;
|
|
let warningTimeout = null;
|
|
|
|
// Session timeout settings (in milliseconds)
|
|
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes
|
|
const WARNING_TIME = 5 * 60 * 1000; // Show warning 5 minutes before expiry
|
|
|
|
const loginSection = document.getElementById('login-section');
|
|
const adminSection = document.getElementById('admin-section');
|
|
const loginForm = document.getElementById('login-form');
|
|
const loginMessage = document.getElementById('login-message');
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
const locationsTableBody = document.getElementById('locations-tbody');
|
|
|
|
// Check if already logged in and session is still valid
|
|
if (authToken) {
|
|
const loginTime = localStorage.getItem('adminLoginTime');
|
|
const now = Date.now();
|
|
|
|
if (loginTime && (now - parseInt(loginTime)) < SESSION_DURATION) {
|
|
showAdminSection();
|
|
loadLocations();
|
|
startSessionTimer();
|
|
} else {
|
|
// Session expired
|
|
logout('Session expired. Please log in again.');
|
|
}
|
|
}
|
|
|
|
// Login form handler
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const password = document.getElementById('password').value;
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
authToken = data.token;
|
|
localStorage.setItem('adminToken', authToken);
|
|
localStorage.setItem('adminLoginTime', Date.now().toString());
|
|
showAdminSection();
|
|
loadLocations();
|
|
startSessionTimer();
|
|
} else {
|
|
showMessage(loginMessage, data.error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showMessage(loginMessage, 'Login failed. Please try again.', 'error');
|
|
}
|
|
});
|
|
|
|
// Logout handler
|
|
logoutBtn.addEventListener('click', () => {
|
|
logout('Logged out successfully.');
|
|
});
|
|
|
|
// Refresh button handler
|
|
refreshBtn.addEventListener('click', () => {
|
|
loadLocations();
|
|
});
|
|
|
|
function showLoginSection() {
|
|
loginSection.style.display = 'block';
|
|
adminSection.style.display = 'none';
|
|
document.getElementById('password').value = '';
|
|
hideMessage(loginMessage);
|
|
}
|
|
|
|
function showAdminSection() {
|
|
loginSection.style.display = 'none';
|
|
adminSection.style.display = 'block';
|
|
}
|
|
|
|
function showMessage(element, message, type) {
|
|
element.textContent = message;
|
|
element.className = `message ${type}`;
|
|
element.style.display = 'block';
|
|
setTimeout(() => hideMessage(element), 5000);
|
|
}
|
|
|
|
function hideMessage(element) {
|
|
element.style.display = 'none';
|
|
}
|
|
|
|
// Session management functions
|
|
function startSessionTimer() {
|
|
clearSessionTimers();
|
|
|
|
// Set warning timer (5 minutes before expiry)
|
|
warningTimeout = setTimeout(() => {
|
|
showSessionWarning();
|
|
}, SESSION_DURATION - WARNING_TIME);
|
|
|
|
// Set logout timer (30 minutes)
|
|
sessionTimeout = setTimeout(() => {
|
|
logout('Session expired due to inactivity.');
|
|
}, SESSION_DURATION);
|
|
}
|
|
|
|
function clearSessionTimers() {
|
|
if (sessionTimeout) {
|
|
clearTimeout(sessionTimeout);
|
|
sessionTimeout = null;
|
|
}
|
|
if (warningTimeout) {
|
|
clearTimeout(warningTimeout);
|
|
warningTimeout = null;
|
|
}
|
|
}
|
|
|
|
function showSessionWarning() {
|
|
const extend = confirm('Your admin session will expire in 5 minutes. Click OK to extend your session, or Cancel to log out now.');
|
|
if (extend) {
|
|
extendSession();
|
|
} else {
|
|
logout('Session ended by user.');
|
|
}
|
|
}
|
|
|
|
function extendSession() {
|
|
// Update login time and restart timers
|
|
localStorage.setItem('adminLoginTime', Date.now().toString());
|
|
startSessionTimer();
|
|
console.log('Admin session extended for 30 minutes');
|
|
}
|
|
|
|
function logout(message = 'Logged out.') {
|
|
clearSessionTimers();
|
|
authToken = null;
|
|
localStorage.removeItem('adminToken');
|
|
localStorage.removeItem('adminLoginTime');
|
|
showLoginSection();
|
|
if (message) {
|
|
showMessage(loginMessage, message, 'error');
|
|
}
|
|
}
|
|
|
|
// Reset session timer on user activity
|
|
function resetSessionTimer() {
|
|
if (authToken && adminSection.style.display !== 'none') {
|
|
const loginTime = localStorage.getItem('adminLoginTime');
|
|
const now = Date.now();
|
|
|
|
// Only reset if we're within the session duration
|
|
if (loginTime && (now - parseInt(loginTime)) < SESSION_DURATION) {
|
|
localStorage.setItem('adminLoginTime', now.toString());
|
|
startSessionTimer();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for user activity to reset session timer
|
|
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'];
|
|
let activityTimer = null;
|
|
|
|
activityEvents.forEach(event => {
|
|
document.addEventListener(event, () => {
|
|
// Throttle activity detection to once per minute
|
|
if (!activityTimer) {
|
|
activityTimer = setTimeout(() => {
|
|
resetSessionTimer();
|
|
activityTimer = null;
|
|
}, 60000); // 1 minute throttle
|
|
}
|
|
}, true);
|
|
});
|
|
|
|
// Check session validity periodically
|
|
setInterval(() => {
|
|
if (authToken) {
|
|
const loginTime = localStorage.getItem('adminLoginTime');
|
|
const now = Date.now();
|
|
|
|
if (!loginTime || (now - parseInt(loginTime)) >= SESSION_DURATION) {
|
|
logout('Session expired.');
|
|
}
|
|
}
|
|
}, 60000); // Check every minute
|
|
|
|
async function loadLocations() {
|
|
try {
|
|
const response = await fetch('/api/admin/locations', {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
// Token expired or invalid
|
|
logout('Session expired. Please log in again.');
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load locations');
|
|
}
|
|
|
|
allLocations = await response.json();
|
|
updateStats();
|
|
renderLocationsTable();
|
|
} catch (error) {
|
|
console.error('Error loading locations:', error);
|
|
locationsTableBody.innerHTML = '<tr><td colspan="7">Error loading locations</td></tr>';
|
|
}
|
|
}
|
|
|
|
function updateStats() {
|
|
const now = new Date();
|
|
const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
|
|
const activeLocations = allLocations.filter(location =>
|
|
new Date(location.created_at) > fortyEightHoursAgo
|
|
);
|
|
|
|
const persistentLocations = allLocations.filter(location => location.persistent);
|
|
|
|
document.getElementById('total-count').textContent = allLocations.length;
|
|
document.getElementById('active-count').textContent = activeLocations.length;
|
|
document.getElementById('expired-count').textContent = allLocations.length - activeLocations.length;
|
|
document.getElementById('persistent-count').textContent = persistentLocations.length;
|
|
}
|
|
|
|
function renderLocationsTable() {
|
|
if (allLocations.length === 0) {
|
|
locationsTableBody.innerHTML = '<tr><td colspan="7">No locations found</td></tr>';
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
|
|
locationsTableBody.innerHTML = allLocations.map(location => {
|
|
const isActive = new Date(location.created_at) > fortyEightHoursAgo;
|
|
const createdDate = new Date(location.created_at);
|
|
const formattedDate = createdDate.toLocaleString();
|
|
|
|
return `
|
|
<tr data-id="${location.id}" ${editingRowId === location.id ? 'class="edit-row"' : ''}>
|
|
<td>${location.id}</td>
|
|
<td>
|
|
<span class="status-indicator ${isActive ? 'status-active' : 'status-expired'}">
|
|
${isActive ? 'ACTIVE' : 'EXPIRED'}
|
|
</span>
|
|
</td>
|
|
<td class="address-cell" title="${location.address}">${location.address}</td>
|
|
<td>${location.description || ''}</td>
|
|
<td>
|
|
<button class="btn ${location.persistent ? 'btn-save' : 'btn-edit'}"
|
|
onclick="togglePersistent(${location.id})"
|
|
title="${location.persistent ? 'Remove persistent status' : 'Mark as persistent'}">
|
|
${location.persistent ? '🔒 Yes' : '🔓 No'}
|
|
</button>
|
|
</td>
|
|
<td title="${formattedDate}">${getTimeAgo(location.created_at)}</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="btn btn-edit" onclick="editLocation(${location.id})">Edit</button>
|
|
<button class="btn btn-delete" onclick="deleteLocation(${location.id})">Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderEditRow(location) {
|
|
const row = document.querySelector(`tr[data-id="${location.id}"]`);
|
|
const now = new Date();
|
|
const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000);
|
|
const isActive = new Date(location.created_at) > fortyEightHoursAgo;
|
|
const createdDate = new Date(location.created_at);
|
|
const formattedDate = createdDate.toLocaleString();
|
|
|
|
row.innerHTML = `
|
|
<td>${location.id}</td>
|
|
<td>
|
|
<span class="status-indicator ${isActive ? 'status-active' : 'status-expired'}">
|
|
${isActive ? 'ACTIVE' : 'EXPIRED'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<input type="text" class="edit-input" id="edit-address-${location.id}"
|
|
value="${location.address}" placeholder="Address">
|
|
</td>
|
|
<td>
|
|
<input type="text" class="edit-input" id="edit-description-${location.id}"
|
|
value="${location.description || ''}" placeholder="Description">
|
|
</td>
|
|
<td>
|
|
<button class="btn ${location.persistent ? 'btn-save' : 'btn-edit'}"
|
|
onclick="togglePersistent(${location.id})"
|
|
title="${location.persistent ? 'Remove persistent status' : 'Mark as persistent'}">
|
|
${location.persistent ? '🔒 Yes' : '🔓 No'}
|
|
</button>
|
|
</td>
|
|
<td title="${formattedDate}">${getTimeAgo(location.created_at)}</td>
|
|
<td>
|
|
<div class="action-buttons">
|
|
<button class="btn btn-save" onclick="saveLocation(${location.id})">Save</button>
|
|
<button class="btn btn-cancel" onclick="cancelEdit()">Cancel</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
row.className = 'edit-row';
|
|
}
|
|
|
|
window.editLocation = (id) => {
|
|
if (editingRowId) {
|
|
cancelEdit();
|
|
}
|
|
|
|
editingRowId = id;
|
|
const location = allLocations.find(loc => loc.id === id);
|
|
if (location) {
|
|
renderEditRow(location);
|
|
}
|
|
};
|
|
|
|
window.cancelEdit = () => {
|
|
editingRowId = null;
|
|
renderLocationsTable();
|
|
};
|
|
|
|
window.saveLocation = async (id) => {
|
|
const address = document.getElementById(`edit-address-${id}`).value.trim();
|
|
const description = document.getElementById(`edit-description-${id}`).value.trim();
|
|
|
|
if (!address) {
|
|
alert('Address is required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Geocode the address if it was changed
|
|
let latitude = null;
|
|
let longitude = null;
|
|
|
|
const originalLocation = allLocations.find(loc => loc.id === id);
|
|
if (address !== originalLocation.address) {
|
|
try {
|
|
const geoResponse = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}`);
|
|
const geoData = await geoResponse.json();
|
|
|
|
if (geoData && geoData.length > 0) {
|
|
latitude = parseFloat(geoData[0].lat);
|
|
longitude = parseFloat(geoData[0].lon);
|
|
}
|
|
} catch (geoError) {
|
|
console.warn('Geocoding failed, keeping original coordinates');
|
|
latitude = originalLocation.latitude;
|
|
longitude = originalLocation.longitude;
|
|
}
|
|
} else {
|
|
latitude = originalLocation.latitude;
|
|
longitude = originalLocation.longitude;
|
|
}
|
|
|
|
const response = await fetch(`/api/admin/locations/${id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ address, latitude, longitude, description })
|
|
});
|
|
|
|
if (response.ok) {
|
|
editingRowId = null;
|
|
loadLocations(); // Reload to get updated data
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Error updating location: ${error.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving location:', error);
|
|
alert('Error saving location. Please try again.');
|
|
}
|
|
};
|
|
|
|
window.togglePersistent = async (id) => {
|
|
const location = allLocations.find(loc => loc.id === id);
|
|
if (!location) return;
|
|
|
|
const newPersistentStatus = !location.persistent;
|
|
const confirmMessage = newPersistentStatus
|
|
? 'Mark this report as persistent? It will not auto-expire after 48 hours.'
|
|
: 'Remove persistent status? This report will expire normally after 48 hours.';
|
|
|
|
if (!confirm(confirmMessage)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/locations/${id}/persistent`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ persistent: newPersistentStatus })
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadLocations(); // Reload to get updated data
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Error updating persistent status: ${error.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling persistent status:', error);
|
|
alert('Error updating persistent status. Please try again.');
|
|
}
|
|
};
|
|
|
|
window.deleteLocation = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this location report?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/admin/locations/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
loadLocations(); // Reload the table
|
|
} else {
|
|
const error = await response.json();
|
|
alert(`Error deleting location: ${error.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting location:', error);
|
|
alert('Error deleting location. Please try again.');
|
|
}
|
|
};
|
|
|
|
function getTimeAgo(timestamp) {
|
|
const now = new Date();
|
|
const reportTime = new Date(timestamp);
|
|
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
|
|
|
|
if (diffInMinutes < 1) return 'just now';
|
|
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
|
|
|
|
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
|
|
|
|
const diffInDays = Math.floor(diffInHours / 24);
|
|
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
|
|
|
|
return reportTime.toLocaleDateString();
|
|
}
|
|
|
|
// Initialize theme toggle
|
|
initializeTheme();
|
|
});
|
|
|
|
// Theme toggle functionality (shared with main page)
|
|
function initializeTheme() {
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
const themeIcon = document.querySelector('.theme-icon');
|
|
|
|
if (!themeToggle || !themeIcon) {
|
|
console.warn('Theme toggle elements not found');
|
|
return;
|
|
}
|
|
|
|
// Check for saved theme preference or default to auto (follows system)
|
|
const savedTheme = localStorage.getItem('theme') || 'auto';
|
|
applyTheme(savedTheme);
|
|
|
|
// Update icon based on current theme
|
|
updateThemeIcon(savedTheme, themeIcon);
|
|
|
|
// Add click listener for cycling through themes
|
|
themeToggle.addEventListener('click', () => {
|
|
const currentTheme = localStorage.getItem('theme') || 'auto';
|
|
let newTheme;
|
|
|
|
// Cycle: auto → light → dark → auto
|
|
switch(currentTheme) {
|
|
case 'auto':
|
|
newTheme = 'light';
|
|
break;
|
|
case 'light':
|
|
newTheme = 'dark';
|
|
break;
|
|
case 'dark':
|
|
newTheme = 'auto';
|
|
break;
|
|
default:
|
|
newTheme = 'auto';
|
|
}
|
|
|
|
localStorage.setItem('theme', newTheme);
|
|
applyTheme(newTheme);
|
|
updateThemeIcon(newTheme, themeIcon);
|
|
|
|
console.log(`Theme switched to: ${newTheme}`);
|
|
});
|
|
|
|
// Listen for system theme changes when in auto mode
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
const currentTheme = localStorage.getItem('theme') || 'auto';
|
|
if (currentTheme === 'auto') {
|
|
applyTheme('auto');
|
|
}
|
|
});
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
}
|
|
|
|
function updateThemeIcon(theme, iconElement) {
|
|
switch(theme) {
|
|
case 'auto':
|
|
iconElement.textContent = '🌍'; // Globe (auto)
|
|
break;
|
|
case 'light':
|
|
iconElement.textContent = '☀️'; // Sun (light)
|
|
break;
|
|
case 'dark':
|
|
iconElement.textContent = '🌙'; // Moon (dark)
|
|
break;
|
|
}
|
|
}
|