/** * SecureAttend AI - Frontend Orchestration Engine * Coordinates tabs, API requests, multi-angle enrollment, dynamic SVG charts, and real-time SSE triggers */ class SecureAttendApp { constructor() { this.activeTab = 'dashboard'; this.currentEnrollId = null; this.currentEnrollName = null; this.capturedAngles = { center: false, left: false, right: false, up: false, down: false }; this.eventSource = null; this.sessionLogsCount = 0; this.cameraActive = true; this.listenersSetup = false; // Client-side camera state variables this.videoEl = null; this.localStream = null; this.scanIntervalId = null; this.latestScanResults = null; this.scanlineY = 15; this.scanlineDirection = 3; // Auth & Session state this.token = localStorage.getItem('secureattend_token') || null; this.role = localStorage.getItem('secureattend_role') || null; this.username = localStorage.getItem('secureattend_username') || null; // Bind methods to keep correct 'this' context this.init = this.init.bind(this); this.switchTab = this.switchTab.bind(this); this.loadStats = this.loadStats.bind(this); this.loadEmployees = this.loadEmployees.bind(this); this.loadLogs = this.loadLogs.bind(this); this.loadAnalytics = this.loadAnalytics.bind(this); this.setupSSE = this.setupSSE.bind(this); this.triggerToast = this.triggerToast.bind(this); this.checkAuth = this.checkAuth.bind(this); this.handleLogin = this.handleLogin.bind(this); this.handleLogout = this.handleLogout.bind(this); } getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.token}` }; } checkAuth() { const overlay = document.getElementById('login-overlay'); if (!this.token || !this.role) { overlay.style.display = 'flex'; document.body.classList.remove('kiosk-mode'); return false; } overlay.style.display = 'none'; // Apply Role UI customization const badge = document.getElementById('role-badge'); const badgeContainer = document.getElementById('role-badge-container'); const registerBtn = document.getElementById('btn-open-enrollment'); const saveCamBtn = document.getElementById('btn-save-camera'); if (this.role === 'admin') { document.body.classList.remove('kiosk-mode'); document.getElementById('btn-kiosk-escape').style.display = 'none'; if (badge) { badge.textContent = 'ADMIN TERMINAL NODE'; badge.style.borderColor = 'var(--neon-cyan)'; badge.style.color = 'var(--neon-cyan)'; badge.style.background = 'rgba(0, 255, 242, 0.1)'; } if (badgeContainer) badgeContainer.style.display = 'block'; if (registerBtn) registerBtn.style.display = 'inline-flex'; if (saveCamBtn) { saveCamBtn.disabled = false; saveCamBtn.style.opacity = '1'; saveCamBtn.textContent = 'Switch Active Camera Source'; } } else if (this.role === 'hr') { document.body.classList.remove('kiosk-mode'); document.getElementById('btn-kiosk-escape').style.display = 'none'; if (badge) { badge.textContent = 'HR OPERATOR SCOPE'; badge.style.borderColor = 'var(--neon-green)'; badge.style.color = 'var(--neon-green)'; badge.style.background = 'rgba(46, 204, 113, 0.1)'; } if (badgeContainer) badgeContainer.style.display = 'block'; if (registerBtn) registerBtn.style.display = 'none'; // Hide registration for HR if (saveCamBtn) { saveCamBtn.disabled = true; saveCamBtn.style.opacity = '0.5'; saveCamBtn.textContent = 'Hardware Locked for HR'; } } else if (this.role === 'kiosk') { document.body.classList.add('kiosk-mode'); document.getElementById('btn-kiosk-escape').style.display = 'inline-flex'; if (badgeContainer) badgeContainer.style.display = 'none'; // Force direct lock inside webcam scan screen this.switchTab('scanner'); } return true; } async handleLogin() { const userEl = document.getElementById('login-username'); const passEl = document.getElementById('login-password'); const errorEl = document.getElementById('login-error-msg'); const username = userEl.value.trim(); const password = passEl.value.trim(); if (!username || !password) { errorEl.textContent = "Credential fields cannot be blank."; errorEl.style.display = 'block'; return; } try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Access handshake rejected."); localStorage.setItem('secureattend_token', data.token); localStorage.setItem('secureattend_role', data.role); localStorage.setItem('secureattend_username', data.username); this.token = data.token; this.role = data.role; this.username = data.username; errorEl.style.display = 'none'; userEl.value = ''; passEl.value = ''; // Re-init application parameters this.init(); this.triggerToast("Session Authorized", `Welcome back, security role: ${this.role.toUpperCase()}`, "check_in"); } catch (err) { errorEl.textContent = err.message; errorEl.style.display = 'block'; } } handleLogout() { // If admin, send a debounced request to shut down camera hardware capture thread if (this.role === 'admin' && this.token) { fetch('/api/camera/toggle', { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ active: false }) }).catch(() => {}); } this.stopClientWebcam(); localStorage.removeItem('secureattend_token'); localStorage.removeItem('secureattend_role'); localStorage.removeItem('secureattend_username'); this.token = null; this.role = null; this.username = null; if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.checkAuth(); } init() { this.videoEl = document.getElementById('client-webcam'); this.setupEventListeners(); this.startLiveClock(); if (!this.checkAuth()) { return; } this.loadStats(); this.loadLogs(); this.loadEmployees(); this.loadAnalytics(); this.setupSSE(); console.log("[+] SecureAttend Client initialized successfully!"); } startLiveClock() { const clockEl = document.getElementById('live-clock'); const updateClock = () => { const now = new Date(); let hours = now.getHours(); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); const ampm = hours >= 12 ? 'PM' : 'AM'; hours = hours % 12; hours = hours ? hours : 12; clockEl.textContent = `${hours}:${minutes}:${seconds} ${ampm}`; }; setInterval(updateClock, 1000); updateClock(); } setupEventListeners() { if (this.listenersSetup) return; // 1. Sidebar Tab Navigation document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', (e) => { if (this.role === 'kiosk') return; // navigation locked in Kiosk mode const tab = e.currentTarget.getAttribute('data-tab'); this.switchTab(tab); }); }); // 2. Open Register Modal const btnOpenEnroll = document.getElementById('btn-open-enrollment'); if (btnOpenEnroll) { btnOpenEnroll.addEventListener('click', () => this.openEnrollmentWizard()); } // 3. Wizard Navigation Events document.getElementById('btn-cancel-wizard').addEventListener('click', () => this.closeEnrollmentWizard()); document.getElementById('btn-step1-next').addEventListener('click', () => this.submitWizardStep1()); document.getElementById('btn-step2-back').addEventListener('click', () => this.backToWizardStep1()); document.getElementById('btn-step2-next').addEventListener('click', () => this.completeWizardEnrollment()); document.getElementById('btn-wizard-finish').addEventListener('click', () => this.closeEnrollmentWizard()); // 4. Directory Local Filter Search document.getElementById('directory-search').addEventListener('input', (e) => { this.filterDirectoryTable(e.target.value); }); // 5. Admin Settings - Save Camera Select document.getElementById('btn-save-camera').addEventListener('click', () => this.saveCameraSettings()); // 6. Camera On/Off Toggle Button const btnToggleCam = document.getElementById('btn-toggle-camera'); if (btnToggleCam) { btnToggleCam.addEventListener('click', () => this.toggleCameraFeed()); } // 7. Auth Event Listeners document.getElementById('btn-login-submit').addEventListener('click', this.handleLogin); document.getElementById('nav-logout').addEventListener('click', this.handleLogout); // Kiosk Mode Exit Handler document.getElementById('btn-kiosk-escape').addEventListener('click', () => { const pass = prompt("Enter ADMIN secure keypass to escape Kiosk Mode:"); if (pass === 'admin123') { this.handleLogout(); } else if (pass !== null) { alert("Unauthorized security keypass! Access denied."); } }); this.listenersSetup = true; } switchTab(tabId) { if (this.activeTab === tabId) return; // 1. Swap navigation classes document.querySelectorAll('.nav-item').forEach(item => { if (item.getAttribute('data-tab') === tabId) { item.classList.add('active'); } else { item.classList.remove('active'); } }); // 2. Swap active panel visibility document.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.remove('active'); }); const targetPanel = document.getElementById(`tab-${tabId}`); if (targetPanel) { targetPanel.classList.add('active'); } // 3. Update Title Header const titles = { dashboard: { title: "Dashboard Overview", subtitle: "Real-time attendance metrics & engine status." }, scanner: { title: "Facial Live Scanner", subtitle: "Stand in front of camera to auto log check-ins." }, directory: { title: "Employee Directory", subtitle: "Management portal for employee credentials." }, settings: { title: "Admin Portal Settings", subtitle: "Configure camera nodes, thresholds, and parameters." } }; if (titles[tabId]) { document.getElementById('view-title-text').textContent = titles[tabId].title; document.getElementById('view-subtitle-text').textContent = titles[tabId].subtitle; } this.activeTab = tabId; // Reload data if opening specific tabs if (tabId === 'dashboard') { this.loadStats(); this.loadLogs(); this.loadAnalytics(); } else if (tabId === 'directory') { this.loadEmployees(); } // Manage client-side webcam stream based on active tab if (tabId === 'scanner' && this.cameraActive && this.token) { this.startClientWebcam().then(success => { if (success) { this.startScanningLoop(); requestAnimationFrame(() => this.renderCanvasLoop()); } }); } else { // If we are not in enrollment wizard, stop the camera if (document.getElementById('wizard-modal').style.display !== 'flex') { this.stopClientWebcam(); } } } // REST API: Load Metrics async loadStats() { try { const response = await fetch('/api/attendance/stats', { headers: this.getHeaders() }); if (!response.ok) throw new Error("Failed to fetch dashboard statistics."); const stats = await response.json(); document.getElementById('stat-total').textContent = stats.total_employees; document.getElementById('stat-present').textContent = stats.present_count; document.getElementById('stat-out').textContent = stats.checked_out_count; document.getElementById('stat-absent').textContent = stats.absent_count; } catch (err) { console.error("[-] Error loading stats:", err); } } // REST API: Load Logs async loadLogs() { try { const response = await fetch('/api/attendance/logs?limit=25', { headers: this.getHeaders() }); if (!response.ok) throw new Error("Failed to fetch logs."); const logs = await response.json(); const listEl = document.getElementById('dashboard-activity-list'); listEl.innerHTML = ''; if (logs.length === 0) { listEl.innerHTML = `

No scans recorded yet today.

`; return; } logs.forEach(log => { const item = document.createElement('div'); item.className = `activity-item ${log.event_type}`; const logTime = new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const initial = log.name.charAt(0).toUpperCase(); item.innerHTML = `
${initial}
${log.name}

${log.role} • Score: ${parseInt(log.similarity_score * 100)}%

${log.event_type.replace('_', ' ')}
${logTime}
`; listEl.appendChild(item); }); } catch (err) { console.error("[-] Error loading logs:", err); } } // REST API: Load Analytics statistics & draw SVG Charts async loadAnalytics() { if (this.role === 'kiosk') return; try { const response = await fetch('/api/attendance/analytics', { headers: this.getHeaders() }); if (!response.ok) throw new Error("Failed to fetch analytics statistics."); const data = await response.json(); this.renderWeeklyChart(data.weekly); this.renderHourlyChart(data.hourly); } catch (err) { console.error("[-] Error loading analytics:", err); } } // Dynamic SVG Area Spline render renderWeeklyChart(data) { const svg = document.getElementById('weekly-trends-svg'); if (!svg) return; // Clear previous dynamic elements svg.querySelectorAll('.dynamic-chart-element').forEach(el => el.remove()); const svgWidth = 500; const svgHeight = 200; const paddingLeft = 40; const paddingRight = 15; const paddingTop = 15; const paddingBottom = 30; const chartW = svgWidth - paddingLeft - paddingRight; const chartH = svgHeight - paddingTop - paddingBottom; const maxCount = Math.max(...data.map(d => d.count), 0); const yMax = maxCount > 0 ? Math.ceil(maxCount / 2) * 2 : 4; // 1. Gridlines and Labels const gridDivs = 4; for (let i = 0; i <= gridDivs; i++) { const yVal = yMax * (i / gridDivs); const yPos = svgHeight - paddingBottom - (i / gridDivs) * chartH; const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', paddingLeft); line.setAttribute('y1', yPos); line.setAttribute('x2', svgWidth - paddingRight); line.setAttribute('y2', yPos); line.setAttribute('class', 'chart-grid-line dynamic-chart-element'); svg.appendChild(line); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', paddingLeft - 10); text.setAttribute('y', yPos + 4); text.setAttribute('text-anchor', 'end'); text.setAttribute('class', 'chart-axis-text dynamic-chart-element'); text.textContent = Math.round(yVal); svg.appendChild(text); } if (data.length === 0) return; // 2. Map point positions const points = data.map((d, i) => { const x = paddingLeft + i * (chartW / (data.length - 1)); const y = svgHeight - paddingBottom - (d.count / yMax) * chartH; return { x, y, label: d.label, date: d.date, count: d.count }; }); // 3. Create Spline Path (Cubic Bezier curve logic) let pathD = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i]; const p1 = points[i+1]; const cpX = (p0.x + p1.x) / 2; pathD += ` C ${cpX} ${p0.y}, ${cpX} ${p1.y}, ${p1.x} ${p1.y}`; } // 4. Fill Spline Gradient Area underneath path const areaD = pathD + ` L ${points[points.length-1].x} ${svgHeight - paddingBottom} L ${points[0].x} ${svgHeight - paddingBottom} Z`; const areaPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); areaPath.setAttribute('d', areaD); areaPath.setAttribute('fill', 'url(#cyan-gradient)'); areaPath.setAttribute('class', 'dynamic-chart-element'); svg.appendChild(areaPath); // 5. Draw Spline neon line const strokePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); strokePath.setAttribute('d', pathD); strokePath.setAttribute('class', 'chart-path-line dynamic-chart-element'); svg.appendChild(strokePath); // 6. Horizontal base line const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); baseLine.setAttribute('x1', paddingLeft); baseLine.setAttribute('y1', svgHeight - paddingBottom); baseLine.setAttribute('x2', svgWidth - paddingRight); baseLine.setAttribute('y2', svgHeight - paddingBottom); baseLine.setAttribute('class', 'chart-axis-line dynamic-chart-element'); svg.appendChild(baseLine); // 7. Render Circles & Labels & Tooltips // Check if tooltip element already exists inside the card let tooltip = svg.parentNode.querySelector('.chart-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'chart-tooltip'; svg.parentNode.appendChild(tooltip); } points.forEach((pt) => { // Date Label text const dateText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); dateText.setAttribute('x', pt.x); dateText.setAttribute('y', svgHeight - paddingBottom + 18); dateText.setAttribute('text-anchor', 'middle'); dateText.setAttribute('class', 'chart-axis-text dynamic-chart-element'); dateText.textContent = pt.label; svg.appendChild(dateText); // Coordinate node circular point const node = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); node.setAttribute('cx', pt.x); node.setAttribute('cy', pt.y); node.setAttribute('r', '4'); node.setAttribute('class', 'chart-data-node dynamic-chart-element'); // Interactive mouse tooltip mappings node.addEventListener('mousemove', (e) => { tooltip.innerHTML = `
${pt.count} Check-Ins
${pt.date}
`; tooltip.style.opacity = '1'; const box = svg.parentNode.getBoundingClientRect(); tooltip.style.left = `${e.clientX - box.left + 15}px`; tooltip.style.top = `${e.clientY - box.top - 20}px`; }); node.addEventListener('mouseleave', () => { tooltip.style.opacity = '0'; }); svg.appendChild(node); }); } // Dynamic SVG Column Chart render renderHourlyChart(data) { const svg = document.getElementById('hourly-clusters-svg'); if (!svg) return; svg.querySelectorAll('.dynamic-chart-element').forEach(el => el.remove()); const svgWidth = 500; const svgHeight = 200; const paddingLeft = 35; const paddingRight = 15; const paddingTop = 15; const paddingBottom = 30; const chartW = svgWidth - paddingLeft - paddingRight; const chartH = svgHeight - paddingTop - paddingBottom; const maxCount = Math.max(...data.map(d => d.count), 0); const yMax = maxCount > 0 ? Math.ceil(maxCount / 2) * 2 : 4; // 1. Gridlines and Labels const gridDivs = 4; for (let i = 0; i <= gridDivs; i++) { const yVal = yMax * (i / gridDivs); const yPos = svgHeight - paddingBottom - (i / gridDivs) * chartH; const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', paddingLeft); line.setAttribute('y1', yPos); line.setAttribute('x2', svgWidth - paddingRight); line.setAttribute('y2', yPos); line.setAttribute('class', 'chart-grid-line dynamic-chart-element'); svg.appendChild(line); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', paddingLeft - 8); text.setAttribute('y', yPos + 4); text.setAttribute('text-anchor', 'end'); text.setAttribute('class', 'chart-axis-text dynamic-chart-element'); text.textContent = Math.round(yVal); svg.appendChild(text); } if (data.length === 0) return; // 2. Render Rounded Bars const barWidth = 22; const spacing = chartW / data.length; let tooltip = svg.parentNode.querySelector('.chart-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.className = 'chart-tooltip'; svg.parentNode.appendChild(tooltip); } data.forEach((d, i) => { const x = paddingLeft + i * spacing + (spacing - barWidth) / 2; const valHeight = (d.count / yMax) * chartH; const y = svgHeight - paddingBottom - valHeight; // SVG column node const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', x); rect.setAttribute('y', y); rect.setAttribute('width', barWidth); rect.setAttribute('height', Math.max(valHeight, 1.5)); rect.setAttribute('rx', '4'); rect.setAttribute('class', 'chart-bar-column dynamic-chart-element'); // Interactive mouse tooltip mappings rect.addEventListener('mousemove', (e) => { tooltip.innerHTML = `
${d.count} Scans
Peak: ${d.label}
`; tooltip.style.opacity = '1'; const box = svg.parentNode.getBoundingClientRect(); tooltip.style.left = `${e.clientX - box.left + 15}px`; tooltip.style.top = `${e.clientY - box.top - 20}px`; }); rect.addEventListener('mouseleave', () => { tooltip.style.opacity = '0'; }); svg.appendChild(rect); // Axis label text const axisText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); axisText.setAttribute('x', x + barWidth / 2); axisText.setAttribute('y', svgHeight - paddingBottom + 18); axisText.setAttribute('text-anchor', 'middle'); axisText.setAttribute('class', 'chart-axis-text dynamic-chart-element'); axisText.textContent = d.label.replace(' ', ''); svg.appendChild(axisText); }); // 3. Horizontal base line const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); baseLine.setAttribute('x1', paddingLeft); baseLine.setAttribute('y1', svgHeight - paddingBottom); baseLine.setAttribute('x2', svgWidth - paddingRight); baseLine.setAttribute('y2', svgHeight - paddingBottom); baseLine.setAttribute('class', 'chart-axis-line dynamic-chart-element'); svg.appendChild(baseLine); } // REST API: Load Employees Directory async loadEmployees() { try { const response = await fetch('/api/employees', { headers: this.getHeaders() }); if (!response.ok) throw new Error("Failed to fetch employees."); const employees = await response.json(); const tbody = document.getElementById('directory-table-body'); tbody.innerHTML = ''; if (employees.length === 0) { tbody.innerHTML = ` No employees registered. Click "Register Employee" to begin biometric enrollment! `; return; } employees.forEach(emp => { const tr = document.createElement('tr'); tr.setAttribute('data-id', emp.id.toLowerCase()); tr.setAttribute('data-name', emp.name.toLowerCase()); tr.setAttribute('data-role', emp.role.toLowerCase()); const joinDate = new Date(emp.created_at).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); // HR can only view directory, delete locks inside table for non-admins const actionHtml = this.role === 'admin' ? ` ` : `Read-Only`; tr.innerHTML = ` ${emp.id} ${emp.name} ${emp.email} ${emp.role} ${joinDate} ${actionHtml} `; tbody.appendChild(tr); }); } catch (err) { console.error("[-] Error loading employees:", err); } } // Local Filter Directory search filterDirectoryTable(query) { const cleanQuery = query.toLowerCase().trim(); const rows = document.querySelectorAll('#directory-table-body tr'); rows.forEach(row => { if (row.cells.length === 1) return; const id = row.getAttribute('data-id') || ''; const name = row.getAttribute('data-name') || ''; const role = row.getAttribute('data-role') || ''; if (id.includes(cleanQuery) || name.includes(cleanQuery) || role.includes(cleanQuery)) { row.style.display = ''; } else { row.style.display = 'none'; } }); } // REST API: Delete Employee async deleteEmployee(empId) { if (this.role !== 'admin') { alert("Access Denied: Requires Admin rights."); return; } if (!confirm(`Are you absolutely sure you want to delete employee ${empId}? This will remove all their biometric face templates and history logs!`)) { return; } try { const response = await fetch(`/api/employees/${empId}`, { method: 'DELETE', headers: this.getHeaders() }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Failed to delete employee profile."); this.triggerToast("Profile Deleted", `Employee ${empId} has been successfully purged.`, "check_out"); this.loadEmployees(); } catch (err) { alert(`Error: ${err.message}`); } } // REST API: Change active camera index async saveCameraSettings() { if (this.role !== 'admin') { alert("Access Denied: Requires Admin privileges."); return; } const select = document.getElementById('camera-select'); const index = parseInt(select.value); try { const response = await fetch('/api/camera/select', { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ index: index }) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Failed to switch camera."); this.triggerToast("Camera Updated", `Successfully connected to camera device index ${index}!`, "check_in"); const stream = document.getElementById('live-stream-feed'); if (stream) { stream.src = `/api/stream/live?token=${this.token}&t=${new Date().getTime()}`; } } catch (err) { alert(`Hardware Error: ${err.message}`); } } // REST API: Turn Camera On/Off dynamically async toggleCameraFeed() { if (this.role !== 'admin') { alert("Access Denied: Requires Admin privileges."); return; } const nextState = !this.cameraActive; try { const response = await fetch('/api/camera/toggle', { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ active: nextState }) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Failed to toggle camera state."); this.cameraActive = nextState; // Update UI button state const btn = document.getElementById('btn-toggle-camera'); const badge = document.getElementById('cam-badge'); if (this.cameraActive) { btn.innerHTML = `videocam_offDisable Camera`; btn.style.borderColor = ''; btn.style.boxShadow = ''; btn.style.background = ''; badge.textContent = 'ACTIVE'; badge.style.background = 'rgba(46, 204, 113, 0.15)'; badge.style.borderColor = 'var(--neon-green)'; badge.style.color = 'var(--neon-green)'; this.startClientWebcam().then(success => { if (success) { this.startScanningLoop(); requestAnimationFrame(() => this.renderCanvasLoop()); } }); this.triggerToast("Camera Enabled", "Webcam hardware has been activated.", "check_in"); } else { btn.innerHTML = `videocamEnable Camera`; btn.style.borderColor = 'var(--neon-amber)'; btn.style.boxShadow = '0 0 10px rgba(241, 196, 15, 0.2)'; btn.style.background = 'rgba(241, 196, 15, 0.05)'; badge.textContent = 'OFFLINE'; badge.style.background = 'rgba(231, 76, 60, 0.15)'; badge.style.borderColor = 'var(--neon-red)'; badge.style.color = 'var(--neon-red)'; this.stopClientWebcam(); this.triggerToast("Camera Disabled", "Webcam hardware has been released.", "check_out"); } } catch (err) { alert(`Toggle Error: ${err.message}`); } } // SSE: Real-Time Attendance Logs alerts listener setupSSE() { if (this.eventSource) { this.eventSource.close(); } console.log("[*] Establishing real-time event stream connection..."); this.eventSource = new EventSource(`/api/stream/alerts?token=${this.token}`); this.eventSource.onmessage = (event) => { try { const alert = JSON.parse(event.data); console.log("[+] SSE ALERT RECEIVED:", alert); // 1. Intercept security threat warning spoofs if (alert.event_type === 'spoof_attempt') { this.triggerToast("Security Alert", "SPOOF SCAN BLOCKED: Motionless printed photo presented!", "spoof_attempt"); return; } // 2. Draw glowing toast popup on screen for standard check-ins/outs const eventTitle = alert.event_type === 'check_in' ? 'Check In Success' : 'Check Out Success'; this.triggerToast(eventTitle, `${alert.name} checked in successfully at ${alert.timestamp}!`, alert.event_type); // 3. Increment session log counter this.sessionLogsCount++; const counterEl = document.getElementById('session-log-count'); if (counterEl) { counterEl.textContent = `${this.sessionLogsCount} LOGS`; } // 4. Append to Session Scanner logs tab const scanConsole = document.getElementById('scanner-session-logs'); if (scanConsole) { if (scanConsole.children.length === 1 && scanConsole.children[0].style.border === 'none') { scanConsole.innerHTML = ''; } const scanItem = document.createElement('div'); scanItem.className = `activity-item ${alert.event_type}`; const initial = alert.name.charAt(0).toUpperCase(); scanItem.innerHTML = `
${initial}
${alert.name}

Score: ${alert.score}% • Biometrics Verified

${alert.event_type.replace('_', ' ')}
${alert.timestamp}
`; scanConsole.insertBefore(scanItem, scanConsole.firstChild); if (scanConsole.children.length > 10) { scanConsole.removeChild(scanConsole.lastChild); } } // 5. Force reload stats and dashboard logs in background this.loadStats(); this.loadLogs(); this.loadAnalytics(); } catch (err) { console.error("[-] Error parsing SSE event data:", err); } }; this.eventSource.onerror = (err) => { console.warn("[-] EventSource connection interrupted, trying to reconnect..."); setTimeout(() => { if (this.token) this.setupSSE(); }, 5000); }; } // UI Toast alert trigger triggerToast(title, message, eventType = 'check_in') { const container = document.getElementById('alerts-container'); if (!container) return; const toast = document.createElement('div'); // Choose CSS styling classes and icons based on alert context let toastClass = eventType; let icon = 'check'; let customStyles = ''; if (eventType === 'check_out') { icon = 'exit_to_app'; } else if (eventType === 'spoof_attempt') { toastClass = 'check_out'; // Use warning crimson accents icon = 'security'; // Inject crimson alarm custom borders/shadows directly customStyles = 'border-left-color: var(--neon-red) !important; box-shadow: 0 10px 30px rgba(231, 76, 60, 0.35) !important; border-right: 1px solid rgba(231, 76, 60, 0.25);'; } toast.className = `alert-toast ${toastClass}`; if (customStyles) { toast.style.cssText = customStyles; } let iconStyle = ''; if (eventType === 'spoof_attempt') { iconStyle = 'style="background: rgba(231, 76, 60, 0.1) !important; color: var(--neon-red) !important; box-shadow: 0 0 10px rgba(231, 76, 60, 0.3);"'; } toast.innerHTML = `
${icon}

${title}

${message}

`; container.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(100px)'; setTimeout(() => { if (toast.parentNode === container) { container.removeChild(toast); } }, 300); }, 4500); } // Client Webcam helpers async startClientWebcam() { if (this.localStream) return true; try { this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: "user" } }); this.videoEl.srcObject = this.localStream; this.videoEl.play(); console.log("[+] Client webcam stream initialized."); return true; } catch (err) { console.error("[-] Failed to open client webcam:", err); alert("Error accessing camera: Please grant camera permissions to use the Face Recognition features."); return false; } } stopClientWebcam() { if (this.scanIntervalId) { clearInterval(this.scanIntervalId); this.scanIntervalId = null; } if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; this.videoEl.srcObject = null; console.log("[+] Client webcam stream stopped."); } this.latestScanResults = null; } startScanningLoop() { if (this.scanIntervalId) return; const offlineCanvas = document.createElement('canvas'); offlineCanvas.width = 640; offlineCanvas.height = 480; const offlineCtx = offlineCanvas.getContext('2d'); this.scanIntervalId = setInterval(async () => { if (this.activeTab !== 'scanner' || !this.localStream) return; // Draw mirrored to offline canvas for backend processing offlineCtx.save(); offlineCtx.translate(offlineCanvas.width, 0); offlineCtx.scale(-1, 1); offlineCtx.drawImage(this.videoEl, 0, 0, offlineCanvas.width, offlineCanvas.height); offlineCtx.restore(); // Export to JPEG blob offlineCanvas.toBlob(async (blob) => { if (!blob) return; const formData = new FormData(); formData.append('file', blob, 'frame.jpg'); try { const response = await fetch('/api/scan/frame', { method: 'POST', headers: { 'Authorization': `Bearer ${this.token}` }, body: formData }); if (response.status === 401) { this.handleLogout(); return; } const data = await response.json(); this.latestScanResults = data.faces || []; } catch (err) { console.error("[-] Frame scan failed:", err); } }, 'image/jpeg', 0.85); }, 250); // Scan 4 frames per second } renderCanvasLoop() { if (!this.localStream) return; const canvas = this.activeTab === 'scanner' ? document.getElementById('live-scanner-canvas') : document.getElementById('enrollment-canvas'); if (!canvas) { requestAnimationFrame(() => this.renderCanvasLoop()); return; } const ctx = canvas.getContext('2d'); // Draw video frame (mirrored) ctx.save(); ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(this.videoEl, 0, 0, canvas.width, canvas.height); ctx.restore(); // Draw the scanner HUD overlay lines this.drawHUD(canvas, ctx); // Draw latest faces/bounding boxes/labels returned by the API if (this.activeTab === 'scanner' && this.latestScanResults) { this.drawFaces(ctx, this.latestScanResults); } requestAnimationFrame(() => this.renderCanvasLoop()); } drawHUD(canvas, ctx) { const w = canvas.width; const h = canvas.height; ctx.strokeStyle = '#00fff2'; ctx.lineWidth = 1; // Outer border rectangle ctx.strokeRect(10, 10, w - 20, h - 20); // Draw corners (tick lines) const len = 15; ctx.lineWidth = 2; const drawCorners = (x1, y1, x2, y2) => { // Top-left ctx.beginPath(); ctx.moveTo(x1, y1 + len); ctx.lineTo(x1, y1); ctx.lineTo(x1 + len, y1); ctx.stroke(); // Top-right ctx.beginPath(); ctx.moveTo(x2, y1 + len); ctx.lineTo(x2, y1); ctx.lineTo(x2 - len, y1); ctx.stroke(); // Bottom-left ctx.beginPath(); ctx.moveTo(x1, y2 - len); ctx.lineTo(x1, y2); ctx.lineTo(x1 + len, y2); ctx.stroke(); // Bottom-right ctx.beginPath(); ctx.moveTo(x2, y2 - len); ctx.lineTo(x2, y2); ctx.lineTo(x2 - len, y2); ctx.stroke(); }; drawCorners(10, 10, w - 10, h - 10); // If no faces detected, draw laser line animation if (!this.latestScanResults || this.latestScanResults.length === 0) { ctx.strokeStyle = '#00fff2'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(10, this.scanlineY); ctx.lineTo(w - 10, this.scanlineY); ctx.stroke(); this.scanlineY += this.scanlineDirection; if (this.scanlineY >= h - 15 || this.scanlineY <= 15) { this.scanlineDirection *= -1; } } } drawFaces(ctx, faces) { faces.forEach(face => { const [x, y, fw, fh] = face.bbox; let color = '#e74c3c'; // Unknown (red) let label = `UNKNOWN ID`; if (face.employee_id) { color = '#2ecc71'; // Recognized (emerald) label = `${face.name} (${Math.round(face.match_score * 100)}%)`; if (face.event_status) { label += ` | ${face.event_status.replace('_', ' ').toUpperCase()}`; } } // Draw bounding box ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.strokeRect(x, y, fw, fh); // Draw corners on bounding box ctx.strokeStyle = '#00fff2'; ctx.lineWidth = 2; const len = 8; // Top-left ctx.beginPath(); ctx.moveTo(x, y + len); ctx.lineTo(x, y); ctx.lineTo(x + len, y); ctx.stroke(); // Top-right ctx.beginPath(); ctx.moveTo(x + fw, y + len); ctx.lineTo(x + fw, y); ctx.lineTo(x + fw - len, y); ctx.stroke(); // Bottom-left ctx.beginPath(); ctx.moveTo(x, y + fh - len); ctx.lineTo(x, y + fh); ctx.lineTo(x + len, y + fh); ctx.stroke(); // Bottom-right ctx.beginPath(); ctx.moveTo(x + fw, y + fh - len); ctx.lineTo(x + fw, y + fh); ctx.lineTo(x + fw - len, y + fh); ctx.stroke(); // Draw landmarks (5 points) ctx.fillStyle = '#00fff2'; face.landmarks.forEach(lm => { ctx.beginPath(); ctx.arc(lm[0], lm[1], 3, 0, 2 * Math.PI); ctx.fill(); ctx.strokeStyle = '#00fff2'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(lm[0], lm[1], 6, 0, 2 * Math.PI); ctx.stroke(); }); // Draw text label banner ctx.font = '12px Courier New'; const textWidth = ctx.measureText(label).width; ctx.fillStyle = color; ctx.fillRect(x, Math.max(0, y - 20), textWidth + 14, 20); ctx.fillStyle = '#ffffff'; ctx.fillText(label, x + 7, Math.max(14, y - 5)); }); } // WIZARD STATE MACHINE METHODS openEnrollmentWizard() { this.currentEnrollId = null; this.currentEnrollName = null; this.capturedAngles = { center: false, left: false, right: false, up: false, down: false }; document.getElementById('emp-id-input').value = ''; document.getElementById('emp-name-input').value = ''; document.getElementById('emp-email-input').value = ''; document.getElementById('emp-role-input').value = ''; this.updateWizardStepUI(1); document.getElementById('wizard-modal').style.display = 'flex'; } closeEnrollmentWizard() { this.stopClientWebcam(); document.getElementById('wizard-modal').style.display = 'none'; this.loadEmployees(); // Re-enable dashboard/scanner camera if scanner tab is active if (this.activeTab === 'scanner') { this.startClientWebcam().then(success => { if (success) { this.startScanningLoop(); requestAnimationFrame(() => this.renderCanvasLoop()); } }); } } updateWizardStepUI(stepNum) { for (let i = 1; i <= 3; i++) { const panel = document.getElementById(`wizard-panel-${i}`); const ind = document.getElementById(`step-ind-${i}`); if (i === stepNum) { panel.classList.add('active'); ind.classList.add('active'); ind.classList.remove('completed'); } else if (i < stepNum) { panel.classList.remove('active'); ind.classList.remove('active'); ind.classList.add('completed'); } else { panel.classList.remove('active'); ind.classList.remove('active'); ind.classList.remove('completed'); } } } // Wizard Step 1 -> Step 2 async submitWizardStep1() { const id = document.getElementById('emp-id-input').value.trim(); const name = document.getElementById('emp-name-input').value.trim(); const email = document.getElementById('emp-email-input').value.trim(); const role = document.getElementById('emp-role-input').value.trim(); if (!id || !name || !email || !role) { alert("Please fill in all employee fields before proceeding."); return; } try { const response = await fetch('/api/employees', { method: 'POST', headers: this.getHeaders(), body: JSON.stringify({ id, name, email, role }) }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Failed to register profile details."); this.currentEnrollId = id; this.currentEnrollName = name; document.querySelectorAll('.angle-card').forEach(card => { card.className = 'angle-card'; const statusIcon = card.querySelector('.angle-status-icon'); const angle = card.getAttribute('data-angle'); if (angle === 'center') { card.classList.add('active'); statusIcon.textContent = 'face'; } else { statusIcon.textContent = this.getAngleIcon(angle); } }); document.getElementById('scan-progress-label').innerHTML = ` Scan progress: 0 / 5 angles captured `; document.getElementById('btn-step2-next').disabled = true; document.getElementById('btn-step2-next').style.opacity = '0.5'; const loadingOverlay = document.getElementById('enroll-cam-loading-text'); // Stop scanner loop first if it was running if (this.scanIntervalId) { clearInterval(this.scanIntervalId); this.scanIntervalId = null; } this.startClientWebcam().then(success => { if (success) { loadingOverlay.style.display = 'none'; requestAnimationFrame(() => this.renderCanvasLoop()); } }); this.updateWizardStepUI(2); } catch (err) { alert(`Error: ${err.message}`); } } getAngleIcon(angle) { const icons = { center: 'face', left: 'chevron_left', right: 'chevron_right', up: 'expand_less', down: 'expand_more' }; return icons[angle] || 'face'; } backToWizardStep1() { if (this.currentEnrollId) { fetch(`/api/employees/${this.currentEnrollId}`, { method: 'DELETE', headers: this.getHeaders() }); } this.stopClientWebcam(); this.updateWizardStepUI(1); } // REST API: Trigger a single angle scan capture async captureAngle(angle) { if (!this.currentEnrollId) return; document.querySelectorAll('.angle-card').forEach(card => { if (card.getAttribute('data-angle') === angle) { card.classList.add('active'); } else { card.classList.remove('active'); } }); const offlineCanvas = document.createElement('canvas'); offlineCanvas.width = 640; offlineCanvas.height = 480; const offlineCtx = offlineCanvas.getContext('2d'); // Draw mirrored offlineCtx.save(); offlineCtx.translate(offlineCanvas.width, 0); offlineCtx.scale(-1, 1); offlineCtx.drawImage(this.videoEl, 0, 0, offlineCanvas.width, offlineCanvas.height); offlineCtx.restore(); offlineCanvas.toBlob(async (blob) => { if (!blob) { alert("Failed to capture webcam frame."); return; } const formData = new FormData(); formData.append('employee_id', this.currentEnrollId); formData.append('angle', angle); formData.append('file', blob, 'enroll.jpg'); try { const response = await fetch('/api/enroll/upload-frame', { method: 'POST', headers: { 'Authorization': `Bearer ${this.token}` }, body: formData }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Capture failed."); this.capturedAngles[angle] = true; const activeCard = document.querySelector(`.angle-card[data-angle="${angle}"]`); activeCard.className = 'angle-card completed'; activeCard.querySelector('.angle-status-icon').textContent = 'check_circle'; this.triggerToast("Angle Saved", `Captured reference embedding for angle: ${angle.toUpperCase()}`, "check_in"); const count = Object.values(this.capturedAngles).filter(val => val === true).length; document.getElementById('scan-progress-label').innerHTML = ` Scan progress: ${count} / 5 angles captured `; const anglesOrder = ['center', 'left', 'right', 'up', 'down']; const nextAngle = anglesOrder.find(a => !this.capturedAngles[a]); if (nextAngle) { const nextCard = document.querySelector(`.angle-card[data-angle="${nextAngle}"]`); nextCard.classList.add('active'); } if (count === 5) { const btnStep2Next = document.getElementById('btn-step2-next'); btnStep2Next.disabled = false; btnStep2Next.style.opacity = '1'; btnStep2Next.style.borderColor = 'var(--neon-green)'; btnStep2Next.style.boxShadow = 'var(--shadow-neon-success)'; } } catch (err) { alert(`Scan Failure: ${err.message}\nMake sure your face is clearly visible, centered, and looking in the requested direction.`); } }, 'image/jpeg', 0.9); } completeWizardEnrollment() { document.getElementById('enrolled-emp-name').textContent = this.currentEnrollName; this.stopClientWebcam(); this.updateWizardStepUI(3); } } // Global instance launcher let app; window.addEventListener('DOMContentLoaded', () => { app = new SecureAttendApp(); app.init(); window.app = app; });