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

110 lines
6.7 KiB
JavaScript

// The account button in the top bar: a dialog with who is signed in, their
// passkeys (add one, remove one) and signing out. Once there is a passkey,
// the password no longer signs this account in.
(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 {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]; }); };
var when = function (t) { return t ? new Date(t * 1000).toLocaleString() : 'ещё не использовался'; };
var csrf = (document.cookie.match(/(?:^|; )lpra_csrf=([^;]*)/) || [])[1] || '';
var X = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
var KEY = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
var dlg = document.createElement('dialog');
dlg.id = 'acctdlg';
dlg.tabIndex = -1;
dlg.innerHTML =
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
'<div class="dlg-b acct">' +
' <div class="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' +
' <h3>Ключи доступа</h3>' +
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.</p>' +
' <ul class="acct-keys"></ul>' +
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
' <p class="acct-msg"></p>' +
' <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);
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;
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
$('.acct-mode').textContent = d.keys.length ? 'вход только по ключу доступа' : 'вход по паролю';
var ul = $('.acct-keys');
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
d.keys.forEach(function (k) {
var li = document.createElement('li');
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
li.querySelector('button').onclick = function () {
var last = d.keys.length === 1;
if (!confirm('Удалить ключ «' + k.name + '»?' + (last ? ' Это последний ключ: вход по паролю снова станет возможен.' : ''))) 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; });
};
btn.onclick = function () {
msg('');
load().then(function () { dlg.showModal(); dlg.focus(); });
};
})();