Login screen and api endpoint reorganization

This commit is contained in:
Maurus Decimus
2026-04-05 19:57:23 +02:00
parent eecec0aa31
commit fde37b670f
29 changed files with 1005 additions and 1606 deletions

View File

@@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>Sign in</title>
<style>
*,
@@ -23,6 +24,7 @@
--accent: #1a1a18;
--accent-fg: #fff;
--info: #185fa5;
--success: #0f7a3c;
--radius: 10px;
--input-bg: #fafaf9;
}
@@ -37,6 +39,7 @@
--accent: #f4f4f2;
--accent-fg: #18181b;
--info: #5faee3;
--success: #58c98a;
--input-bg: #2a2a2e;
}
}
@@ -61,19 +64,37 @@
max-width: 384px;
}
.logo {
.logo-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 72px;
margin: 0.25rem auto 0.5rem;
}
.logo-wrap svg.default-logo {
display: block;
width: calc(100% + 1rem);
height: auto;
margin: -0.25rem auto 0.1rem;
max-width: 320px
max-width: 320px;
}
.logo .wordmark {
.logo-wrap img.custom-logo {
display: block;
max-width: 220px;
max-height: 96px;
width: auto;
height: auto;
object-fit: contain;
}
.default-logo .wordmark {
fill: var(--text)
}
.logo .symbol {
.default-logo .symbol {
fill: #db2d54
}
@@ -100,13 +121,23 @@
border-radius: 8px;
font-size: .8125rem;
color: #a0192f;
margin-bottom: .25rem;
margin-bottom: .875rem;
}
.alert.success {
background: color-mix(in srgb, #0f7a3c 10%, transparent);
border-color: color-mix(in srgb, #0f7a3c 30%, transparent);
color: #0f7a3c;
}
@media(prefers-color-scheme:dark) {
.alert {
color: #f4839a
}
.alert.success {
color: #58c98a;
}
}
.alert svg {
@@ -131,16 +162,6 @@
margin-bottom: .3125rem;
}
.field label a {
font-weight: 400;
color: var(--info);
text-decoration: none
}
.field label a:hover {
text-decoration: underline
}
input[type=text],
input[type=password],
input[type=email] {
@@ -178,26 +199,12 @@
font-variant-numeric: tabular-nums
}
.remember {
display: flex;
align-items: center;
gap: .5rem;
padding: .125rem 0
}
.remember input[type=checkbox] {
width: 1rem;
height: 1rem;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--accent);
}
.remember label {
font-size: .8125rem;
color: var(--muted);
cursor: pointer;
user-select: none
#device-code {
letter-spacing: .15em;
font-size: 1.0625rem;
text-align: center;
text-transform: uppercase;
font-variant-numeric: tabular-nums;
}
.btn {
@@ -224,59 +231,351 @@
opacity: .75;
transform: scale(.99)
}
.btn[disabled] {
opacity: .55;
cursor: not-allowed;
transform: none;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<div class="card">
<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680.5 252.1" aria-label="Logo">
<path class="wordmark"
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z" />
<path class="symbol"
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z" />
</svg>
<h1>Sign in</h1>
<p class="sub">Enter your credentials to continue</p>
<div class="alert" id="login-error" hidden aria-live="polite">
<div class="logo-wrap" id="logo-wrap">
<svg class="default-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680.5 252.1" aria-label="Logo">
<path class="wordmark"
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z" />
<path class="symbol"
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z" />
</svg>
</div>
<h1 id="title">Sign in</h1>
<p class="sub" id="subtitle">Enter your credentials to continue</p>
<div class="alert" id="alert" hidden aria-live="polite">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span id="login-error-msg">Invalid username or password. Please try again.</span>
<span id="alert-msg"></span>
</div>
<form class="fields" method="post" action="/oauth/login">
<div class="field">
<form class="fields" id="login-form" novalidate>
<div class="field" id="field-username">
<label for="username">Username</label>
<input id="username" name="username" type="text" placeholder="you@example.com" autocomplete="username"
required>
autocapitalize="none" autocorrect="off" spellcheck="false" required>
</div>
<div class="field">
<label for="password">
Password
<a href="/forgot">Forgot password?</a>
</label>
<div class="field" id="field-password">
<label for="password">Password</label>
<input id="password" name="password" type="password" placeholder="••••••••"
autocomplete="current-password" required>
</div>
<div class="field">
<label for="otp">One-time code <span style="font-weight:400;opacity:.6">(optional)</span></label>
<div class="field" id="field-device-code" hidden>
<label for="device-code">Device code</label>
<input id="device-code" name="device-code" type="text" placeholder="XXXX-XXXX" autocomplete="off"
autocapitalize="characters" spellcheck="false">
</div>
<div class="field" id="field-otp" hidden>
<label for="otp">One-time code</label>
<input id="otp" name="otp" type="text" placeholder="000000" maxlength="6" inputmode="numeric"
pattern="\d{6}" autocomplete="one-time-code">
</div>
<div class="remember">
<input type="checkbox" id="remember" name="remember" value="1">
<label for="remember">Remember me for 30 days</label>
</div>
<button type="submit" class="btn">Sign in</button>
<button type="submit" class="btn" id="submit-btn">Sign in</button>
</form>
</div>
<script>
document.getElementById('otp').addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '');
if (this.value.length === 6) document.querySelector('.btn').focus();
});
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
function setText(el, text) {
// textContent is XSS-safe: never parses HTML.
el.textContent = text == null ? '' : String(text);
}
function show(el) { if (el) el.hidden = false; }
function hide(el) { if (el) el.hidden = true; }
function showError(msg) {
var a = $('alert');
a.classList.remove('success');
setText($('alert-msg'), msg);
show(a);
}
function showSuccess(msg) {
var a = $('alert');
a.classList.add('success');
setText($('alert-msg'), msg);
show(a);
}
function clearAlert() { hide($('alert')); }
var url = new URL(window.location.href);
var params = url.searchParams;
var isDevice = /\/device(\/|$)/.test(url.pathname);
// Collected OAuth params for the code flow
var oauth = {
client_id: params.get('client_id') || '',
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope'),
state: params.get('state'),
nonce: params.get('nonce'),
code_challenge: params.get('code_challenge'),
code_challenge_method: params.get('code_challenge_method')
};
// Prefill username from login_hint
var loginHint = params.get('login_hint');
if (loginHint) $('username').value = loginHint;
// Device-flow specific UI
if (isDevice) {
setText($('title'), 'Authorize device');
setText($('subtitle'), 'Sign in to approve this device.');
show($('field-device-code'));
var prefillCode = params.get('code');
if (prefillCode) $('device-code').value = prefillCode;
}
var inMfa = false;
var lastCreds = null; // { account_name, account_secret } cached to resubmit with OTP
function enterMfaMode() {
inMfa = true;
hide($('field-username'));
hide($('field-password'));
if (isDevice) hide($('field-device-code'));
show($('field-otp'));
setText($('title'), 'Two-factor authentication');
setText($('subtitle'), 'Enter the 6-digit code from your authenticator app.');
clearAlert();
setTimeout(function () { $('otp').focus(); }, 0);
}
(function loadCustomLogo() {
// Try to replace the embedded SVG with /logo if the server serves an image.
fetch('/logo', { method: 'GET', credentials: 'same-origin', cache: 'no-cache' })
.then(function (res) {
if (!res.ok) return null;
var ct = (res.headers.get('content-type') || '').toLowerCase();
if (ct.indexOf('image/') !== 0) return null;
return res.blob();
})
.then(function (blob) {
if (!blob) return;
var objectUrl = URL.createObjectURL(blob);
var img = new Image();
img.className = 'custom-logo';
img.alt = 'Logo';
img.onload = function () {
var wrap = $('logo-wrap');
// Replace default logo in place.
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
wrap.appendChild(img);
};
img.onerror = function () {
URL.revokeObjectURL(objectUrl);
};
img.src = objectUrl;
})
.catch(function (err) {
// Keep embedded logo on any failure.
console.log('Custom logo unavailable:', err);
});
})();
function buildRequest(creds, otpValue) {
if (isDevice) {
var req = {
type: 'authDevice',
account_name: creds.account_name,
account_secret: creds.account_secret,
code: ($('device-code').value || '').trim()
};
if (otpValue) req.mfaToken = otpValue;
return req;
}
var r = {
type: 'authCode',
account_name: creds.account_name,
account_secret: creds.account_secret,
client_id: oauth.client_id || ''
};
if (oauth.redirect_uri) r.redirectUri = oauth.redirect_uri;
if (oauth.scope) r.scope = oauth.scope;
if (oauth.state) r.state = oauth.state;
if (oauth.nonce) r.nonce = oauth.nonce;
if (oauth.code_challenge) r.codeChallenge = oauth.code_challenge;
if (oauth.code_challenge_method) r.codeChallengeMethod = oauth.code_challenge_method;
if (otpValue) r.mfaToken = otpValue;
return r;
}
function performRedirect(clientCode) {
// Build redirect_uri?code=<client_code>&state=<state>
var target;
try {
if (!oauth.redirect_uri) throw new Error('missing redirect_uri');
target = new URL(oauth.redirect_uri);
} catch (e) {
console.log('Invalid or missing redirect_uri:', oauth.redirect_uri, e);
showError('Temporary server failure. If the problem persists, contact your administrator.');
return;
}
target.searchParams.set('code', clientCode);
if (oauth.state) target.searchParams.set('state', oauth.state);
window.location.assign(target.toString());
}
function handleResponse(resp) {
if (!resp || typeof resp !== 'object' || typeof resp.type !== 'string') {
console.log('Malformed login response:', resp);
showError('Temporary server failure. If the problem persists, contact your administrator.');
return;
}
switch (resp.type) {
case 'authenticated':
if (typeof resp.client_code !== 'string') {
console.log('Missing client_code in response:', resp);
showError('Temporary server failure. If the problem persists, contact your administrator.');
return;
}
performRedirect(resp.client_code);
return;
case 'verified':
hide($('login-form'));
setText($('title'), 'Device authorized');
setText($('subtitle'), 'You have successfully authorized this device. You may now close this window.');
clearAlert();
showSuccess('Device verified.');
return;
case 'mfaRequired':
enterMfaMode();
return;
case 'failure':
// Reset MFA state on failure so the user can retry credentials.
if (inMfa) {
inMfa = false;
show($('field-username'));
show($('field-password'));
if (isDevice) show($('field-device-code'));
hide($('field-otp'));
$('otp').value = '';
setText($('title'), isDevice ? 'Authorize device' : 'Sign in');
setText($('subtitle'), isDevice
? 'Sign in to approve this device.'
: 'Enter your credentials to continue');
}
showError('Invalid username or password. Please try again.');
return;
default:
console.log('Unknown login response type:', resp);
showError('Temporary server failure. If the problem persists, contact your administrator.');
}
}
function submitLogin(e) {
if (e) e.preventDefault();
clearAlert();
var creds;
if (inMfa) {
var otp = ($('otp').value || '').trim();
if (!otp) { showError('Please enter your one-time code.'); return; }
if (!lastCreds) {
// Safety net: shouldn't happen.
inMfa = false;
show($('field-username'));
show($('field-password'));
hide($('field-otp'));
showError('Session expired. Please sign in again.');
return;
}
creds = lastCreds;
var body = buildRequest(creds, otp);
postLogin(body);
return;
}
var username = ($('username').value || '').trim();
var password = $('password').value || '';
if (!username || !password) {
showError('Please enter your username and password.');
return;
}
if (isDevice) {
var devCode = ($('device-code').value || '').trim();
if (!devCode) {
showError('Please enter the device code.');
return;
}
}
creds = { account_name: username, account_secret: password };
lastCreds = creds;
postLogin(buildRequest(creds, null));
}
function postLogin(body) {
var btn = $('submit-btn');
btn.disabled = true;
fetch('/api/auth', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(body)
}).then(function (res) {
if (res.status !== 200) {
return res.text().then(function (txt) {
console.log('Login endpoint returned HTTP', res.status, txt);
throw new Error('http ' + res.status);
});
}
return res.json().catch(function (err) {
console.log('Failed to parse login response JSON:', err);
throw err;
});
}).then(function (json) {
handleResponse(json);
}).catch(function (err) {
console.log('Login request failed:', err);
showError('Temporary server failure. If the problem persists, contact your administrator.');
}).then(function () {
btn.disabled = false;
});
}
$('login-form').addEventListener('submit', submitLogin);
// Digit-only filter on OTP and auto-submit on 6 digits.
$('otp').addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '');
if (this.value.length === 6) submitLogin();
});
// Focus the first visible empty field.
setTimeout(function () {
if (!$('username').value) { $('username').focus(); return; }
if (!$('password').value) { $('password').focus(); return; }
if (isDevice && !$('device-code').value) { $('device-code').focus(); return; }
}, 0);
})();
</script>
</body>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
#
# Minify a self-contained HTML file (with inlined <style> and <script>).
#
# Uses html-minifier-terser via `npx`, which runs it from npx's cache without
# touching the repo's package.json / node_modules. The first invocation will
# download the package; subsequent runs are instant.
#
# Usage:
# resources/scripts/minify_html.sh path/to/file.html
#
# Writes `path/to/file.min.html` next to the source.
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $(basename "$0") <file.html>" >&2
exit 1
fi
src="$1"
if [[ ! -f "$src" ]]; then
echo "error: not a file: $src" >&2
exit 1
fi
if ! command -v npx >/dev/null 2>&1; then
echo "error: npx not found in PATH (install Node.js)" >&2
exit 1
fi
# login.html -> login.min.html
dir=$(dirname -- "$src")
base=$(basename -- "$src")
stem="${base%.*}"
ext="${base##*.}"
dst="$dir/$stem.$ext.min"
npx -y html-minifier-terser@latest \
--collapse-whitespace \
--conservative-collapse \
--remove-comments \
--minify-css true \
--minify-js true \
--decode-entities \
-o "$dst" \
"$src"
before=$(wc -c < "$src" | tr -d ' ')
after=$(wc -c < "$dst" | tr -d ' ')
saved=$((before - after))
pct=$(awk "BEGIN { printf \"%.1f\", ($saved / $before) * 100 }")
echo "$src -> $dst"
echo " $before B -> $after B ($saved B, $pct% smaller)"