119 lines
5.5 KiB
JavaScript
119 lines
5.5 KiB
JavaScript
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
|
|
// before showing anything. The tab remembers it was unlocked (sessionStorage
|
|
// lives as long as the tab, through reloads, and a new tab starts empty), so
|
|
// a reload does not ask again. Loaded in <head>, before the page shows.
|
|
// A device can be set to trust the PIN for a while: for that many minutes
|
|
// after it was entered, new tabs there open without asking (localStorage,
|
|
// this browser only).
|
|
(function () {
|
|
'use strict';
|
|
var KEY = 'lpra.pin.ok';
|
|
var ok = false;
|
|
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
|
|
if (ok) return;
|
|
try {
|
|
if (Date.now() < Number(localStorage.getItem('lpra.pin.until') || 0)) {
|
|
sessionStorage.setItem(KEY, '1');
|
|
return;
|
|
}
|
|
} catch (e) { /* ask */ }
|
|
var root = document.documentElement;
|
|
root.classList.add('pin-wait');
|
|
function release() { root.classList.remove('pin-wait'); }
|
|
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
|
|
// After the PIN itself: no asking on this device for the minutes it trusts.
|
|
function trust() {
|
|
try {
|
|
var min = Number(localStorage.getItem('lpra.pin.grace') || 0);
|
|
if (min > 0) localStorage.setItem('lpra.pin.until', String(Date.now() + min * 60000));
|
|
} catch (e) { /* asks every tab */ }
|
|
}
|
|
|
|
fetch('/api/panel/pin', {cache: 'no-store'})
|
|
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
|
|
.then(function (d) {
|
|
if (!d.pin) { remember(); release(); return; }
|
|
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
|
|
})
|
|
.catch(release);
|
|
|
|
function lock() {
|
|
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
|
|
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
|
|
var box = document.createElement('div');
|
|
box.className = 'pinlock';
|
|
box.setAttribute('role', 'dialog');
|
|
box.setAttribute('aria-modal', 'true');
|
|
box.setAttribute('aria-label', 'Ввод PIN-кода');
|
|
var keys = '';
|
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'out', '0', 'back'].forEach(function (k) {
|
|
var label = k === 'back' ? ' aria-label="Стереть">' + BACK : k === 'out' ? ' title="Выйти из панели">Выйти' : '>' + k;
|
|
keys += '<button type="button" data-k="' + k + '"' + label + '</button>';
|
|
});
|
|
box.innerHTML =
|
|
'<div class="pin-card">' +
|
|
'<div class="pin-ico">' + LOCK + '</div>' +
|
|
'<div class="pin-title">Введите PIN-код</div>' +
|
|
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
|
|
'<div class="pin-msg" aria-live="polite"></div>' +
|
|
'<div class="pin-pad">' + keys + '</div>' +
|
|
'<form method="post" action="/logout" class="pin-out" hidden></form>' +
|
|
'</div>';
|
|
document.body.appendChild(box);
|
|
requestAnimationFrame(function () { box.classList.add('on'); });
|
|
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
|
|
var code = '', busy = false;
|
|
function show() {
|
|
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
|
|
}
|
|
function press(k) {
|
|
if (k === 'out') { box.querySelector('.pin-out').submit(); return; }
|
|
if (busy) return;
|
|
if (k === 'back') { code = code.slice(0, -1); show(); return; }
|
|
if (code.length >= 4) return;
|
|
code += k;
|
|
msg.textContent = '';
|
|
card.classList.remove('bad');
|
|
show();
|
|
if (code.length === 4) check();
|
|
}
|
|
function check() {
|
|
busy = true;
|
|
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (r) {
|
|
if (r.ok) {
|
|
remember();
|
|
trust();
|
|
box.classList.add('done');
|
|
release();
|
|
setTimeout(function () { box.remove(); }, 320);
|
|
document.removeEventListener('keydown', onKey, true);
|
|
return;
|
|
}
|
|
if (r.out) { location.href = '/login'; return; }
|
|
card.classList.remove('bad');
|
|
void card.offsetWidth; // restart the shake
|
|
card.classList.add('bad');
|
|
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
|
|
setTimeout(function () { code = ''; show(); busy = false; }, 380);
|
|
})
|
|
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
|
|
}
|
|
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
|
|
var b = ev.target.closest('button');
|
|
if (b) press(b.dataset.k);
|
|
});
|
|
// Typed digits, ahead of anything on the page (the terminal included).
|
|
function onKey(ev) {
|
|
if (/^[0-9]$/.test(ev.key)) press(ev.key);
|
|
else if (ev.key === 'Backspace') press('back');
|
|
else if (ev.key !== 'Tab') return;
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
}
|
|
document.addEventListener('keydown', onKey, true);
|
|
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
}
|
|
})();
|