// The account button in the top bar: a dialog with who is signed in, their
// passkeys (add one, remove one), the password switch and signing out. The
// password can be turned off once there is a passkey to sign in with.
(function () {
'use strict';
var btn = document.getElementById('acct');
if (!btn) return;
var b64u = {
enc: function (buf) {
var s = '', b = new Uint8Array(buf);
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
},
dec: function (str) {
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4));
var b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b.buffer;
}
};
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
var X = ' ';
var KEY = ' ';
var dlg = document.createElement('dialog');
dlg.id = 'acctdlg';
dlg.tabIndex = -1;
dlg.innerHTML =
'
Аккаунт ' +
'' + X + '
' +
'' +
'
' +
'
Ключи доступа ' +
'
Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.
' +
'
' +
'
' +
' Добавить ключ
' +
'
' +
'
Вход по паролю ' +
' ' +
'
Сменить пароль ' +
' ' +
'
PIN-код ' +
' ' +
' На этом устройстве не спрашивать после ввода ' +
' каждый раз 5 минут 15 минут ' +
' 30 минут 60 минут ' +
'
Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.
' +
'
' +
'
';
document.body.appendChild(dlg);
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
var $ = function (sel) { return dlg.querySelector(sel); };
$('[data-close]').onclick = function () { dlg.close(); };
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
function msg(text, bad) {
$('.acct-msg').textContent = text || '';
$('.acct-msg').className = 'acct-msg' + (bad ? ' bad' : '');
}
function load() {
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-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-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw');
sw.checked = !d.password_off;
sw.disabled = !d.keys.length || d.pw_locked;
$('.acct-pwnote').textContent = d.pw_locked ? 'закреплено администратором' + (d.password_off ? ': только по ключу' : '')
: !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
var ul = $('.acct-keys');
ul.innerHTML = d.keys.length ? '' : 'Ключей пока нет. ';
d.keys.forEach(function (k) {
var li = document.createElement('li');
li.innerHTML = '' + KEY + ' ' + esc(k.name) + '
' +
'
добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '
' +
'Удалить ';
li.querySelector('button').onclick = function () {
var last = d.keys.length === 1;
if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return;
fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); })
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
};
ul.appendChild(li);
});
});
}
$('.acct-addbtn').onclick = function () {
if (!supported) { msg('этот браузер не умеет ключи доступа, или страница открыта не по https', true); return; }
var add = $('.acct-addbtn');
add.disabled = true;
msg('подтвердите на устройстве…');
fetch('/api/panel/account/keys/begin', {method: 'POST'}).then(function (r) { return r.json(); }).then(function (r) {
var o = r.options;
o.challenge = b64u.dec(o.challenge);
o.user.id = b64u.dec(o.user.id);
o.excludeCredentials.forEach(function (c) { c.id = b64u.dec(c.id); });
return navigator.credentials.create({publicKey: o});
}).then(function (cred) {
return fetch('/api/panel/account/keys/finish', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: $('.acct-keyname').value,
clientDataJSON: b64u.enc(cred.response.clientDataJSON),
attestationObject: b64u.enc(cred.response.attestationObject)
})
}).then(function (r) { return r.json(); });
}).then(function (r) {
if (!r.ok) { msg(r.error, true); return; }
$('.acct-keyname').value = '';
msg('ключ добавлен - теперь вход по нему');
load();
}).catch(function (e) {
msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true);
}).then(function () { add.disabled = false; });
};
$('.acct-pwform').onsubmit = function (ev) {
ev.preventDefault();
const f = ev.target;
if (f.new.value !== f.again.value) { msg('новые пароли не совпадают', true); return; }
f.querySelector('button').disabled = true;
fetch('/api/panel/account/password/change', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({current: f.current.value, new: f.new.value})})
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) { msg(r.error, true); return; }
f.reset();
dlg.querySelector('.acct-chpw').open = false;
msg('пароль сменён');
})
.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-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 () {
if (confirm('Убрать PIN-код?')) savePin('');
};
$('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
ev.target.checked = true;
return;
}
fetch('/api/panel/account/password', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({off: off})})
.then(function (r) { return r.json(); })
.then(function (r) { if (!r.ok) msg(r.error, true); else msg(off ? 'вход по паролю выключен' : 'вход по паролю включён'); load(); });
};
btn.onclick = function () {
msg('');
pinMsg('');
load().then(function () { dlg.showModal(); dlg.focus(); });
};
})();