Files
lpra/internal/server/web/static/pin.js
T

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();
}
})();