diff --git a/public/js/main.js b/public/js/main.js index f930367..898f236 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -16,7 +16,9 @@ import { renderTimeOffHistoryView, updatePendingRequestsList, renderEditTimeOffModal, - renderCalendarView + renderCalendarView, + renderAdminLogsContent, + renderEmployeeLogsSection } from './ui.js'; // --- STATE MANAGEMENT --- @@ -24,6 +26,7 @@ let user = null; let authToken = null; let lastAdminTab = 'overview'; let lastEmployeeTab = 'dashboard'; +let punchInFlight = false; export { lastAdminTab }; // --- NOTIFICATION LOGIC --- @@ -129,7 +132,12 @@ function handleSignOut(message) { } 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) { @@ -440,7 +448,7 @@ export function attachEmployeeDashboardListeners() { const dashboard = document.getElementById('employee-dashboard'); if (!dashboard) return; - dashboard.addEventListener('click', async (e) => { + dashboard.onclick = async (e) => { const target = e.target; if (target.id === 'punch-btn') handlePunch(); if (target.id === 'change-password-btn') renderChangePasswordModal(handleChangePassword); @@ -465,13 +473,27 @@ export function attachEmployeeDashboardListeners() { } } } - }); + }; const timeOffForm = document.getElementById('time-off-form'); if (timeOffForm) { 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(); if (lastEmployeeTab !== 'dashboard') { const tabsContainer = document.getElementById('employee-tabs-nav'); @@ -484,13 +506,29 @@ export function attachEmployeeDashboardListeners() { } 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('add-punch-form')?.addEventListener('submit', handleAddPunch); document.getElementById('add-note-form')?.addEventListener('submit', handleAddNote); document.getElementById('calendar-settings-form')?.addEventListener('submit', handleCalendarSettingsSubmit); document.getElementById('calendar-clear-btn')?.addEventListener('click', handleCalendarSettingsClear); 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(); const adminContent = document.getElementById('admin-tabs-content'); if (adminContent) { @@ -503,7 +541,7 @@ export function attachAdminDashboardListeners() { export function attachTimeOffHistoryListeners() { const historyView = document.getElementById('admin-time-off-history-view'); 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.')); + +// 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 = '

CHEAT CODE ACTIVATED

You\'ve earned an extra 15 minute break.

(Not legally binding)

