/* Fingerprint Agent — enrollment wizard. Captures run through the local fpagent binary; artifacts are saved via the app's a2loft proxy. */ /* global Vue, axios, swal, io */ (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, agent: { connected: false, version: '', captureAgentId: '' }, domain: '', reference: '', otpLookup: '', otpSave: '', staff: null, busy: { lookup: false, save: false }, refreshing: false, pageError: '', 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: { initials: function () { var n = (this.session && (this.session.firstName || this.session.phone)) || '?'; return n.slice(0, 2).toUpperCase(); }, 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.otpLookup) && !this.busy.lookup; }, canSave: function () { return this.bothCaptured && !!this.otpSave && !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(); }, }, created: function () { var self = this; this.loadSession() .then(function () { self.connectAgent(); }) .catch(function () { /* loadSession redirects on 401 */ }); setInterval(function () { self.tick = Date.now(); }, 1000); }, methods: { loadSession: function () { var self = this; return axios.get('/auth/session').then(function (r) { self.session = r.data; if (self.session.domains.length) self.domain = self.session.domains[0].code; self.loading = false; }).catch(function (err) { if (err.response && err.response.status === 401) { window.location.href = '/auth/login'; return; } self.loading = false; self.pageError = 'Could not load your session. Please refresh the page.'; }); }, 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) { swal('No permitted domains', 'You are not permitted to capture fingerprints for any domain.', 'info'); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not refresh domain permissions.'; swal('Refresh failed', msg, 'error'); }).finally(function () { self.refreshing = false; }); }, connectAgent: function () { var self = this; this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 }); this.socket.on('connect', function () { self.agent.connected = true; }); this.socket.on('disconnect', function () { self.agent.connected = false; }); this.socket.on('connect_error', function () { self.agent.connected = false; }); this.socket.on('agent-hello', function (data) { self.agent.version = (data && data.version) || ''; self.agent.captureAgentId = (data && data.captureAgentId) || ''; }); 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; } swal( 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.', wasCancelling ? 'info' : 'error' ); }); }, capture: function (finger) { if (this.capturing || !this.socket || !this.domain || !this.reference) 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), null, { params: { otp: this.otpLookup } } ).then(function (r) { if (!r.data.ok) { swal('Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')', 'error'); 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.'; swal('Lookup failed', msg, 'error'); }).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 }, { params: { otp: this.otpSave } } ).then(function (r) { if (r.data.ok) { swal('Saved', 'Fingerprints saved for ' + self.staff.name + ' (reference ' + self.staff.reference + ').', 'success'); self.newEnrollment(); } else { swal('Not saved', 'Operation failed. ' + (r.data.message || ''), 'error'); } }).catch(function (err) { var msg = (err.response && err.response.data && err.response.data.message) || 'Could not reach the identity service.'; swal('Save failed', msg, 'error'); }).finally(function () { self.busy.save = false; }); }, newEnrollment: function () { this.staff = null; this.reference = ''; this.otpLookup = ''; this.otpSave = ''; this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() }; this.capturing = null; this.cancelling = false; this.prevCapture = null; }, fingerBadge: function (finger) { switch (this.fingers[finger].status) { case 'scanning': return { cls: 'badge-brand', text: 'Scanning' }; case 'done': return { cls: 'badge-ok', text: 'Captured' }; case 'failed': return { cls: 'badge-err', text: 'Failed' }; default: return { cls: 'badge-idle', text: 'Pending' }; } }, progressPct: function (stage) { return Math.round((Math.min(stage, MAX_STAGE) / MAX_STAGE) * 100) + '%'; }, }, }); })();