feat: the password is turned off by hand in the account, with a passkey in place; password change; tabs sized by their titles
This commit is contained in:
1 parent
67222e2e05
commit
6dd1120a41
9 files changed
+212
-25
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// 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.
|
||||
// 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');
|
||||
@@ -34,11 +34,20 @@
|
||||
'<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>' +
|
||||
' <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>' +
|
||||
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||
' <input type="checkbox" class="sw" role="switch"></label>' +
|
||||
' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
|
||||
' <form class="acct-pwform" autocomplete="on">' +
|
||||
' <input type="text" name="username" autocomplete="username" hidden>' +
|
||||
' <input type="password" name="current" placeholder="текущий пароль" autocomplete="current-password" required>' +
|
||||
' <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>' +
|
||||
' <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);
|
||||
@@ -54,8 +63,14 @@
|
||||
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;
|
||||
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
|
||||
$('.acct-mode').textContent = d.keys.length ? 'вход только по ключу доступа' : 'вход по паролю';
|
||||
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
|
||||
const sw = $('.acct-pw .sw');
|
||||
sw.checked = !d.password_off;
|
||||
sw.disabled = !d.keys.length;
|
||||
$('.acct-pwnote').textContent = !d.keys.length ? 'выключить можно, когда есть хотя бы один ключ'
|
||||
: d.password_off ? 'выключен: войти можно только ключом' : 'можно выключить и входить только ключом';
|
||||
var ul = $('.acct-keys');
|
||||
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
|
||||
d.keys.forEach(function (k) {
|
||||
@@ -65,7 +80,7 @@
|
||||
'<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;
|
||||
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(); } });
|
||||
};
|
||||
@@ -102,6 +117,33 @@
|
||||
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; });
|
||||
};
|
||||
$('.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('');
|
||||
load().then(function () { dlg.showModal(); dlg.focus(); });
|
||||
|
||||
@@ -134,6 +134,22 @@ header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padd
|
||||
.acct-add button { flex: none; }
|
||||
.acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); }
|
||||
.acct-msg.bad { color: var(--danger); }
|
||||
.acct-pw { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0 0; padding-top: 12px;
|
||||
border-top: 1px solid var(--border); color: var(--text); font-size: 14px; cursor: pointer; }
|
||||
.acct-pw b { display: block; font-weight: 500; }
|
||||
.acct-pw small { display: block; font-size: 12px; margin-top: 2px; }
|
||||
/* A switch: a checkbox drawn as a sliding knob. */
|
||||
input.sw { -webkit-appearance: none; appearance: none; flex: none; width: 40px; height: 22px; margin: 0; border-radius: 11px;
|
||||
background: var(--border); position: relative; cursor: pointer; transition: background .2s; }
|
||||
input.sw::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; transition: transform .2s; }
|
||||
input.sw:checked { background: var(--accent); }
|
||||
input.sw:checked::after { transform: translateX(18px); }
|
||||
input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.acct-chpw { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.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-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; }
|
||||
|
||||
@@ -173,8 +173,9 @@
|
||||
const at = index == null ? tabs.length : index;
|
||||
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||
tabs.splice(at, 0, t);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||
renderTab(t);
|
||||
fitTabs();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
|
||||
return t;
|
||||
}
|
||||
function tabTitle(t) {
|
||||
@@ -182,8 +183,35 @@
|
||||
const a = t.panes[0].agent;
|
||||
return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name;
|
||||
}
|
||||
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||
// a maximum; when they do not fit, all give way evenly down to a floor,
|
||||
// titles cut with "…", and below the floor the strip scrolls instead.
|
||||
const ruler = document.createElement('canvas').getContext('2d');
|
||||
const W = touch ? { min: 76, base: 108, max: 170, font: 12 } : { min: 96, base: 150, max: 280, font: 13 };
|
||||
const CHROME = touch ? 52 : 62; // padding, dot, gaps, close button, borders
|
||||
function fitTabs() {
|
||||
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||
const live = tabs;
|
||||
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME)));
|
||||
const strip = $('tabs');
|
||||
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||
let cap = W.max;
|
||||
if (want.reduce((a, b) => a + b, 0) > room) {
|
||||
// The widest give way first, to one width for all at the most.
|
||||
let lo = W.min, hi = W.max;
|
||||
for (let k = 0; k < 20; k++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (want.reduce((a, b) => a + Math.min(b, mid), 0) > room) hi = mid; else lo = mid;
|
||||
}
|
||||
cap = Math.max(W.min, Math.floor(lo));
|
||||
}
|
||||
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
|
||||
}
|
||||
window.addEventListener('resize', () => fitTabs());
|
||||
|
||||
function renderTab(t) {
|
||||
t.el.querySelector('.tab-title').textContent = tabTitle(t);
|
||||
requestAnimationFrame(fitTabs);
|
||||
t.el.title = t.panes.map(s => s.agent.user + '@' + s.agent.host).join(', ');
|
||||
// The tab's dot: red if any shell failed, amber while one connects.
|
||||
const st = t.panes.map(s => s.state);
|
||||
|
||||
@@ -96,14 +96,14 @@
|
||||
{{range .Admins}}
|
||||
<tr>
|
||||
<td>{{.Username}}</td>
|
||||
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}</td>
|
||||
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{end}}{{if index $.PassOff .ID}} · пароль выключен{{end}}</td>
|
||||
<td style="text-align:right" class="nowrap">
|
||||
{{if index $.KeyCounts .ID}}
|
||||
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова станет возможен.')">
|
||||
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn ghost small" type="submit">Сбросить ключи</button>
|
||||
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:0 1 auto; min-width:0; max-width:220px; height:34px; padding:0 6px 0 12px;
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
@@ -103,7 +103,7 @@
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; max-width:140px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
|
||||
Reference in new issue
Block a user