'; + document.body.appendChild(overlay); + overlay.addEventListener('click', () => overlay.remove()); + setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 5000); +}); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && localStorage.getItem('user')) { initializeApp(); diff --git a/public/js/ui.js b/public/js/ui.js index bf9aad3..3f415f8 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -28,6 +28,10 @@ let employeeTimerInterval = null; let adminTimerIntervals = []; let allTimeEntries = []; let allUsers = []; +let allEmployeeEntries = []; + +// Convenience alias +const esc = utils.escapeHtml; // --- VIEW MANAGEMENT --- export function showView(viewName) { @@ -92,6 +96,7 @@ export async function renderEmployeeDashboard() { 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; }, 0); + allEmployeeEntries = entries; mainViews.employee.innerHTML = `
@@ -115,7 +120,7 @@ export async function renderEmployeeDashboard() {

Notes from Admin

- +

My Account

@@ -146,8 +151,8 @@ export async function renderEmployeeDashboard() { ${requests.map(r => ` ${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)} - ${r.reason || ''} - ${r.status} + ${esc(r.reason)} + ${esc(r.status)} ${r.status === 'pending' ? `
@@ -163,8 +168,16 @@ export async function renderEmployeeDashboard() {
-

My Time Log

-
${entries.map(e => ``).join('') || ''}
InOutDuration (Hours)
${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}
No entries.
+
+

My Time Log

+ +
+
+
+
+ +
+
${entries.map(e => ``).join('') || ''}
InOutDuration (Hours)
${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}
No entries.
-

If the calendar doesn't display, use the “Open in new tab” link.

+

If the calendar doesn't display, use the "Open in new tab" link.

`; - + attachEmployeeDashboardListeners(); if (punchedIn) { @@ -224,7 +237,7 @@ export async function renderAdminDashboard() {
-

Currently Punched In

    ${punchedInEntries.map(e => `
  • ${e.username}
    Since: ${utils.formatDateTime(e.punch_in_time)}
  • `).join('') || '
  • None
  • '}
+

Currently Punched In

    ${punchedInEntries.map(e => { const un = esc(e.username); return `
  • ${un}
    Since: ${utils.formatDateTime(e.punch_in_time)}
  • `; }).join('') || '
  • None
  • '}

Pending Time Off Requests

@@ -233,9 +246,9 @@ export async function renderAdminDashboard() { ${pendingRequests.map(r => ` - ${r.username} + ${esc(r.username)} ${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)} - ${r.reason||''} + ${esc(r.reason)}
@@ -252,28 +265,31 @@ export async function renderAdminDashboard() {

Employee Notes

- +
-
${res.data.map(e => ``).join('') || ''}
EmployeeInOutDuration (Hrs)Archived On
${e.username||'N/A'}${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}${utils.formatDateTime(e.archived_at)}
No archived entries found.
`; +

Archived Logs

${res.data.map(e => ``).join('') || ''}
EmployeeInOutDuration (Hrs)Archived On
${esc(e.username) || 'N/A'}${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}${utils.formatDateTime(e.archived_at)}
No archived entries found.
`; document.getElementById('back-to-dash-btn').addEventListener('click', renderAdminDashboard); }); } @@ -375,10 +389,10 @@ export function renderTimeOffHistoryView() { ${res.data.map(r => ` - ${r.username} + ${esc(r.username)} ${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)} - ${r.reason||''} - ${r.status} + ${esc(r.reason)} + ${esc(r.status)}
@@ -408,7 +422,7 @@ export function renderEditModal(id, submitHandler) { const entry = allTimeEntries.find(e => e.id == id); if (!entry) { utils.showMessage('Could not find entry to edit.', 'error'); return; } const formHTML = `
`; - renderModal(`Edit Entry for ${entry.username}`, formHTML, submitHandler); + renderModal(`Edit Entry for ${esc(entry.username)}`, formHTML, submitHandler); } export function renderChangePasswordModal(submitHandler) { @@ -417,12 +431,13 @@ export function renderChangePasswordModal(submitHandler) { } export function renderResetPasswordModal(username, submitHandler) { - const formHTML = ``; - renderModal(`Reset Password for ${username}`, formHTML, submitHandler); + const safeUsername = esc(username); + const formHTML = ``; + renderModal(`Reset Password for ${safeUsername}`, formHTML, submitHandler); } export function renderRequestHistoryModal(requests) { - const modalBody = `
${requests.map(r => ``).join('') || ''}
DatesReasonStatus
${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}${r.reason || ''}${r.status}
No history found.
`; + const modalBody = `
${requests.map(r => ``).join('') || ''}
DatesReasonStatus
${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)}${esc(r.reason)}${esc(r.status)}
No history found.
`; 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 = ''; }); @@ -444,7 +459,7 @@ export function renderEditTimeOffModal(request, submitHandler) {
- +
`; renderModal('Edit Time Off Request', formHTML, submitHandler); @@ -457,19 +472,19 @@ export async function handleViewNotesClick() { if (!userId) { return utils.showMessage('Please select an employee to view their notes.', 'error'); } - + container.innerHTML = 'Loading notes...'; const res = await apiCall(`/admin/notes/${userId}`); if (res.success) { if (res.data.length > 0) { container.innerHTML = ` -

Showing Notes for ${document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text}

+

Showing Notes for ${esc(document.getElementById('note-user-select').options[document.getElementById('note-user-select').selectedIndex].text)}

    ${res.data.map(note => `
  • -

    "${note.note_text}"

    -

    - ${note.admin_username} on ${utils.formatDate(note.created_at)}

    +

    "${esc(note.note_text)}"

    +

    - ${esc(note.admin_username)} on ${utils.formatDate(note.created_at)}

  • @@ -481,7 +496,67 @@ export async function handleViewNotesClick() { } else { container.innerHTML = '

    Could not load notes.

    '; } -} +} + +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 = ` +

    Hours by Employee

    ${Object.entries(employeeTotals).map(([username, totalMs]) => ``).join('') || ''}
    EmployeeTotal Hours
    ${esc(username)}${utils.formatDecimal(totalMs)}
    No data.
    +

    Detailed Logs

    ${filtered.map(e => ``).join('') || ''}
    EmployeeInOutDurationActions
    ${esc(e.username) || 'N/A'}${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${e.punch_out_time ? utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time)) + ' hrs' : '...'}
    ${e.status === 'out' ? `` : ''}
    No entries in selected range.
    + `; +} + +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 => `${utils.formatDateTime(e.punch_in_time)}${utils.formatDateTime(e.punch_out_time)}${e.status === 'in' ? 'Running...' : utils.formatDecimal(new Date(e.punch_out_time) - new Date(e.punch_in_time))}`).join('') || 'No entries in selected range.'; + + 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) { const tableBody = document.querySelector('#tab-content-overview table tbody'); @@ -494,9 +569,9 @@ export function updatePendingRequestsList(requests) { tableBody.innerHTML = requests.map(r => ` - ${r.username} + ${esc(r.username)} ${utils.formatDate(r.start_date)} - ${utils.formatDate(r.end_date)} - ${r.reason || ''} + ${esc(r.reason)}
    diff --git a/public/js/utils.js b/public/js/utils.js index 19634d2..9c516a5 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -46,6 +46,11 @@ export const toLocalISO = (d) => { 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, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +}; + export const formatDuration = (ms) => { if (!ms || ms < 0) return '00:00:00'; const totalSeconds = Math.floor(ms / 1000); diff --git a/server.js b/server.js index fd3f58e..1bb08b2 100644 --- a/server.js +++ b/server.js @@ -12,7 +12,11 @@ const ics = require('ics'); const webpush = require('web-push'); const bodyParser = require('body-parser'); 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_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword'; const publicVapidKey = process.env.PUBLIC_VAPID_KEY; @@ -267,14 +271,22 @@ app.post('/api/subscribe', authenticateToken, async (req, res) => { try { app.post('/api/punch', authenticateToken, async (req, res) => { try { const { id, username } = req.user; - 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(); - if (openPunch) { - await db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [now, openPunch.id]); - res.json({ message: "Punched out." }); - } else { - await db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, now]); - res.json({ message: "Punched in." }); + 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 now = new Date().toISOString(); + if (openPunch) { + 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." }); + } else { + 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." }); + } + } catch (innerErr) { + await db.run('ROLLBACK'); + throw innerErr; } } catch (err) { 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) => { try { 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]); // Get the details of the request we're updating