feat: optional 4-digit PIN asked in every new tab (not on reload), on-screen keypad for phones; set, change or remove it in the account; five misses sign out

This commit is contained in:
redtea committed 2026-10-14 21:12:09 +03:00
1 parent 29353e5e6a
commit 119b524825
14 files changed
+316 -1

No files matched your search

+42
View File
@@ -48,6 +48,13 @@
' <input type="password" name="new" placeholder="новый пароль, от 8 символов" autocomplete="new-password" minlength="8" required>' +
' <input type="password" name="again" placeholder="новый ещё раз" autocomplete="new-password" required>' +
' <button type="submit">Сменить</button></form></details>' +
' <details class="acct-pin"><summary>PIN-код <small class="acct-pinstate"></small></summary>' +
' <form class="acct-pinform">' +
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
' <div class="row"><button type="button" class="btn danger small acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' </form><p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' +
'</div>';
document.body.appendChild(dlg);
@@ -64,6 +71,11 @@
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
$('.acct-name').textContent = d.username;
dlg.querySelector('.acct-pwform [name=username]').value = d.username;
const pf = dlg.querySelector('.acct-pinform');
dlg.querySelector('.acct-pinstate').textContent = d.pin ? '· установлен' : '· не задан';
pf.current.hidden = !d.pin;
pf.querySelector('.acct-pindel').hidden = !d.pin;
pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить';
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw');
@@ -134,6 +146,35 @@
.catch(function () { msg('ошибка сети', true); })
.then(function () { f.querySelector('button').disabled = false; });
};
function pinMsg(text, bad) {
$('.acct-pinmsg').textContent = text || '';
$('.acct-pinmsg').className = 'acct-pinmsg' + (bad ? ' bad' : '');
}
function savePin(pin) {
const f = $('.acct-pinform');
return fetch('/api/panel/account/pin', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin: pin, current: f.current.value})})
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) { pinMsg(r.error, true); return; }
f.reset();
// This tab stays unlocked: the PIN is for tabs opened from now on.
try { sessionStorage.setItem('lpra.pin.ok', '1'); } catch (e) { /* asked next load */ }
pinMsg(pin ? 'PIN сохранён' : 'PIN убран');
load();
})
.catch(function () { pinMsg('ошибка сети', true); });
}
$('.acct-pinform').onsubmit = function (ev) {
ev.preventDefault();
const f = ev.target;
if (!/^[0-9]{4}$/.test(f.pin.value)) { pinMsg('PIN - ровно 4 цифры', true); return; }
if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; }
savePin(f.pin.value);
};
$('.acct-pindel').onclick = function () {
if (confirm('Убрать PIN-код?')) savePin('');
};
$('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
@@ -146,6 +187,7 @@
};
btn.onclick = function () {
msg('');
pinMsg('');
load().then(function () { dlg.showModal(); dlg.focus(); });
};
})();
+41
View File
@@ -107,6 +107,39 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); }
/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */
html.pin-wait body > :not(.pinlock) { visibility: hidden; }
.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
opacity: 0; transition: opacity .3s ease; }
.pinlock.on { opacity: 1; }
.pinlock.done { opacity: 0; pointer-events: none; }
.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center;
transform: translateY(8px) scale(.98); transition: transform .3s ease; }
.pinlock.on .pin-card { transform: none; }
.pinlock.done .pin-card { transform: scale(1.03); }
.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #12202f; border: 1px solid var(--border); color: var(--accent); }
.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; }
.pin-dots { display: flex; gap: 16px; }
.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; }
.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); }
.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); }
.pin-card.bad .pin-dots { animation: pin-shake .38s ease; }
@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); }
.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; }
.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center;
background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400;
-webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; }
.pin-pad button:hover { background: #1b222c; }
.pin-pad button:active { background: #12202f; transform: scale(.94); }
.pin-pad button[data-k="back"] { background: transparent; border-color: transparent; color: var(--muted); }
.pin-out { margin-top: 18px; }
.pin-out button { background: transparent; color: var(--muted); padding: 6px 12px; font-size: 14px; }
.pin-out button:hover { background: transparent; color: var(--text); }
@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } }
/* Dialogs shared by the pages. */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
@@ -150,6 +183,14 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
.acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); }
.acct-pwform { display: grid; gap: 8px; margin-top: 10px; }
.acct-pwform button { justify-self: end; }
.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); }
.acct-pin small { color: var(--muted); font-size: 12px; }
.acct-pinform { display: grid; gap: 8px; margin-top: 10px; }
.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
.acct-pinform .row { justify-content: flex-end; }
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
.acct-pinmsg.bad { color: var(--danger); }
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
/* Sign-in page. */
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
+100
View File
@@ -0,0 +1,100 @@
// 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.
(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;
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 */ } }
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', '', '0', 'back'].forEach(function (k) {
if (!k) { keys += '<span></span>'; return; }
keys += '<button type="button" data-k="' + k + '"' + (k === 'back' ? ' aria-label="Стереть">' + BACK : '>' + k) + '</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"><button type="submit">Выйти</button></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 (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();
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();
}
})();
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
</head>
<body>
<header class="topbar">
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
</head>
<body>
<header class="topbar">
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
<style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style>
/* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style>
/* An install command, with a button to copy it at its right. */
.copybox { position:relative; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<link rel="stylesheet" href="{{asset "xterm.css"}}">
<style>
#status.st-ok { color:#37c871; }