Nishant Katiyar
Deploy biometric node to HF Spaces
b561839
Raw
History Blame Contribute Delete
50.8 kB
/**
* 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 = `
<div class="activity-item" style="justify-content: center; color: var(--text-muted); border: none; background: transparent;">
<p>No scans recorded yet today.</p>
</div>
`;
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 = `
<div class="activity-avatar">${initial}</div>
<div class="activity-details">
<h5>${log.name}</h5>
<p>${log.role} • Score: ${parseInt(log.similarity_score * 100)}%</p>
</div>
<div style="text-align: right;">
<span class="activity-badge">${log.event_type.replace('_', ' ')}</span>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">${logTime}</div>
</div>
`;
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 = `<div style="font-weight:700;color:var(--neon-cyan);">${pt.count} Check-Ins</div><div style="font-size:9px;color:var(--text-muted);margin-top:2px;">${pt.date}</div>`;
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 = `<div style="font-weight:700;color:var(--neon-green);">${d.count} Scans</div><div style="font-size:9px;color:var(--text-muted);margin-top:2px;">Peak: ${d.label}</div>`;
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 = `
<tr>
<td colspan="6" style="text-align: center; color: var(--text-muted); padding: 40px;">
No employees registered. Click "Register Employee" to begin biometric enrollment!
</td>
</tr>
`;
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' ? `
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px; border-radius: 8px;" onclick="app.deleteEmployee('${emp.id}')">
<i class="material-icons" style="font-size: 16px;">delete</i>
Delete
</button>
` : `<span style="font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px;">Read-Only</span>`;
tr.innerHTML = `
<td style="font-weight: 700; color: var(--neon-cyan);">${emp.id}</td>
<td style="font-weight: 600;">${emp.name}</td>
<td style="color: var(--text-secondary);">${emp.email}</td>
<td>${emp.role}</td>
<td style="color: var(--text-muted);">${joinDate}</td>
<td style="text-align: right;">${actionHtml}</td>
`;
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 = `<i class="material-icons" style="font-size: 18px;">videocam_off</i><span>Disable Camera</span>`;
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 = `<i class="material-icons" style="font-size: 18px;">videocam</i><span>Enable Camera</span>`;
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 = `
<div class="activity-avatar">${initial}</div>
<div class="activity-details">
<h5>${alert.name}</h5>
<p>Score: ${alert.score}% • Biometrics Verified</p>
</div>
<div style="text-align: right;">
<span class="activity-badge">${alert.event_type.replace('_', ' ')}</span>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">${alert.timestamp}</div>
</div>
`;
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 = `
<div class="alert-toast-icon" ${iconStyle}><i class="material-icons">${icon}</i></div>
<div class="alert-toast-content">
<h4 style="${eventType === 'spoof_attempt' ? 'color: var(--neon-red);' : ''}">${title}</h4>
<p>${message}</p>
</div>
`;
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: <span style="color: var(--neon-cyan); font-weight: 700;">0 / 5 angles</span> 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: <span style="color: var(--neon-green); font-weight: 700;">${count} / 5 angles</span> 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;
});