Login screen and api endpoint reorganization
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
1
resources/html-templates/login.html.min
Normal file
1
resources/html-templates/login.html.min
Normal file
File diff suppressed because one or more lines are too long
56
resources/scripts/minify_html.sh
Executable file
56
resources/scripts/minify_html.sh
Executable 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)"
|
||||
Reference in New Issue
Block a user