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
+37
-6
@@ -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,14 +293,23 @@ 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 ---
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user