/* Fingerprint Agent — enrollment wizard. Captures run through the local fpagent binary; artifacts are saved via the app's a2loft proxy. */ /* global Vue, axios, io, customElements */ /* window.__SESSION__ is injected server-side by /fingerprint */ Vue.config.ignoredElements = [/^cds-/]; (function () { 'use strict'; var MAX_STAGE = 5; // the U.are.U 4500 reports five enrollment stages function newFinger() { return { status: 'idle', stage: 0, id: '', fingerprint: '', image: '' }; } var app = new Vue({ el: '#app', data: { loading: true, session: null, tick: Date.now(), socket: null, // available is tri-state: null = not yet reported (old agent), true/false // = the last fprint-device-status. Unknown is not blocked. agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' }, domain: '', reference: '', staff: null, busy: { lookup: false, save: false }, refreshing: false, notif: { show: false, kind: 'info', title: '', subtitle: '' }, notifTimer: null, fingers: { 'right-thumb': newFinger(), 'right-index-finger': newFinger(), }, fingerLabels: { 'right-thumb': 'Right thumb', 'right-index-finger': 'Right index finger' }, capturing: null, // finger currently being captured (fpagent serves one at a time) cancelling: false, prevCapture: null, // previous good capture for the finger being recaptured }, computed: { maxStage: function () { return MAX_STAGE; }, bothCaptured: function () { return this.fingers['right-thumb'].status === 'done' && this.fingers['right-index-finger'].status === 'done'; }, canLookup: function () { return !!(this.domain && this.reference) && !this.busy.lookup; }, canSave: function () { return this.bothCaptured && !this.busy.save; }, refreshUnlocksAt: function () { var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000; return ((this.session && this.session.domainsCheckedAt) || 0) + ttl; }, refreshEnabled: function () { return !!this.session && !this.refreshing && this.tick >= this.refreshUnlocksAt; }, refreshEtaLabel: function () { var s = Math.max(0, Math.ceil((this.refreshUnlocksAt - this.tick) / 1000)); var m = Math.floor(s / 60); return m > 0 ? 'in ' + m + ' min' : 'in ' + s + ' s'; }, domainsLastCheckedLabel: function () { if (!this.session || !this.session.domainsCheckedAt) return ''; return new Date(this.session.domainsCheckedAt).toLocaleTimeString(); }, // Warning shown in the capture panel when the agent cannot capture. scannerNotice: function () { if (!this.agent.connected) { return { title: 'Capture agent offline', subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.', }; } if (this.agent.available === false) { return { title: 'Scanner not connected', subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.', }; } return null; }, // Capture is allowed when the agent is connected and no status has said // the scanner is missing. Unknown availability (old agent) stays allowed. captureReady: function () { return this.agent.connected && this.agent.available !== false; }, }, created: function () { var self = this; this.loadSession() .then(function () { self.connectAgent(); }) .catch(function () { /* loadSession redirects on 401 */ }); setInterval(function () { self.tick = Date.now(); }, 1000); // web components upgrade after first render — re-apply reactive bindings Promise.all( ['cds-button', 'cds-tag', 'cds-progress-bar', 'cds-inline-notification'].map(function (n) { return customElements.whenDefined(n).catch(function () {}); }) ).then(function () { self.$forceUpdate(); }); }, methods: { showNotif: function (kind, title, subtitle, sticky) { var self = this; this.notif = { show: true, kind: kind, title: title, subtitle: subtitle || '' }; if (this.notifTimer) clearTimeout(this.notifTimer); if (!sticky) { this.notifTimer = setTimeout(function () { self.notif.show = false; }, 6000); } }, loadSession: function () { // injected by the server when the page was rendered (no round-trip) this.session = window.__SESSION__ || null; if (!this.session) { window.location.href = '/auth/login'; return Promise.resolve(); } if (this.session.domains && this.session.domains.length) { this.domain = this.session.domains[0].code; } this.loading = false; return Promise.resolve(); }, refreshDomains: function () { var self = this; if (!this.refreshEnabled) return; this.refreshing = true; axios.post('/auth/domains/refresh').then(function (r) { self.session.domains = r.data.domains; self.session.domainsCheckedAt = r.data.domainsCheckedAt; if (self.session.domains.length && self.session.domains.indexOf(self.domain) < 0) { self.domain = self.session.domains[0].code; } if (!self.session.domains.length) { self.showNotif('warning', 'No permitted domains', 'You are not permitted to capture fingerprints for any domain.', true); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not refresh domain permissions.'; self.showNotif('error', 'Refresh failed', msg); }).finally(function () { self.refreshing = false; }); }, /** The agent status tag lives in the server-rendered header. */ renderAgentTag: function () { var el = document.getElementById('agent-status'); if (!el) return; var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : ''; if (!this.agent.connected) { label = 'Agent offline'; type = 'red'; } else if (this.agent.available === false) { label = 'Scanner not connected'; type = 'red'; } else if (this.agent.available === true) { label = 'Agent ready'; type = 'green'; } else { // Connected but no status yet (or an agent that does not report it). label = 'Agent connected'; type = 'green'; } if (this.agent.available === true && this.agent.device) { title += ' · ' + this.agent.device; } el.textContent = label; el.setAttribute('type', type); el.setAttribute('title', title); }, connectAgent: function () { var self = this; this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 }); var offline = function () { self.agent.connected = false; self.agent.available = null; self.agent.device = ''; self.renderAgentTag(); }; this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); }); this.socket.on('disconnect', offline); this.socket.on('connect_error', offline); this.socket.on('agent-hello', function (data) { self.agent.version = (data && data.version) || ''; self.agent.captureAgentId = (data && data.captureAgentId) || ''; self.renderAgentTag(); }); this.socket.on('fprint-device-status', function (data) { self.agent.available = !!(data && data.available); self.agent.device = (data && data.device) || ''; self.renderAgentTag(); }); this.socket.on('fprint-enroll-status', function (data) { if (!self.capturing) return; self.fingers[self.capturing].stage = (data && data.stage) || 0; }); this.socket.on('fprint-enroll-done', function (data) { var finger = self.capturing; if (!finger) return; var wasCancelling = self.cancelling; self.capturing = null; self.cancelling = false; var f = self.fingers[finger]; if (data && data.ok) { f.status = 'done'; f.stage = MAX_STAGE; f.id = data.id || ''; f.fingerprint = data.fingerprint || ''; f.image = data.image || ''; self.prevCapture = null; return; } // Failure or cancellation. A recapture attempt that did not complete // restores the previous good capture instead of dropping it; a // cancelled first attempt returns to idle. var restored = self.prevCapture && self.prevCapture.finger === finger; if (restored) { var p = self.prevCapture; f.status = 'done'; f.stage = MAX_STAGE; f.id = p.id; f.fingerprint = p.fingerprint; f.image = p.image; self.prevCapture = null; } else if (wasCancelling) { f.status = 'idle'; f.stage = 0; } else { f.status = 'failed'; f.stage = 0; } self.showNotif( wasCancelling ? 'info' : 'error', wasCancelling ? 'Capture cancelled' : 'Capture failed', wasCancelling ? (restored ? 'The previous capture for the ' + self.fingerLabels[finger].toLowerCase() + ' was kept.' : 'No changes were saved for the ' + self.fingerLabels[finger].toLowerCase() + '.') : ((data && data.message) || 'The capture did not complete.') ); }); }, capture: function (finger) { if (this.capturing || !this.socket || !this.domain || !this.reference) return; if (!this.captureReady) { var notice = this.scannerNotice; if (notice) this.showNotif('warning', notice.title, notice.subtitle); return; } // Preserve a previous good capture so a cancelled/failed recapture // can restore it instead of destroying it. var old = this.fingers[finger]; this.prevCapture = old.id ? { finger: finger, id: old.id, fingerprint: old.fingerprint, image: old.image } : null; this.fingers[finger] = newFinger(); this.fingers[finger].status = 'scanning'; this.capturing = finger; this.cancelling = false; this.socket.emit('fprint-enroll', { token: this.session.token, domain: this.domain, finger: finger, reference: this.reference, }); }, cancelCapture: function () { if (!this.capturing || !this.socket) return; this.cancelling = true; this.socket.emit('fprint-enroll-stop', {}); }, lookup: function () { var self = this; if (!this.canLookup) return; this.busy.lookup = true; axios.post( '/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) ).then(function (r) { if (!r.data.ok) { self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')'); return; } self.staff = r.data.data; }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; self.showNotif('error', 'Lookup failed', msg); }).finally(function () { self.busy.lookup = false; }); }, save: function () { var self = this; if (!this.canSave) return; this.busy.save = true; var fingerprints = Object.keys(this.fingers).map(function (finger) { return { finger: finger, fingerprint: self.fingers[finger].fingerprint }; }); axios.post( '/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints', { fingerprints: fingerprints } ).then(function (r) { if (r.data.ok) { self.showNotif('success', 'Enrollment saved', 'Fingerprints saved for ' + self.staff.name + ' (reference ' + self.staff.reference + ').'); self.newEnrollment(); } else { self.showNotif('error', 'Not saved', 'Operation failed. ' + (r.data.message || '')); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; self.showNotif('error', 'Save failed', msg); }).finally(function () { self.busy.save = false; }); }, newEnrollment: function () { this.staff = null; this.reference = ''; this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() }; this.capturing = null; this.cancelling = false; this.prevCapture = null; }, fingerTag: function (finger) { switch (this.fingers[finger].status) { case 'scanning': return { type: 'blue', text: 'Scanning' }; case 'done': return { type: 'green', text: 'Captured' }; case 'failed': return { type: 'red', text: 'Failed' }; default: return { type: 'gray', text: 'Pending' }; } }, }, }); })();