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:
redtea committed 2026-10-14 20:46:35 +03:00
1 parent 67222e2e05
commit 6dd1120a41
9 files changed
+210 -23

No files matched your search

+35 -4
View File
@@ -117,6 +117,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN login_user TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE forward_rules ADD COLUMN no_ufw INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`, `ALTER TABLE forward_rules ADD COLUMN label TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
} { } {
if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") { if _, err := db.sql.Exec(alter); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err return err
@@ -246,8 +247,29 @@ func (db *DB) ListAdminKeys(adminID int64) ([]AdminKey, error) {
return out, rows.Err() return out, rows.Err()
} }
// CountAdminKeys is how many passkeys an admin has: with any, the password // PasswordOff tells an admin who turned the password off: then only a
// no longer signs them in. // passkey signs them in. It holds only while they have one.
func (db *DB) PasswordOff(adminID int64) bool {
var off int
_ = db.sql.QueryRow(`SELECT password_off FROM admins WHERE id = ?`, adminID).Scan(&off)
return off == 1 && db.CountAdminKeys(adminID) > 0
}
func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
return err
}
func (db *DB) SetPasswordOff(adminID int64, off bool) error {
v := 0
if off {
v = 1
}
_, err := db.sql.Exec(`UPDATE admins SET password_off = ? WHERE id = ?`, v, adminID)
return err
}
// CountAdminKeys is how many passkeys an admin has.
func (db *DB) CountAdminKeys(adminID int64) int { func (db *DB) CountAdminKeys(adminID int64) int {
var n int var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n) _ = db.sql.QueryRow(`SELECT COUNT(*) FROM admin_keys WHERE admin_id = ?`, adminID).Scan(&n)
@@ -271,15 +293,24 @@ func (db *DB) UseAdminKey(id int64, signCount uint32) error {
return err return err
} }
// DeleteAdminKey removes a passkey; with the last one gone, the password
// signs the admin in again.
func (db *DB) DeleteAdminKey(adminID, id int64) error { func (db *DB) DeleteAdminKey(adminID, id int64) error {
_, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id) if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ? AND id = ?`, adminID, id); err != nil {
return err return err
} }
if db.CountAdminKeys(adminID) == 0 {
return db.SetPasswordOff(adminID, false)
}
return nil
}
func (db *DB) DeleteAdminKeys(adminID int64) error { func (db *DB) DeleteAdminKeys(adminID int64) error {
_, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID) if _, err := db.sql.Exec(`DELETE FROM admin_keys WHERE admin_id = ?`, adminID); err != nil {
return err return err
} }
return db.SetPasswordOff(adminID, false)
}
// --- admin sessions --- // --- admin sessions ---
+5 -3
View File
@@ -90,8 +90,8 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username}) s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль.", "Username": username})
return return
} }
// With a passkey on the account, the password alone does not do. // An admin who turned the password off signs in with a passkey only.
if s.db.CountAdminKeys(admin.ID) > 0 { if s.db.PasswordOff(admin.ID) {
s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username}) s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username})
return return
} }
@@ -378,9 +378,10 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
admins, _ := s.db.ListAdmins() admins, _ := s.db.ListAdmins()
keyCounts := map[int64]int{} keyCounts, passOff := map[int64]int{}, map[int64]bool{}
for _, a := range admins { for _, a := range admins {
keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) keyCounts[a.ID] = s.db.CountAdminKeys(a.ID)
passOff[a.ID] = s.db.PasswordOff(a.ID)
} }
host := s.db.HostAddress() host := s.db.HostAddress()
secret := s.db.EnrollSecret() secret := s.db.EnrollSecret()
@@ -393,6 +394,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"Secret": secret, "Secret": secret,
"Admins": admins, "Admins": admins,
"KeyCounts": keyCounts, "KeyCounts": keyCounts,
"PassOff": passOff,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code), "InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
+69 -3
View File
@@ -26,8 +26,8 @@ import (
) )
// Passkeys (WebAuthn): an admin signs in with a key held by the device - a // Passkeys (WebAuthn): an admin signs in with a key held by the device - a
// fingerprint, the phone's lock, a hardware key - instead of a password. Once // fingerprint, the phone's lock, a hardware key - instead of a password. With
// an admin has a key, the password no longer signs them in. // a key in place, the admin can turn the password off in their account.
// //
// Attestation is not checked ("none"): the panel trusts any authenticator, // Attestation is not checked ("none"): the panel trusts any authenticator,
// it only needs the public key and, at sign-in, a signature made with it. // it only needs the public key and, at sign-in, a signature made with it.
@@ -353,7 +353,73 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
for _, k := range keys { for _, k := range keys {
list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed}) list = append(list, map[string]any{"id": k.ID, "name": k.Name, "created": k.CreatedAt, "last_used": k.LastUsed})
} }
writeJSON(w, map[string]any{"username": a.Username, "keys": list}) writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID)})
}
// POST /api/panel/account/password/change - a new password, given the
// current one.
func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
var in struct {
Current string `json:"current"`
New string `json:"new"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
ip := clientIP(r)
if s.limiter.blocked(ip) {
writeJSON(w, map[string]any{"ok": false, "error": "Слишком много попыток. Подождите немного."})
return
}
if !checkPassword(a.PassHash, in.Current) {
s.limiter.fin(ip, false)
writeJSON(w, map[string]any{"ok": false, "error": "текущий пароль не тот"})
return
}
if len(in.New) < 8 {
writeJSON(w, map[string]any{"ok": false, "error": "новый пароль - не короче 8 символов"})
return
}
hash, err := hashPassword(in.New)
if err == nil {
err = s.db.SetAdminPassword(a.ID, hash)
}
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
s.limiter.fin(ip, true)
s.db.Audit(a.ID, "", "password_change")
writeJSON(w, map[string]any{"ok": true})
}
// POST /api/panel/account/password {"off": bool} - turn signing in with the
// password off or on again. Off only with a passkey to sign in with.
func (s *Server) handlePasswordToggle(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
var in struct {
Off bool `json:"off"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
if in.Off && s.db.CountAdminKeys(a.ID) == 0 {
writeJSON(w, map[string]any{"ok": false, "error": "сначала добавьте ключ доступа"})
return
}
if err := s.db.SetPasswordOff(a.ID, in.Off); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
action := "password_on"
if in.Off {
action = "password_off"
}
s.db.Audit(a.ID, "", action)
writeJSON(w, map[string]any{"ok": true})
} }
// POST /api/panel/account/keys/begin - options to make a new passkey with. // POST /api/panel/account/keys/begin - options to make a new passkey with.
+2
View File
@@ -237,6 +237,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
mux.Handle("POST /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle))
mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd))
mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke))
+47 -5
View File
@@ -1,6 +1,6 @@
// The account button in the top bar: a dialog with who is signed in, their // 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, // passkeys (add one, remove one), the password switch and signing out. The
// the password no longer signs this account in. // password can be turned off once there is a passkey to sign in with.
(function () { (function () {
'use strict'; 'use strict';
var btn = document.getElementById('acct'); var btn = document.getElementById('acct');
@@ -34,11 +34,20 @@
'<div class="dlg-b acct">' + '<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>' + ' <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>' + ' <h3>Ключи доступа</h3>' +
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Пока у аккаунта есть хоть один ключ, войти по паролю нельзя - держите второй ключ или другого администратора, который сможет их сбросить.</p>' + ' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
' <ul class="acct-keys"></ul>' + ' <ul class="acct-keys"></ul>' +
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' + ' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' + ' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
' <p class="acct-msg"></p>' + ' <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>' + ' <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);
@@ -54,8 +63,14 @@
function load() { function load() {
return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { return fetch('/api/panel/account', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) {
$('.acct-name').textContent = d.username; $('.acct-name').textContent = d.username;
dlg.querySelector('.acct-pwform [name=username]').value = d.username;
$('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase(); $('.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'); var ul = $('.acct-keys');
ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>'; ul.innerHTML = d.keys.length ? '' : '<li class="muted">Ключей пока нет.</li>';
d.keys.forEach(function (k) { d.keys.forEach(function (k) {
@@ -65,7 +80,7 @@
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>'; '<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
li.querySelector('button').onclick = function () { li.querySelector('button').onclick = function () {
var last = d.keys.length === 1; 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(); }) 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(); } }); .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); msg(e && e.name === 'NotAllowedError' ? 'отменено' : e && e.name === 'InvalidStateError' ? 'этот ключ уже добавлен' : 'не вышло: ' + (e && e.message || e), true);
}).then(function () { add.disabled = false; }); }).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 () { btn.onclick = function () {
msg(''); msg('');
load().then(function () { dlg.showModal(); dlg.focus(); }); load().then(function () { dlg.showModal(); dlg.focus(); });
+16
View File
@@ -134,6 +134,22 @@ header.topbar nav .acct-btn { margin-left: 22px; width: 34px; height: 34px; padd
.acct-add button { flex: none; } .acct-add button { flex: none; }
.acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); } .acct-msg { min-height: 20px; margin: 8px 0 0; font-size: 13px; color: var(--ok); }
.acct-msg.bad { color: var(--danger); } .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; } .acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; }
/* Sign-in page. */ /* Sign-in page. */
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; } .or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
+29 -1
View File
@@ -173,8 +173,9 @@
const at = index == null ? tabs.length : index; const at = index == null ? tabs.length : index;
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark); tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
tabs.splice(at, 0, t); tabs.splice(at, 0, t);
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
renderTab(t); renderTab(t);
fitTabs();
requestAnimationFrame(() => requestAnimationFrame(() => t.el.classList.remove('enter')));
return t; return t;
} }
function tabTitle(t) { function tabTitle(t) {
@@ -182,8 +183,35 @@
const a = t.panes[0].agent; const a = t.panes[0].agent;
return t.panes.length > 1 ? a.name + ' +' + (t.panes.length - 1) : a.name; 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) { function renderTab(t) {
t.el.querySelector('.tab-title').textContent = tabTitle(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(', '); 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. // The tab's dot: red if any shell failed, amber while one connects.
const st = t.panes.map(s => s.state); const st = t.panes.map(s => s.state);
+3 -3
View File
@@ -96,14 +96,14 @@
{{range .Admins}} {{range .Admins}}
<tr> <tr>
<td>{{.Username}}</td> <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"> <td style="text-align:right" class="nowrap">
{{if index $.KeyCounts .ID}} {{if index $.KeyCounts .ID}}
<form method="post" action="/settings/admins/keys/reset" style="display:inline" <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="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <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> </form>
{{end}} {{end}}
<form method="post" action="/settings/admins/delete" style="display:inline" <form method="post" action="/settings/admins/delete" style="display:inline"
+4 -4
View File
@@ -18,15 +18,15 @@
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; } 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 { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
.tablist::-webkit-scrollbar { display: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); 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; 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:hover { color:var(--text); background:#151a21; }
.tab.on { background:var(--panel); color:var(--text); } .tab.on { background:var(--panel); color:var(--text); }
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; } .tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
.tab.dragging { opacity:.3; } .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-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; .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; } 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.multi .area { margin-top:0; }
body.touch .tabs { padding-left:6px; padding-right:6px; } body.touch .tabs { padding-left:6px; padding-right:6px; }
body.touch.multi .tabs { padding-top:6px; max-height:38px; } 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 .tab-add { width:30px; height:28px; }
body.touch .tabview { inset:4px; } body.touch .tabview { inset:4px; }
body.touch .keys { body.touch .keys {