feat: fix double punch, XSS, add log filtering and Easter egg

- Fix duplicate clock-in: server-side BEGIN IMMEDIATE transaction + client-side punchInFlight guard
- Fix accumulating event listeners: switch persistent containers to onclick property assignment
- Remove insecure JWT_SECRET fallback; server refuses to start without it set
- Add escapeHtml and apply it throughout all innerHTML template literals (XSS prevention)
- Fix calendar iframe URL injection by assigning iframe.src directly
- Add status validation on time-off status update endpoint
- Add date range filtering to admin logs tab and employee time log
- Replace Konami code Easter egg with 7-tap logo trigger (works on all devices)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-28 16:42:34 -04:00
parent b5cf8f4346
commit a8b1c68d97
4 changed files with 205 additions and 55 deletions

View File

@ -16,7 +16,9 @@ import {
renderTimeOffHistoryView, renderTimeOffHistoryView,
updatePendingRequestsList, updatePendingRequestsList,
renderEditTimeOffModal, renderEditTimeOffModal,
renderCalendarView renderCalendarView,
renderAdminLogsContent,
renderEmployeeLogsSection
} from './ui.js'; } from './ui.js';
// --- STATE MANAGEMENT --- // --- STATE MANAGEMENT ---
@ -24,6 +26,7 @@ let user = null;
let authToken = null; let authToken = null;
let lastAdminTab = 'overview'; let lastAdminTab = 'overview';
let lastEmployeeTab = 'dashboard'; let lastEmployeeTab = 'dashboard';
let punchInFlight = false;
export { lastAdminTab }; export { lastAdminTab };
// --- NOTIFICATION LOGIC --- // --- NOTIFICATION LOGIC ---
@ -129,7 +132,12 @@ function handleSignOut(message) {
} }
const handlePunch = () => { const handlePunch = () => {
apiCall('/punch', 'POST').then(res => res.success && renderEmployeeDashboard()); if (punchInFlight) return;
punchInFlight = true;
apiCall('/punch', 'POST').then(res => {
punchInFlight = false;
if (res.success) renderEmployeeDashboard();
});
}; };
async function handleChangePassword(e) { async function handleChangePassword(e) {
@ -440,7 +448,7 @@ export function attachEmployeeDashboardListeners() {
const dashboard = document.getElementById('employee-dashboard'); const dashboard = document.getElementById('employee-dashboard');
if (!dashboard) return; if (!dashboard) return;
dashboard.addEventListener('click', async (e) => { dashboard.onclick = async (e) => {
const target = e.target; const target = e.target;
if (target.id === 'punch-btn') handlePunch(); if (target.id === 'punch-btn') handlePunch();
if (target.id === 'change-password-btn') renderChangePasswordModal(handleChangePassword); if (target.id === 'change-password-btn') renderChangePasswordModal(handleChangePassword);
@ -465,13 +473,27 @@ export function attachEmployeeDashboardListeners() {
} }
} }
} }
}); };
const timeOffForm = document.getElementById('time-off-form'); const timeOffForm = document.getElementById('time-off-form');
if (timeOffForm) { if (timeOffForm) {
timeOffForm.addEventListener('submit', handleTimeOffRequest); timeOffForm.addEventListener('submit', handleTimeOffRequest);
} }
const applyEmpLogFilter = () => renderEmployeeLogsSection(
document.getElementById('emp-log-start')?.value || '',
document.getElementById('emp-log-end')?.value || ''
);
document.getElementById('emp-log-start')?.addEventListener('change', applyEmpLogFilter);
document.getElementById('emp-log-end')?.addEventListener('change', applyEmpLogFilter);
document.getElementById('emp-log-clear')?.addEventListener('click', () => {
const s = document.getElementById('emp-log-start');
const e = document.getElementById('emp-log-end');
if (s) s.value = '';
if (e) e.value = '';
renderEmployeeLogsSection('', '');
});
setupEmployeeTabs(); setupEmployeeTabs();
if (lastEmployeeTab !== 'dashboard') { if (lastEmployeeTab !== 'dashboard') {
const tabsContainer = document.getElementById('employee-tabs-nav'); const tabsContainer = document.getElementById('employee-tabs-nav');
@ -484,13 +506,29 @@ export function attachEmployeeDashboardListeners() {
} }
export function attachAdminDashboardListeners() { export function attachAdminDashboardListeners() {
document.getElementById('admin-dashboard')?.addEventListener('click', handleAdminDashboardClick); const adminDashboard = document.getElementById('admin-dashboard');
if (adminDashboard) adminDashboard.onclick = handleAdminDashboardClick;
document.getElementById('create-user-form')?.addEventListener('submit', handleCreateUser); document.getElementById('create-user-form')?.addEventListener('submit', handleCreateUser);
document.getElementById('add-punch-form')?.addEventListener('submit', handleAddPunch); document.getElementById('add-punch-form')?.addEventListener('submit', handleAddPunch);
document.getElementById('add-note-form')?.addEventListener('submit', handleAddNote); document.getElementById('add-note-form')?.addEventListener('submit', handleAddNote);
document.getElementById('calendar-settings-form')?.addEventListener('submit', handleCalendarSettingsSubmit); document.getElementById('calendar-settings-form')?.addEventListener('submit', handleCalendarSettingsSubmit);
document.getElementById('calendar-clear-btn')?.addEventListener('click', handleCalendarSettingsClear); document.getElementById('calendar-clear-btn')?.addEventListener('click', handleCalendarSettingsClear);
document.getElementById('calendar-embed-url')?.addEventListener('input', (e) => updateCalendarPreview(e.target.value.trim())); document.getElementById('calendar-embed-url')?.addEventListener('input', (e) => updateCalendarPreview(e.target.value.trim()));
const applyLogFilter = () => renderAdminLogsContent(
document.getElementById('log-filter-start')?.value || '',
document.getElementById('log-filter-end')?.value || ''
);
document.getElementById('log-filter-start')?.addEventListener('change', applyLogFilter);
document.getElementById('log-filter-end')?.addEventListener('change', applyLogFilter);
document.getElementById('log-filter-clear')?.addEventListener('click', () => {
const s = document.getElementById('log-filter-start');
const e = document.getElementById('log-filter-end');
if (s) s.value = '';
if (e) e.value = '';
renderAdminLogsContent('', '');
});
setupTabbedInterface(); setupTabbedInterface();
const adminContent = document.getElementById('admin-tabs-content'); const adminContent = document.getElementById('admin-tabs-content');
if (adminContent) { if (adminContent) {
@ -503,7 +541,7 @@ export function attachAdminDashboardListeners() {
export function attachTimeOffHistoryListeners() { export function attachTimeOffHistoryListeners() {
const historyView = document.getElementById('admin-time-off-history-view'); const historyView = document.getElementById('admin-time-off-history-view');
if (historyView) { if (historyView) {
historyView.addEventListener('click', handleTimeOffHistoryClick); historyView.onclick = handleTimeOffHistoryClick;
} }
} }
@ -598,6 +636,23 @@ if ('serviceWorker' in navigator) {
} }
document.getElementById('sign-out-btn').addEventListener('click', () => handleSignOut('You have been signed out.')); document.getElementById('sign-out-btn').addEventListener('click', () => handleSignOut('You have been signed out.'));
// Easter egg: 7 rapid taps on the app logo/title
let eggTapCount = 0;
let eggTapTimer = null;
document.querySelector('header nav .flex.items-center').addEventListener('click', () => {
eggTapCount++;
clearTimeout(eggTapTimer);
eggTapTimer = setTimeout(() => { eggTapCount = 0; }, 1500);
if (eggTapCount < 7) return;
eggTapCount = 0;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.88);display:flex;align-items:center;justify-content:center;z-index:9999;cursor:pointer';
overlay.innerHTML = '<div style="text-align:center;color:#fff;padding:2rem;user-select:none"><div style="font-size:5rem">⏰</div><h2 style="font-size:2rem;font-weight:700;margin:1rem 0;letter-spacing:0.05em">CHEAT CODE ACTIVATED</h2><p style="font-size:1.1rem;opacity:0.8">You\'ve earned an extra 15 minute break.</p><p style="font-size:0.85rem;opacity:0.45;margin-top:0.5rem">(Not legally binding)</p></div>';
document.body.appendChild(overlay);
overlay.addEventListener('click', () => overlay.remove());
setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 5000);
});
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && localStorage.getItem('user')) { if (document.visibilityState === 'visible' && localStorage.getItem('user')) {
initializeApp(); initializeApp();

View File

@ -28,6 +28,10 @@ let employeeTimerInterval = null;
let adminTimerIntervals = []; let adminTimerIntervals = [];
let allTimeEntries = []; let allTimeEntries = [];
let allUsers = []; let allUsers = [];
let allEmployeeEntries = [];
// Convenience alias
const esc = utils.escapeHtml;
// --- VIEW MANAGEMENT --- // --- VIEW MANAGEMENT ---
export function showView(viewName) { export function showView(viewName) {
@ -92,6 +96,7 @@ export async function renderEmployeeDashboard() {
let totalMilliseconds = entries.reduce((acc, e) => { let totalMilliseconds = entries.reduce((acc, e) => {
return e.status === 'out' && e.punch_out_time ? acc + (new Date(e.punch_out_time) - new Date(e.punch_in_time)) : acc; return e.status === 'out' && e.punch_out_time ? acc + (new Date(e.punch_out_time) - new Date(e.punch_in_time)) : acc;
}, 0); }, 0);
allEmployeeEntries = entries;
mainViews.employee.innerHTML = ` mainViews.employee.innerHTML = `
<div class="max-w-5xl mx-auto space-y-4"> <div class="max-w-5xl mx-auto space-y-4">
@ -115,7 +120,7 @@ export async function renderEmployeeDashboard() {
</div> </div>
<div class="bg-white rounded-xl shadow-md p-6"> <div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-xl font-bold text-gray-700 mb-4">Notes from Admin</h3> <h3 class="text-xl font-bold text-gray-700 mb-4">Notes from Admin</h3>
<ul class="space-y-4 mt-4 max-h-60 overflow-y-auto">${notes.length > 0 ? notes.map(note => `<li class="bg-amber-50 p-3 rounded-lg border-l-4 border-amber-400"><p class="text-gray-800 break-words">"${note.note_text}"</p><p class="text-xs text-gray-500 text-right mt-2">- ${note.admin_username} on ${utils.formatDate(note.created_at)}</p></li>`).join('') : '<p class="text-gray-500 text-center">You have no new notes.</p>'}</ul> <ul class="space-y-4 mt-4 max-h-60 overflow-y-auto">${notes.length > 0 ? notes.map(note => `<li class="bg-amber-50 p-3 rounded-lg border-l-4 border-amber-400"><p class="text-gray-800 break-words">"${esc(note.note_text)}"</p><p class="text-xs text-gray-500 text-right mt-2">- ${esc(note.admin_username)} on ${utils.formatDate(note.created_at)}</p></li>`).join('') : '<p class="text-gray-500 text-center">You have no new notes.</p>'}</ul>
</div> </div>
<div class="bg-white rounded-xl shadow-md p-6"> <div class="bg-white rounded-xl shadow-md p-6">
<div class="flex justify-between items-center"><h3 class="text-xl font-bold text-gray-700">My Account</h3><button id="change-password-btn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Change Password</button></div> <div class="flex justify-between items-center"><h3 class="text-xl font-bold text-gray-700">My Account</h3><button id="change-password-btn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Change Password</button></div>
@ -146,8 +151,8 @@ export async function renderEmployeeDashboard() {
${requests.map(r => ` ${requests.map(r => `
<tr class="border-t"> <tr class="border-t">
<td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td> <td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td>
<td class="p-2">${r.reason || ''}</td> <td class="p-2">${esc(r.reason)}</td>
<td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${r.status}</td> <td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : r.status === 'denied' ? 'red' : 'gray'}-600">${esc(r.status)}</td>
<td class="p-2"> <td class="p-2">
${r.status === 'pending' ? ` ${r.status === 'pending' ? `
<div class="flex gap-2"> <div class="flex gap-2">
@ -163,8 +168,16 @@ export async function renderEmployeeDashboard() {
</div> </div>
</div> </div>
<div class="bg-white rounded-xl shadow-md p-6"> <div class="bg-white rounded-xl shadow-md p-6">
<h3 class="text-xl font-bold text-gray-700 mb-2">My Time Log</h3> <div class="flex flex-wrap justify-between items-center mb-2">
<div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hours)</th></tr></thead><tbody>${entries.map(e => `<tr class="border-t"><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="duration-${e.id}">${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No entries.</td></tr>'}</tbody></table></div> <h3 class="text-xl font-bold text-gray-700">My Time Log</h3>
<span id="employee-log-filtered-hours" class="text-sm font-medium text-blue-600 hidden"></span>
</div>
<div class="flex flex-wrap gap-3 items-end mb-3">
<div><label class="block text-xs text-gray-500 mb-1">From</label><input type="date" id="emp-log-start" class="p-1.5 border rounded text-sm"></div>
<div><label class="block text-xs text-gray-500 mb-1">To</label><input type="date" id="emp-log-end" class="p-1.5 border rounded text-sm"></div>
<button id="emp-log-clear" class="px-3 py-1.5 text-sm bg-gray-100 rounded hover:bg-gray-200">Clear</button>
</div>
<div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hours)</th></tr></thead><tbody id="employee-log-tbody">${entries.map(e => `<tr class="border-t"><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="duration-${e.id}">${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No entries.</td></tr>'}</tbody></table></div>
</div> </div>
</div> </div>
<div id="tab-content-employee-calendar" class="space-y-4 hidden"> <div id="tab-content-employee-calendar" class="space-y-4 hidden">
@ -177,7 +190,7 @@ export async function renderEmployeeDashboard() {
<iframe id="employee-calendar-iframe" title="Employee Calendar" style="width: 100%; height: 70vh; border: none;"></iframe> <iframe id="employee-calendar-iframe" title="Employee Calendar" style="width: 100%; height: 70vh; border: none;"></iframe>
</div> </div>
<p id="employee-calendar-empty" class="text-sm text-gray-500 mt-2 hidden">Calendar not configured.</p> <p id="employee-calendar-empty" class="text-sm text-gray-500 mt-2 hidden">Calendar not configured.</p>
<p class="text-xs text-gray-400 mt-1">If the calendar doesn't display, use the Open in new tab link.</p> <p class="text-xs text-gray-400 mt-1">If the calendar doesn't display, use the "Open in new tab" link.</p>
</div> </div>
</div> </div>
</div> </div>
@ -224,7 +237,7 @@ export async function renderAdminDashboard() {
</div> </div>
<div id="admin-tabs-content" class="admin-tabs-content bg-white rounded-b-lg shadow-md p-6"> <div id="admin-tabs-content" class="admin-tabs-content bg-white rounded-b-lg shadow-md p-6">
<div id="tab-content-overview" class="space-y-8"> <div id="tab-content-overview" class="space-y-8">
<div><h3 class="text-xl font-bold text-gray-700 mb-2">Currently Punched In</h3><ul class="border rounded-lg divide-y">${punchedInEntries.map(e => `<li class="flex flex-col items-start space-y-2 p-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"><span class="font-medium text-gray-800">${e.username}</span><div class="flex items-center space-x-4"><span class="text-sm text-gray-500">Since: ${utils.formatDateTime(e.punch_in_time)}</span><button class="force-clock-out-btn px-3 py-1 text-xs bg-red-500 text-white rounded whitespace-nowrap" data-userid="${e.user_id}" data-username="${e.username}">Force Clock Out</button></div></li>`).join('') || '<li class="p-4 text-center text-gray-500">None</li>'}</ul></div> <div><h3 class="text-xl font-bold text-gray-700 mb-2">Currently Punched In</h3><ul class="border rounded-lg divide-y">${punchedInEntries.map(e => { const un = esc(e.username); return `<li class="flex flex-col items-start space-y-2 p-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"><span class="font-medium text-gray-800">${un}</span><div class="flex items-center space-x-4"><span class="text-sm text-gray-500">Since: ${utils.formatDateTime(e.punch_in_time)}</span><button class="force-clock-out-btn px-3 py-1 text-xs bg-red-500 text-white rounded whitespace-nowrap" data-userid="${e.user_id}" data-username="${un}">Force Clock Out</button></div></li>`; }).join('') || '<li class="p-4 text-center text-gray-500">None</li>'}</ul></div>
<div> <div>
<div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold text-gray-700">Pending Time Off Requests</h3><button id="view-time-off-history-btn" class="px-4 py-2 text-sm bg-gray-200 rounded-lg hover:bg-gray-300">View History</button></div> <div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold text-gray-700">Pending Time Off Requests</h3><button id="view-time-off-history-btn" class="px-4 py-2 text-sm bg-gray-200 rounded-lg hover:bg-gray-300">View History</button></div>
<div class="overflow-x-auto border rounded-lg"> <div class="overflow-x-auto border rounded-lg">
@ -233,9 +246,9 @@ export async function renderAdminDashboard() {
<tbody> <tbody>
${pendingRequests.map(r => ` ${pendingRequests.map(r => `
<tr class="border-t"> <tr class="border-t">
<td class="p-2">${r.username}</td> <td class="p-2">${esc(r.username)}</td>
<td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td> <td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td>
<td class="p-2">${r.reason||''}</td> <td class="p-2">${esc(r.reason)}</td>
<td class="p-2"> <td class="p-2">
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button> <button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button>
@ -252,28 +265,31 @@ export async function renderAdminDashboard() {
<div> <div>
<h3 class="text-xl font-bold text-gray-700 mb-4">Employee Notes</h3> <h3 class="text-xl font-bold text-gray-700 mb-4">Employee Notes</h3>
<form id="add-note-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"> <form id="add-note-form" class="space-y-3 bg-gray-50 p-4 rounded-lg">
<select id="note-user-select" class="w-full p-2 border rounded" required><option value="">-- Select an Employee --</option>${employeesOnly.map(u => `<option value="${u.id}">${u.username}</option>`).join('')}</select> <select id="note-user-select" class="w-full p-2 border rounded" required><option value="">-- Select an Employee --</option>${employeesOnly.map(u => `<option value="${u.id}">${esc(u.username)}</option>`).join('')}</select>
<textarea id="note-text" placeholder="Write a new note here..." class="w-full p-2 border rounded" rows="3" required></textarea> <textarea id="note-text" placeholder="Write a new note here..." class="w-full p-2 border rounded" rows="3" required></textarea>
<div class="flex gap-2"><button type="submit" class="w-full bg-cyan-600 text-white p-2 rounded hover:bg-cyan-700">Submit Note</button><button type="button" id="view-notes-btn" class="w-full bg-gray-600 text-white p-2 rounded hover:bg-gray-700">View Notes</button></div> <div class="flex gap-2"><button type="submit" class="w-full bg-cyan-600 text-white p-2 rounded hover:bg-cyan-700">Submit Note</button><button type="button" id="view-notes-btn" class="w-full bg-gray-600 text-white p-2 rounded hover:bg-gray-700">View Notes</button></div>
</form> </form>
<div id="employee-notes-container" class="mt-4"></div> <div id="employee-notes-container" class="mt-4"></div>
</div> </div>
</div> </div>
<div id="tab-content-logs" class="space-y-8 hidden"> <div id="tab-content-logs" class="space-y-6 hidden">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600">View Archives</button> <button id="view-archives-btn" class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600">View Archives</button>
<button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600">Archive Time Records</button> <button id="archive-btn" class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600">Archive Time Records</button>
</div> </div>
<div><h3 class="text-xl font-bold text-gray-700 mb-2">Hours by Employee</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Total Hours</th></tr></thead><tbody>${Object.entries(employeeTotals).map(([username, totalMs]) => `<tr class="border-t"><td class="p-2 font-medium">${username}</td><td class="p-2">${utils.formatDecimal(totalMs)}</td></tr>`).join('') || '<tr><td colspan="2" class="text-center p-4">No data.</td></tr>'}</tbody></table></div></div> <div class="flex flex-wrap gap-3 items-end">
<div><h3 class="text-xl font-bold text-gray-700 mb-4">Detailed Logs</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration</th><th class="p-2">Actions</th></tr></thead><tbody>${allTimeEntries.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="admin-duration-${e.id}">${e.punch_out_time ? utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time)) + ' hrs' : '...'}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2"><button class="edit-btn font-medium text-blue-600 hover:underline" data-id="${e.id}">Edit</button><button class="delete-btn font-medium text-red-600 hover:underline" data-id="${e.id}">Delete</button> <div><label class="block text-xs text-gray-500 mb-1">From</label><input type="date" id="log-filter-start" class="p-1.5 border rounded text-sm"></div>
${e.status === 'out' ? `<button class="archive-log-btn font-medium text-amber-600 hover:underline" data-id="${e.id}">Archive</button>` : ''}</div></td></tr>`).join('')}</tbody></table></div></div> <div><label class="block text-xs text-gray-500 mb-1">To</label><input type="date" id="log-filter-end" class="p-1.5 border rounded text-sm"></div>
<button id="log-filter-clear" class="px-3 py-1.5 text-sm bg-gray-100 rounded hover:bg-gray-200 self-end">Clear</button>
</div>
<div id="admin-logs-content" class="space-y-8"></div>
</div> </div>
<div id="tab-content-users" class="space-y-8 hidden"> <div id="tab-content-users" class="space-y-8 hidden">
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<form id="create-user-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Create User</h4><input type="text" id="new-username" placeholder="Username" class="w-full p-2 border rounded" required><input type="password" id="new-password" placeholder="Password" class="w-full p-2 border rounded" required><select id="new-user-role" class="w-full p-2 border rounded"><option value="employee">Employee</option><option value="admin">Admin</option></select><button type="submit" class="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700">Create User</button></form> <form id="create-user-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Create User</h4><input type="text" id="new-username" placeholder="Username" class="w-full p-2 border rounded" required><input type="password" id="new-password" placeholder="Password" class="w-full p-2 border rounded" required><select id="new-user-role" class="w-full p-2 border rounded"><option value="employee">Employee</option><option value="admin">Admin</option></select><button type="submit" class="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700">Create User</button></form>
<form id="add-punch-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Add Manual Entry</h4><select id="add-punch-user" class="w-full p-2 border rounded" required>${allUsers.map(u => `<option value="${u.id}" data-username="${u.username}">${u.username}</option>`).join('')}</select><label class="text-sm">In (Required):</label><input type="datetime-local" id="add-punch-in" class="w-full p-2 border rounded" required><label class="text-sm">Out (Optional):</label><input type="datetime-local" id="add-punch-out" class="w-full p-2 border rounded"><button type="submit" class="w-full bg-purple-600 text-white p-2 rounded hover:bg-purple-700">Add Entry</button></form> <form id="add-punch-form" class="space-y-3 bg-gray-50 p-4 rounded-lg"><h4 class="font-semibold">Add Manual Entry</h4><select id="add-punch-user" class="w-full p-2 border rounded" required>${allUsers.map(u => `<option value="${u.id}" data-username="${esc(u.username)}">${esc(u.username)}</option>`).join('')}</select><label class="text-sm">In (Required):</label><input type="datetime-local" id="add-punch-in" class="w-full p-2 border rounded" required><label class="text-sm">Out (Optional):</label><input type="datetime-local" id="add-punch-out" class="w-full p-2 border rounded"><button type="submit" class="w-full bg-purple-600 text-white p-2 rounded hover:bg-purple-700">Add Entry</button></form>
</div> </div>
<div><h4 class="font-semibold mb-2">Manage Users</h4><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Username</th><th class="p-2">Role</th><th class="p-2">Actions</th></tr></thead><tbody>${allUsers.map(u => `<tr class="border-t"><td class="p-2 font-medium">${u.username}</td><td class="p-2 capitalize">${u.role}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn font-medium text-blue-600 hover:underline" data-username="${u.username}">Reset PW</button><button class="change-role-btn font-medium text-purple-600 hover:underline" data-username="${u.username}" data-role="${u.role}">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>${u.username !== user.username ? `<button class="delete-user-btn font-medium text-red-600 hover:underline" data-username="${u.username}">Delete</button>` : ''}`}</div></td></tr>`).join('')}</tbody></table></div></div> <div><h4 class="font-semibold mb-2">Manage Users</h4><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Username</th><th class="p-2">Role</th><th class="p-2">Actions</th></tr></thead><tbody>${allUsers.map(u => { const un = esc(u.username); const ur = esc(u.role); return `<tr class="border-t"><td class="p-2 font-medium">${un}</td><td class="p-2 capitalize">${ur}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2">${u.isPrimary ? `<span class="text-sm text-gray-500">Primary Admin</span>` : `<button class="reset-pw-btn font-medium text-blue-600 hover:underline" data-username="${un}">Reset PW</button><button class="change-role-btn font-medium text-purple-600 hover:underline" data-username="${un}" data-role="${ur}">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>${u.username !== user.username ? `<button class="delete-user-btn font-medium text-red-600 hover:underline" data-username="${un}">Delete</button>` : ''}`}</div></td></tr>`; }).join('')}</tbody></table></div></div>
</div> </div>
<div id="tab-content-calendar" class="space-y-8 hidden"> <div id="tab-content-calendar" class="space-y-8 hidden">
</div> </div>
@ -310,6 +326,7 @@ ${e.status === 'out' ? `<button class="archive-log-btn font-medium text-amber-60
} }
attachAdminDashboardListeners(); attachAdminDashboardListeners();
renderAdminLogsContent();
punchedInEntries.forEach(entry => { punchedInEntries.forEach(entry => {
const durationCell = document.getElementById(`admin-duration-${entry.id}`); const durationCell = document.getElementById(`admin-duration-${entry.id}`);
if (durationCell) { if (durationCell) {
@ -329,11 +346,8 @@ export async function renderCalendarView() {
const res = await apiCall('/admin/calendar-url'); const res = await apiCall('/admin/calendar-url');
if (res.success && res.data.url) { if (res.success && res.data.url) {
calendarContainer.innerHTML = ` calendarContainer.innerHTML = '<div class="-mx-6 -my-6"><iframe id="admin-cal-frame" style="width: 100%; height: 80vh; border: none;"></iframe></div>';
<div class="-mx-6 -my-6"> document.getElementById('admin-cal-frame').src = res.data.url;
<iframe src="${res.data.url}" style="width: 100%; height: 80vh; border: none;"></iframe>
</div>
`;
} else { } else {
calendarContainer.innerHTML = ` calendarContainer.innerHTML = `
<div class="text-center p-8"> <div class="text-center p-8">
@ -349,7 +363,7 @@ export function renderArchiveView() {
if (!res.success) return; if (!res.success) return;
showView('archive'); showView('archive');
mainViews.archive.innerHTML = ` mainViews.archive.innerHTML = `
<div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Archived Logs</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hrs)</th><th class="p-2">Archived On</th></tr></thead><tbody>${res.data.map(e => `<tr class="border-t"><td class="p-2">${e.username||'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2">${utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td><td class="p-2">${utils.formatDateTime(e.archived_at)}</td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No archived entries found.</td></tr>'}</tbody></table></div></div>`; <div class="max-w-6xl mx-auto bg-white rounded-xl shadow-md p-6"><div class="flex justify-between items-center mb-4"><h2 class="text-2xl font-bold">Archived Logs</h2><button id="back-to-dash-btn" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Back to Dashboard</button></div><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration (Hrs)</th><th class="p-2">Archived On</th></tr></thead><tbody>${res.data.map(e => `<tr class="border-t"><td class="p-2">${esc(e.username) || 'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2">${utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td><td class="p-2">${utils.formatDateTime(e.archived_at)}</td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No archived entries found.</td></tr>'}</tbody></table></div></div>`;
document.getElementById('back-to-dash-btn').addEventListener('click', renderAdminDashboard); document.getElementById('back-to-dash-btn').addEventListener('click', renderAdminDashboard);
}); });
} }
@ -375,10 +389,10 @@ export function renderTimeOffHistoryView() {
<tbody> <tbody>
${res.data.map(r => ` ${res.data.map(r => `
<tr class="border-t"> <tr class="border-t">
<td class="p-2">${r.username}</td> <td class="p-2">${esc(r.username)}</td>
<td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td> <td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td>
<td class="p-2">${r.reason||''}</td> <td class="p-2">${esc(r.reason)}</td>
<td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${r.status}</td> <td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${esc(r.status)}</td>
<td class="p-2"> <td class="p-2">
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<button class="set-pending-btn font-medium text-blue-600 hover:underline" data-id="${r.id}">Set to Pending</button> <button class="set-pending-btn font-medium text-blue-600 hover:underline" data-id="${r.id}">Set to Pending</button>
@ -408,7 +422,7 @@ export function renderEditModal(id, submitHandler) {
const entry = allTimeEntries.find(e => e.id == id); const entry = allTimeEntries.find(e => e.id == id);
if (!entry) { utils.showMessage('Could not find entry to edit.', 'error'); return; } if (!entry) { utils.showMessage('Could not find entry to edit.', 'error'); return; }
const formHTML = `<input type="hidden" id="edit-id" value="${entry.id}"><div><label class="font-medium">Punch In</label><input type="datetime-local" id="edit-in" value="${utils.toLocalISO(entry.punch_in_time)}" class="w-full p-2 border rounded" required></div><div><label class="font-medium">Punch Out</label><input type="datetime-local" id="edit-out" value="${utils.toLocalISO(entry.punch_out_time)}" class="w-full p-2 border rounded"></div>`; const formHTML = `<input type="hidden" id="edit-id" value="${entry.id}"><div><label class="font-medium">Punch In</label><input type="datetime-local" id="edit-in" value="${utils.toLocalISO(entry.punch_in_time)}" class="w-full p-2 border rounded" required></div><div><label class="font-medium">Punch Out</label><input type="datetime-local" id="edit-out" value="${utils.toLocalISO(entry.punch_out_time)}" class="w-full p-2 border rounded"></div>`;
renderModal(`Edit Entry for ${entry.username}`, formHTML, submitHandler); renderModal(`Edit Entry for ${esc(entry.username)}`, formHTML, submitHandler);
} }
export function renderChangePasswordModal(submitHandler) { export function renderChangePasswordModal(submitHandler) {
@ -417,12 +431,13 @@ export function renderChangePasswordModal(submitHandler) {
} }
export function renderResetPasswordModal(username, submitHandler) { export function renderResetPasswordModal(username, submitHandler) {
const formHTML = `<input type="hidden" id="reset-username" value="${username}"><input type="password" id="reset-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`; const safeUsername = esc(username);
renderModal(`Reset Password for ${username}`, formHTML, submitHandler); const formHTML = `<input type="hidden" id="reset-username" value="${safeUsername}"><input type="password" id="reset-new-pw" placeholder="New Password" class="w-full p-2 border rounded" required>`;
renderModal(`Reset Password for ${safeUsername}`, formHTML, submitHandler);
} }
export function renderRequestHistoryModal(requests) { export function renderRequestHistoryModal(requests) {
const modalBody = `<div class="max-h-[70vh] overflow-y-auto"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50 sticky top-0"><tr><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${requests.map(r => `<tr class="border-t"><td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td><td class="p-2">${r.reason || ''}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${r.status}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No history found.</td></tr>'}</tbody></table></div>`; const modalBody = `<div class="max-h-[70vh] overflow-y-auto"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50 sticky top-0"><tr><th class="p-2">Dates</th><th class="p-2">Reason</th><th class="p-2">Status</th></tr></thead><tbody>${requests.map(r => `<tr class="border-t"><td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td><td class="p-2">${esc(r.reason)}</td><td class="p-2 font-medium capitalize text-${r.status === 'approved' ? 'green' : 'red'}-600">${esc(r.status)}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No history found.</td></tr>'}</tbody></table></div>`;
modalContainer.innerHTML = `<div class="modal-overlay"><div class="modal-content"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold">Full Time Off History</h3><button class="cancel-modal-btn font-bold text-2xl">&times;</button></div>${modalBody}</div></div>`; modalContainer.innerHTML = `<div class="modal-overlay"><div class="modal-content"><div class="flex justify-between items-center mb-4"><h3 class="text-xl font-bold">Full Time Off History</h3><button class="cancel-modal-btn font-bold text-2xl">&times;</button></div>${modalBody}</div></div>`;
document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = ''); document.querySelector('.cancel-modal-btn').addEventListener('click', () => modalContainer.innerHTML = '');
document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; }); document.querySelector('.modal-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) modalContainer.innerHTML = ''; });
@ -444,7 +459,7 @@ export function renderEditTimeOffModal(request, submitHandler) {
</div> </div>
<div> <div>
<label class="block text-sm font-medium">Reason (optional)</label> <label class="block text-sm font-medium">Reason (optional)</label>
<input type="text" id="edit-reason" placeholder="e.g., Vacation" class="w-full p-2 border rounded" value="${request.reason || ''}"> <input type="text" id="edit-reason" placeholder="e.g., Vacation" class="w-full p-2 border rounded" value="${esc(request.reason)}">
</div> </div>
`; `;
renderModal('Edit Time Off Request', formHTML, submitHandler); renderModal('Edit Time Off Request', formHTML, submitHandler);
@ -463,13 +478,13 @@ export async function handleViewNotesClick() {
if (res.success) { if (res.success) {
if (res.data.length > 0) { if (res.data.length > 0) {
container.innerHTML = ` container.innerHTML = `
<h4 class="font-semibold mb-2 text-gray-600">Showing Notes for ${document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text}</h4> <h4 class="font-semibold mb-2 text-gray-600">Showing Notes for ${esc(document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text)}</h4>
<ul class="space-y-3 max-h-70 overflow-y-auto border rounded-lg p-2 bg-gray-50"> <ul class="space-y-3 max-h-70 overflow-y-auto border rounded-lg p-2 bg-gray-50">
${res.data.map(note => ` ${res.data.map(note => `
<li class="bg-white p-3 rounded-lg shadow-sm flex justify-between items-start"> <li class="bg-white p-3 rounded-lg shadow-sm flex justify-between items-start">
<div> <div>
<p class="text-gray-800 break-words">"${note.note_text}"</p> <p class="text-gray-800 break-words">"${esc(note.note_text)}"</p>
<p class="text-xs text-gray-500 mt-2">- ${note.admin_username} on ${utils.formatDate(note.created_at)}</p> <p class="text-xs text-gray-500 mt-2">- ${esc(note.admin_username)} on ${utils.formatDate(note.created_at)}</p>
</div> </div>
<button class="delete-note-btn text-red-500 hover:text-red-700 flex-shrink-0 ml-4" data-note-id="${note.id}">&times;</button> <button class="delete-note-btn text-red-500 hover:text-red-700 flex-shrink-0 ml-4" data-note-id="${note.id}">&times;</button>
</li> </li>
@ -483,6 +498,66 @@ export async function handleViewNotesClick() {
} }
} }
export function renderAdminLogsContent(startDate, endDate) {
const container = document.getElementById('admin-logs-content');
if (!container) return;
let filtered = allTimeEntries;
if (startDate) {
const start = new Date(startDate + 'T00:00:00');
filtered = filtered.filter(e => new Date(e.punch_in_time) >= start);
}
if (endDate) {
const end = new Date(endDate + 'T23:59:59.999');
filtered = filtered.filter(e => new Date(e.punch_in_time) <= end);
}
const employeeTotals = filtered.reduce((acc, entry) => {
const dur = entry.punch_out_time
? (new Date(entry.punch_out_time) - new Date(entry.punch_in_time))
: (Date.now() - new Date(entry.punch_in_time));
acc[entry.username] = (acc[entry.username] || 0) + dur;
return acc;
}, {});
container.innerHTML = `
<div><h3 class="text-xl font-bold text-gray-700 mb-2">Hours by Employee</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">Total Hours</th></tr></thead><tbody>${Object.entries(employeeTotals).map(([username, totalMs]) => `<tr class="border-t"><td class="p-2 font-medium">${esc(username)}</td><td class="p-2">${utils.formatDecimal(totalMs)}</td></tr>`).join('') || '<tr><td colspan="2" class="text-center p-4">No data.</td></tr>'}</tbody></table></div></div>
<div><h3 class="text-xl font-bold text-gray-700 mb-4">Detailed Logs</h3><div class="overflow-x-auto border rounded-lg"><table class="min-w-full text-sm text-left"><thead class="bg-gray-50"><tr><th class="p-2">Employee</th><th class="p-2">In</th><th class="p-2">Out</th><th class="p-2">Duration</th><th class="p-2">Actions</th></tr></thead><tbody>${filtered.map(e => `<tr class="border-t"><td class="p-2">${esc(e.username) || 'N/A'}</td><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="admin-duration-${e.id}">${e.punch_out_time ? utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time)) + ' hrs' : '...'}</td><td class="p-2"><div class="flex flex-col sm:flex-row items-start sm:items-center gap-2"><button class="edit-btn font-medium text-blue-600 hover:underline" data-id="${e.id}">Edit</button><button class="delete-btn font-medium text-red-600 hover:underline" data-id="${e.id}">Delete</button>${e.status === 'out' ? `<button class="archive-log-btn font-medium text-amber-600 hover:underline" data-id="${e.id}">Archive</button>` : ''}</div></td></tr>`).join('') || '<tr><td colspan="5" class="text-center p-4">No entries in selected range.</td></tr>'}</tbody></table></div></div>
`;
}
export function renderEmployeeLogsSection(startDate, endDate) {
const tbody = document.getElementById('employee-log-tbody');
const filteredHours = document.getElementById('employee-log-filtered-hours');
if (!tbody) return;
let filtered = allEmployeeEntries;
if (startDate) {
const start = new Date(startDate + 'T00:00:00');
filtered = filtered.filter(e => new Date(e.punch_in_time) >= start);
}
if (endDate) {
const end = new Date(endDate + 'T23:59:59.999');
filtered = filtered.filter(e => new Date(e.punch_in_time) <= end);
}
tbody.innerHTML = filtered.map(e => `<tr class="border-t"><td class="p-2">${utils.formatDateTime(e.punch_in_time)}</td><td class="p-2">${utils.formatDateTime(e.punch_out_time)}</td><td class="p-2" id="duration-${e.id}">${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}</td></tr>`).join('') || '<tr><td colspan="3" class="text-center p-4">No entries in selected range.</td></tr>';
if (filteredHours) {
if (startDate || endDate) {
const total = filtered.reduce((acc, e) => {
return e.status === 'out' && e.punch_out_time
? acc + (new Date(e.punch_out_time) - new Date(e.punch_in_time))
: acc;
}, 0);
filteredHours.textContent = `${utils.formatDecimal(total)} hrs in range`;
filteredHours.classList.remove('hidden');
} else {
filteredHours.classList.add('hidden');
}
}
}
export function updatePendingRequestsList(requests) { export function updatePendingRequestsList(requests) {
const tableBody = document.querySelector('#tab-content-overview table tbody'); const tableBody = document.querySelector('#tab-content-overview table tbody');
if (!tableBody) return; if (!tableBody) return;
@ -494,9 +569,9 @@ export function updatePendingRequestsList(requests) {
tableBody.innerHTML = requests.map(r => ` tableBody.innerHTML = requests.map(r => `
<tr class="border-t"> <tr class="border-t">
<td class="p-2">${r.username}</td> <td class="p-2">${esc(r.username)}</td>
<td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td> <td class="p-2 whitespace-nowrap">${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}</td>
<td class="p-2">${r.reason || ''}</td> <td class="p-2">${esc(r.reason)}</td>
<td class="p-2"> <td class="p-2">
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button> <button class="approve-request-btn font-medium text-green-600 hover:underline" data-id="${r.id}">Approve</button>

View File

@ -46,6 +46,11 @@ export const toLocalISO = (d) => {
return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
}; };
export const escapeHtml = (s) => {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;');
};
export const formatDuration = (ms) => { export const formatDuration = (ms) => {
if (!ms || ms < 0) return '00:00:00'; if (!ms || ms < 0) return '00:00:00';
const totalSeconds = Math.floor(ms / 1000); const totalSeconds = Math.floor(ms / 1000);

View File

@ -12,7 +12,11 @@ const ics = require('ics');
const webpush = require('web-push'); const webpush = require('web-push');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key'; if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set. Refusing to start.');
process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword';
const publicVapidKey = process.env.PUBLIC_VAPID_KEY; const publicVapidKey = process.env.PUBLIC_VAPID_KEY;
@ -267,15 +271,23 @@ app.post('/api/subscribe', authenticateToken, async (req, res) => { try {
app.post('/api/punch', authenticateToken, async (req, res) => { app.post('/api/punch', authenticateToken, async (req, res) => {
try { try {
const { id, username } = req.user; const { id, username } = req.user;
await db.run('BEGIN IMMEDIATE');
try {
const openPunch = await db.get(`SELECT * FROM time_entries WHERE user_id = ? AND status = 'in' ORDER BY punch_in_time DESC LIMIT 1`, [id]); const openPunch = await db.get(`SELECT * FROM time_entries WHERE user_id = ? AND status = 'in' ORDER BY punch_in_time DESC LIMIT 1`, [id]);
const now = new Date().toISOString(); const now = new Date().toISOString();
if (openPunch) { if (openPunch) {
await db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [now, openPunch.id]); await db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [now, openPunch.id]);
await db.run('COMMIT');
res.json({ message: "Punched out." }); res.json({ message: "Punched out." });
} else { } else {
await db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, now]); await db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, now]);
await db.run('COMMIT');
res.json({ message: "Punched in." }); res.json({ message: "Punched in." });
} }
} catch (innerErr) {
await db.run('ROLLBACK');
throw innerErr;
}
} catch (err) { } catch (err) {
res.status(500).json({ message: "Server error during punch." }); res.status(500).json({ message: "Server error during punch." });
} }
@ -644,6 +656,9 @@ app.post('/api/admin/notify', authenticateToken, requireRole('admin'), async (re
app.post('/api/admin/update-time-off-status', authenticateToken, requireRole('admin'), async (req, res) => { app.post('/api/admin/update-time-off-status', authenticateToken, requireRole('admin'), async (req, res) => {
try { try {
const { requestId, status } = req.body; const { requestId, status } = req.body;
if (!['approved', 'denied', 'pending'].includes(status)) {
return res.status(400).json({ message: "Invalid status value." });
}
await db.run('UPDATE time_off_requests SET status = ? WHERE id = ?', [status, requestId]); await db.run('UPDATE time_off_requests SET status = ? WHERE id = ?', [status, requestId]);
// Get the details of the request we're updating // Get the details of the request we're updating