feat: a device can skip the PIN for 5, 15, 30 or 60 minutes after it was entered (off by default, this browser only)

This commit is contained in:
redtea committed 2026-10-14 21:13:50 +03:00
1 parent 119b524825
commit 5a204ae9bb
3 files changed
+36 -1

No files matched your search

+16 -1
View File
@@ -54,7 +54,11 @@
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" 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">' + ' <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>' + ' <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>' +
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
' <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>' + ' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' +
'</div>'; '</div>';
document.body.appendChild(dlg); document.body.appendChild(dlg);
@@ -76,6 +80,8 @@
pf.current.hidden = !d.pin; pf.current.hidden = !d.pin;
pf.querySelector('.acct-pindel').hidden = !d.pin; pf.querySelector('.acct-pindel').hidden = !d.pin;
pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить'; pf.querySelector('.acct-pinsave').textContent = d.pin ? 'Сменить' : 'Установить';
$('.acct-pingrace').hidden = !d.pin;
try { $('.acct-pingrace select').value = localStorage.getItem('lpra.pin.grace') || '0'; } catch (e) { /* default */ }
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase(); $('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю'; $('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw'); const sw = $('.acct-pw .sw');
@@ -172,6 +178,15 @@
if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; } if (f.pin.value !== f.again.value) { pinMsg('новые PIN не совпадают', true); return; }
savePin(f.pin.value); savePin(f.pin.value);
}; };
$('.acct-pingrace select').onchange = function (ev) {
const min = Number(ev.target.value);
try {
localStorage.setItem('lpra.pin.grace', String(min));
// Counted from now, as the PIN was entered to get here.
localStorage.setItem('lpra.pin.until', min ? String(Date.now() + min * 60000) : '0');
} catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; }
pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке');
};
$('.acct-pindel').onclick = function () { $('.acct-pindel').onclick = function () {
if (confirm('Убрать PIN-код?')) savePin(''); if (confirm('Убрать PIN-код?')) savePin('');
}; };
+3
View File
@@ -189,6 +189,9 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
.acct-pinform { display: grid; gap: 8px; margin-top: 10px; } .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 input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
.acct-pinform .row { justify-content: flex-end; } .acct-pinform .row { justify-content: flex-end; }
.acct-pingrace { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; font-size: 13px; color: var(--muted); }
.acct-pingrace[hidden] { display: none; }
.acct-pingrace select { width: auto; flex: none; }
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; } .acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
.acct-pinmsg.bad { color: var(--danger); } .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; } .acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
+17
View File
@@ -2,16 +2,32 @@
// before showing anything. The tab remembers it was unlocked (sessionStorage // 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 // 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 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 () { (function () {
'use strict'; 'use strict';
var KEY = 'lpra.pin.ok'; var KEY = 'lpra.pin.ok';
var ok = false; var ok = false;
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ } try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
if (ok) return; 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; var root = document.documentElement;
root.classList.add('pin-wait'); root.classList.add('pin-wait');
function release() { root.classList.remove('pin-wait'); } function release() { root.classList.remove('pin-wait'); }
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } } 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'}) fetch('/api/panel/pin', {cache: 'no-store'})
.then(function (r) { return r.ok ? r.json() : {pin: false}; }) .then(function (r) { return r.ok ? r.json() : {pin: false}; })
@@ -67,6 +83,7 @@
.then(function (r) { .then(function (r) {
if (r.ok) { if (r.ok) {
remember(); remember();
trust();
box.classList.add('done'); box.classList.add('done');
release(); release();
setTimeout(function () { box.remove(); }, 320); setTimeout(function () { box.remove(); }, 320);