feat: sign in with passkeys, no password once an account has one; an account button and dialog in place of the sign-out button

This commit is contained in:
redtea committed 2026-10-14 20:33:31 +03:00
1 parent d2d6fe0100
commit e7b5d28470
9 files changed
+892 -18

No files matched your search

+2 -3
View File
@@ -12,9 +12,7 @@
<nav>
<a href="/">Машины</a>
<a href="/settings">Настройки</a>
<form method="post" action="/logout" style="display:inline; margin-left:22px">
<button class="btn ghost small" type="submit">Выйти</button>
</form>
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav>
</header>
<div class="wrap">
@@ -100,5 +98,6 @@
if (Object.keys(rows).length) schedule();
})();
</script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>
+51 -5
View File
@@ -10,15 +10,61 @@
<div class="center">
<div class="card">
<h1>Вход в панель</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
{{if .Error}}<div class="err" id="err">{{.Error}}</div>{{else}}<div class="err" id="err" hidden></div>{{end}}
<button type="button" class="keybtn" id="keylogin" hidden><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>Войти с ключом доступа</button>
<div class="or" id="or" hidden>или по паролю</div>
<form method="post" action="/login">
<label>Имя пользователя</label>
<input type="text" name="username" autofocus required>
<label>Пароль</label>
<input type="password" name="password" required>
<label for="u">Имя пользователя</label>
<input type="text" id="u" name="username" value="{{.Username}}" autocomplete="username webauthn" autofocus required>
<label for="pw">Пароль</label>
<input type="password" id="pw" name="password" autocomplete="current-password" required>
<div style="margin-top:18px"><button type="submit">Войти</button></div>
</form>
</div>
</div>
<script>
// Sign in with a passkey: with the name typed, its keys are offered; without
// it, the device offers the keys it keeps for this site.
(function () {
if (!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext)) return;
var btn = document.getElementById('keylogin'), err = document.getElementById('err');
btn.hidden = false;
document.getElementById('or').hidden = false;
var enc = function (buf) {
var s = '', b = new Uint8Array(buf);
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
var dec = function (str) {
var s = atob(str.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((str.length + 3) % 4)), b = new Uint8Array(s.length);
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
return b.buffer;
};
function show(t) { err.textContent = t; err.hidden = !t; }
btn.onclick = function () {
btn.disabled = true;
show('');
fetch('/api/login/key/begin', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: document.getElementById('u').value})})
.then(function (r) { return r.json(); })
.then(function (r) {
if (!r.ok) throw new Error(r.error);
var o = r.options;
o.challenge = dec(o.challenge);
o.allowCredentials.forEach(function (c) { c.id = dec(c.id); });
return navigator.credentials.get({publicKey: o});
})
.then(function (c) {
return fetch('/api/login/key/finish', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: enc(c.rawId), clientDataJSON: enc(c.response.clientDataJSON),
authenticatorData: enc(c.response.authenticatorData), signature: enc(c.response.signature)})})
.then(function (r) { return r.json(); });
})
.then(function (r) { if (!r.ok) throw new Error(r.error); location.href = '/'; })
.catch(function (e) { show(e && e.name === 'NotAllowedError' ? 'Вход по ключу отменён.' : (e && e.message) || 'Не вышло.'); })
.then(function () { btn.disabled = false; });
};
})();
</script>
</body>
</html>
+12 -4
View File
@@ -20,9 +20,7 @@
<nav>
<a href="/">Машины</a>
<a href="/settings">Настройки</a>
<form method="post" action="/logout" style="display:inline; margin-left:22px">
<button class="btn ghost small" type="submit">Выйти</button>
</form>
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
</nav>
</header>
<div class="wrap">
@@ -98,7 +96,16 @@
{{range .Admins}}
<tr>
<td>{{.Username}}</td>
<td style="text-align:right">
<td class="muted">{{with index $.KeyCounts .ID}}ключей доступа: {{.}}{{else}}по паролю{{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}}? Вход по паролю снова станет возможен.')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn ghost small" type="submit">Сбросить ключи</button>
</form>
{{end}}
<form method="post" action="/settings/admins/delete" style="display:inline"
onsubmit="return confirm('Удалить администратора {{.Username}}?')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -210,5 +217,6 @@
});
})();
</script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>