feat: optional 4-digit PIN asked in every new tab (not on reload), on-screen keypad for phones; set, change or remove it in the account; five misses sign out

This commit is contained in:
redtea committed 2026-10-14 21:12:09 +03:00
1 parent 29353e5e6a
commit 119b524825
14 files changed
+316 -1

No files matched your search

+13
View File
@@ -119,6 +119,7 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`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`, `ALTER TABLE admins ADD COLUMN password_off INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`, `ALTER TABLE agents ADD COLUMN transport TEXT NOT NULL DEFAULT 'auto'`,
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
} { } {
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
@@ -256,6 +257,18 @@ func (db *DB) PasswordOff(adminID int64) bool {
return off == 1 && db.CountAdminKeys(adminID) > 0 return off == 1 && db.CountAdminKeys(adminID) > 0
} }
// AdminPin is the hash of the admin's PIN, "" when there is none.
func (db *DB) AdminPin(adminID int64) string {
var h string
_ = db.sql.QueryRow(`SELECT pin_hash FROM admins WHERE id = ?`, adminID).Scan(&h)
return h
}
func (db *DB) SetAdminPin(adminID int64, pinHash string) error {
_, err := db.sql.Exec(`UPDATE admins SET pin_hash = ? WHERE id = ?`, pinHash, adminID)
return err
}
func (db *DB) SetAdminPassword(adminID int64, passHash string) error { func (db *DB) SetAdminPassword(adminID int64, passHash string) error {
_, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID) _, err := db.sql.Exec(`UPDATE admins SET pass_hash = ? WHERE id = ?`, passHash, adminID)
return err return err
+1 -1
View File
@@ -353,7 +353,7 @@ 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, "password_off": s.db.PasswordOff(a.ID)}) writeJSON(w, map[string]any{"username": a.Username, "keys": list, "password_off": s.db.PasswordOff(a.ID), "pin": s.db.AdminPin(a.ID) != ""})
} }
// POST /api/panel/account/password/change - a new password, given the // POST /api/panel/account/password/change - a new password, given the
+108
View File
@@ -0,0 +1,108 @@
package server
import (
"encoding/json"
"net/http"
"regexp"
"sync"
)
// The PIN: four digits an admin can set, asked for by the page in every new
// browser tab (a reload of the same tab does not ask again). The server
// checks it; five wrong ones in a row end the session.
var pinRe = regexp.MustCompile(`^[0-9]{4}$`)
// pinMisses counts wrong PINs per session.
type pinMisses struct {
mu sync.Mutex
m map[string]int
}
func (p *pinMisses) miss(sid string) int {
p.mu.Lock()
defer p.mu.Unlock()
if p.m == nil {
p.m = map[string]int{}
}
p.m[sid]++
return p.m[sid]
}
func (p *pinMisses) clear(sid string) {
p.mu.Lock()
delete(p.m, sid)
p.mu.Unlock()
}
// GET /api/panel/pin - whether the admin has a PIN to ask for.
func (s *Server) handlePinState(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, map[string]any{"pin": s.db.AdminPin(a.ID) != ""})
}
// POST /api/panel/pin/check {"pin"} - the PIN typed in a new tab.
func (s *Server) handlePinCheck(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
var in struct {
Pin string `json:"pin"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in)
h := s.db.AdminPin(a.ID)
c, _ := r.Cookie(sessionCookie)
if h == "" || (pinRe.MatchString(in.Pin) && checkPassword(h, in.Pin)) {
s.pinMisses.clear(c.Value)
writeJSON(w, map[string]any{"ok": true})
return
}
if n := s.pinMisses.miss(c.Value); n >= 5 {
s.pinMisses.clear(c.Value)
s.clearSession(w, r)
s.db.Audit(a.ID, "", "pin_lockout")
writeJSON(w, map[string]any{"ok": false, "out": true, "error": "слишком много неверных попыток"})
return
} else {
writeJSON(w, map[string]any{"ok": false, "left": 5 - n, "error": "неверный PIN"})
}
}
// POST /api/panel/account/pin {"pin", "current"} - set or change the PIN;
// with pin empty, remove it. A PIN in place is needed to change or drop it.
func (s *Server) handlePinSet(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
var in struct {
Pin string `json:"pin"`
Current string `json:"current"`
}
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 h := s.db.AdminPin(a.ID); h != "" && !checkPassword(h, in.Current) {
writeJSON(w, map[string]any{"ok": false, "error": "текущий PIN не тот"})
return
}
hash := ""
if in.Pin != "" {
if !pinRe.MatchString(in.Pin) {
writeJSON(w, map[string]any{"ok": false, "error": "PIN - ровно 4 цифры"})
return
}
var err error
if hash, err = hashPassword(in.Pin); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
}
if err := s.db.SetAdminPin(a.ID, hash); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
action := "pin_set"
if hash == "" {
action = "pin_remove"
}
s.db.Audit(a.ID, "", action)
writeJSON(w, map[string]any{"ok": true})
}
+4
View File
@@ -61,6 +61,7 @@ type Server struct {
xfers xfers // files on their way between a browser and an agent xfers xfers // files on their way between a browser and an agent
challenges challenges // passkey challenges handed out challenges challenges // passkey challenges handed out
wsAgents wsAgents // agents on their WebSocket now wsAgents wsAgents // agents on their WebSocket now
pinMisses pinMisses // wrong PINs per session
assets map[string]string // static file -> hash of its content assets map[string]string // static file -> hash of its content
} }
@@ -241,6 +242,9 @@ func (s *Server) Handler() http.Handler {
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", s.requireAdmin(s.handlePasswordToggle))
mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange)) 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))
+42
View File
@@ -48,6 +48,13 @@
' <input type="password" name="new" placeholder="новый пароль, от 8 символов" autocomplete="new-password" minlength="8" required>' + ' <input type="password" name="new" placeholder="новый пароль, от 8 символов" autocomplete="new-password" minlength="8" required>' +
' <input type="password" name="again" placeholder="новый ещё раз" autocomplete="new-password" required>' + ' <input type="password" name="again" placeholder="новый ещё раз" autocomplete="new-password" required>' +
' <button type="submit">Сменить</button></form></details>' + ' <button type="submit">Сменить</button></form></details>' +
' <details class="acct-pin"><summary>PIN-код <small class="acct-pinstate"></small></summary>' +
' <form class="acct-pinform">' +
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
' <div class="row"><button type="button" class="btn danger small acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' </form><p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></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);
@@ -64,6 +71,11 @@
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; 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-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase(); $('.acct-ava').textContent = (d.username || '?').slice(0, 1).toUpperCase();
$('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю'; $('.acct-mode').textContent = d.password_off ? 'вход только по ключу доступа' : d.keys.length ? 'вход по паролю или ключу' : 'вход по паролю';
const sw = $('.acct-pw .sw'); const sw = $('.acct-pw .sw');
@@ -134,6 +146,35 @@
.catch(function () { msg('ошибка сети', true); }) .catch(function () { msg('ошибка сети', true); })
.then(function () { f.querySelector('button').disabled = false; }); .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-pindel').onclick = function () {
if (confirm('Убрать PIN-код?')) savePin('');
};
$('.acct-pw .sw').onchange = function (ev) { $('.acct-pw .sw').onchange = function (ev) {
const off = !ev.target.checked; const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
@@ -146,6 +187,7 @@
}; };
btn.onclick = function () { btn.onclick = function () {
msg(''); msg('');
pinMsg('');
load().then(function () { dlg.showModal(); dlg.focus(); }); load().then(function () { dlg.showModal(); dlg.focus(); });
}; };
})(); })();
+41
View File
@@ -107,6 +107,39 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; } .spacer { flex: 1; }
#terminal { width: 100%; height: calc(100vh - 150px); } #terminal { width: 100%; height: calc(100vh - 150px); }
/* The PIN lock of a new tab: the page stays hidden until it is unlocked. */
html.pin-wait body > :not(.pinlock) { visibility: hidden; }
.pinlock { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
background: rgba(10, 13, 17, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
opacity: 0; transition: opacity .3s ease; }
.pinlock.on { opacity: 1; }
.pinlock.done { opacity: 0; pointer-events: none; }
.pin-card { width: 100%; max-width: 300px; display: flex; flex-direction: column; align-items: center; text-align: center;
transform: translateY(8px) scale(.98); transition: transform .3s ease; }
.pinlock.on .pin-card { transform: none; }
.pinlock.done .pin-card { transform: scale(1.03); }
.pin-ico { width: 54px; height: 54px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
background: #12202f; border: 1px solid var(--border); color: var(--accent); }
.pin-title { margin: 14px 0 18px; font-size: 18px; font-weight: 600; }
.pin-dots { display: flex; gap: 16px; }
.pin-dots i { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--muted); transition: background .15s, border-color .15s, transform .15s; }
.pin-dots i.on { background: var(--accent); border-color: var(--accent); transform: scale(1.1); }
.pin-card.bad .pin-dots i.on { background: var(--danger); border-color: var(--danger); }
.pin-card.bad .pin-dots { animation: pin-shake .38s ease; }
@keyframes pin-shake { 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } }
.pin-msg { min-height: 20px; margin: 14px 0 6px; font-size: 13px; color: var(--danger); }
.pin-pad { display: grid; grid-template-columns: repeat(3, 72px); gap: 14px; justify-content: center; }
.pin-pad button { width: 72px; height: 72px; border-radius: 50%; padding: 0; display: inline-flex; align-items: center; justify-content: center;
background: #151a21; border: 1px solid var(--border); color: var(--text); font-size: 26px; font-weight: 400;
-webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background .12s, transform .08s; }
.pin-pad button:hover { background: #1b222c; }
.pin-pad button:active { background: #12202f; transform: scale(.94); }
.pin-pad button[data-k="back"] { background: transparent; border-color: transparent; color: var(--muted); }
.pin-out { margin-top: 18px; }
.pin-out button { background: transparent; color: var(--muted); padding: 6px 12px; font-size: 14px; }
.pin-out button:hover { background: transparent; color: var(--text); }
@media (max-height: 560px) { .pin-pad { grid-template-columns: repeat(3, 58px); gap: 10px; } .pin-pad button { width: 58px; height: 58px; font-size: 22px; } }
/* Dialogs shared by the pages. */ /* Dialogs shared by the pages. */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; } border-radius: 12px; padding: 0; max-width: 900px; width: 92vw; }
@@ -150,6 +183,14 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
.acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); } .acct-chpw summary { cursor: pointer; font-size: 14px; color: var(--text); }
.acct-pwform { display: grid; gap: 8px; margin-top: 10px; } .acct-pwform { display: grid; gap: 8px; margin-top: 10px; }
.acct-pwform button { justify-self: end; } .acct-pwform button { justify-self: end; }
.acct-pin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.acct-pin summary { cursor: pointer; font-size: 14px; color: var(--text); }
.acct-pin small { color: var(--muted); font-size: 12px; }
.acct-pinform { display: grid; gap: 8px; margin-top: 10px; }
.acct-pinform input:not(:placeholder-shown) { letter-spacing: .4em; font-family: ui-monospace, Menlo, monospace; }
.acct-pinform .row { justify-content: flex-end; }
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
.acct-pinmsg.bad { color: var(--danger); }
.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; }
+100
View File
@@ -0,0 +1,100 @@
// The PIN lock: in a new browser tab the panel asks for the admin's PIN
// before showing anything. The tab remembers it was unlocked (sessionStorage
// lives as long as the tab, through reloads, and a new tab starts empty), so
// a reload does not ask again. Loaded in <head>, before the page shows.
(function () {
'use strict';
var KEY = 'lpra.pin.ok';
var ok = false;
try { ok = sessionStorage.getItem(KEY) === '1'; } catch (e) { /* no storage: ask */ }
if (ok) return;
var root = document.documentElement;
root.classList.add('pin-wait');
function release() { root.classList.remove('pin-wait'); }
function remember() { try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* asked again next load */ } }
fetch('/api/panel/pin', {cache: 'no-store'})
.then(function (r) { return r.ok ? r.json() : {pin: false}; })
.then(function (d) {
if (!d.pin) { remember(); release(); return; }
if (document.body) lock(); else document.addEventListener('DOMContentLoaded', lock);
})
.catch(release);
function lock() {
var LOCK = '<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
var BACK = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H9l-6 7 6 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z"/><path d="m16 9-5 6M11 9l5 6"/></svg>';
var box = document.createElement('div');
box.className = 'pinlock';
box.setAttribute('role', 'dialog');
box.setAttribute('aria-modal', 'true');
box.setAttribute('aria-label', 'Ввод PIN-кода');
var keys = '';
['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].forEach(function (k) {
if (!k) { keys += '<span></span>'; return; }
keys += '<button type="button" data-k="' + k + '"' + (k === 'back' ? ' aria-label="Стереть">' + BACK : '>' + k) + '</button>';
});
box.innerHTML =
'<div class="pin-card">' +
'<div class="pin-ico">' + LOCK + '</div>' +
'<div class="pin-title">Введите PIN-код</div>' +
'<div class="pin-dots"><i></i><i></i><i></i><i></i></div>' +
'<div class="pin-msg" aria-live="polite"></div>' +
'<div class="pin-pad">' + keys + '</div>' +
'<form method="post" action="/logout" class="pin-out"><button type="submit">Выйти</button></form>' +
'</div>';
document.body.appendChild(box);
requestAnimationFrame(function () { box.classList.add('on'); });
var dots = box.querySelectorAll('.pin-dots i'), msg = box.querySelector('.pin-msg'), card = box.querySelector('.pin-card');
var code = '', busy = false;
function show() {
for (var i = 0; i < 4; i++) dots[i].classList.toggle('on', i < code.length);
}
function press(k) {
if (busy) return;
if (k === 'back') { code = code.slice(0, -1); show(); return; }
if (code.length >= 4) return;
code += k;
msg.textContent = '';
card.classList.remove('bad');
show();
if (code.length === 4) check();
}
function check() {
busy = true;
fetch('/api/panel/pin/check', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({pin: code})})
.then(function (r) { return r.json(); })
.then(function (r) {
if (r.ok) {
remember();
box.classList.add('done');
release();
setTimeout(function () { box.remove(); }, 320);
document.removeEventListener('keydown', onKey, true);
return;
}
if (r.out) { location.href = '/login'; return; }
card.classList.remove('bad');
void card.offsetWidth; // restart the shake
card.classList.add('bad');
msg.textContent = r.error + (r.left ? ' - осталось попыток: ' + r.left : '');
setTimeout(function () { code = ''; show(); busy = false; }, 380);
})
.catch(function () { msg.textContent = 'нет связи с сервером'; code = ''; show(); busy = false; });
}
box.querySelector('.pin-pad').addEventListener('click', function (ev) {
var b = ev.target.closest('button');
if (b) press(b.dataset.k);
});
// Typed digits, ahead of anything on the page (the terminal included).
function onKey(ev) {
if (/^[0-9]$/.test(ev.key)) press(ev.key);
else if (ev.key === 'Backspace') press('back');
else if (ev.key !== 'Tab') return;
ev.preventDefault();
ev.stopPropagation();
}
document.addEventListener('keydown', onKey, true);
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
}
})();
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title> <title>Машины - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title> <title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Файлы · {{.Agent.Name}} - LPRA</title> <title>Файлы · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}"> <link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
<style> <style>
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; } .fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title> <title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style> <style>
/* One line: fixed narrow fields, the label takes what is left. */ /* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; } .frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title> <title>Настройки - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style> <style>
/* An install command, with a button to copy it at its right. */ /* An install command, with a button to copy it at its right. */
.copybox { position:relative; } .copybox { position:relative; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title> <title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<style> <style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; } .meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; } .meters .card { margin:0; }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} - LPRA</title> <title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}"> <link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<link rel="stylesheet" href="{{asset "xterm.css"}}"> <link rel="stylesheet" href="{{asset "xterm.css"}}">
<style> <style>
#status.st-ok { color:#37c871; } #status.st-ok { color:#37c871; }