The "Cod incorect." message overlapped the OTP boxes and lingered while the operator re-entered a code. - Placement: the -52px (and -24px/-32px responsive) negative margins were tuned for an older layout and pulled the error up over the boxes. Replace with a small -8px so it sits just below the row. - Decay: clear #login-error as soon as the operator edits the code again (input, paste, backspace), so a stale error does not stay on screen. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1197,6 +1197,26 @@ sub build_title {
|
||
| 1197 | 1197 |
return $stamp ? "$label deployed $stamp" : $label; |
| 1198 | 1198 |
} |
| 1199 | 1199 |
|
| 1200 |
+sub build_revision {
|
|
| 1201 |
+ my $info = build_info(); |
|
| 1202 |
+ return $info->{revision} || 'unknown';
|
|
| 1203 |
+} |
|
| 1204 |
+ |
|
| 1205 |
+sub build_details {
|
|
| 1206 |
+ my $info = build_info(); |
|
| 1207 |
+ my %details = ( |
|
| 1208 |
+ app => 'Madagascar Local Authority', |
|
| 1209 |
+ revision => $info->{revision} || 'unknown',
|
|
| 1210 |
+ branch => $info->{branch} || '',
|
|
| 1211 |
+ dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
|
|
| 1212 |
+ built_at => $info->{built_at} || '',
|
|
| 1213 |
+ deployed_at => $info->{deployed_at} || '',
|
|
| 1214 |
+ label => build_label(), |
|
| 1215 |
+ title => build_title(), |
|
| 1216 |
+ ); |
|
| 1217 |
+ return json_encode(\%details); |
|
| 1218 |
+} |
|
| 1219 |
+ |
|
| 1200 | 1220 |
sub html_escape {
|
| 1201 | 1221 |
my ($value) = @_; |
| 1202 | 1222 |
$value = '' unless defined $value; |
@@ -1209,8 +1229,9 @@ sub html_escape {
|
||
| 1209 | 1229 |
} |
| 1210 | 1230 |
|
| 1211 | 1231 |
sub app_html {
|
| 1212 |
- my $build = html_escape(build_label()); |
|
| 1232 |
+ my $build = html_escape(build_revision()); |
|
| 1213 | 1233 |
my $build_title = html_escape(build_title()); |
| 1234 |
+ my $build_details = html_escape(build_details()); |
|
| 1214 | 1235 |
my $html = <<'HTML'; |
| 1215 | 1236 |
<!doctype html> |
| 1216 | 1237 |
<html lang="ro"> |
@@ -1313,7 +1334,7 @@ sub app_html {
|
||
| 1313 | 1334 |
.otp-row input.filled { border-color: #b3c6f0; background: #fff; }
|
| 1314 | 1335 |
#login-error {
|
| 1315 | 1336 |
color: var(--bad); font-size: 13px; text-align: center; |
| 1316 |
- min-height: 18px; margin-top: -52px; |
|
| 1337 |
+ min-height: 18px; margin-top: -8px; |
|
| 1317 | 1338 |
} |
| 1318 | 1339 |
@media (max-width: 760px) {
|
| 1319 | 1340 |
.login-card {
|
@@ -1325,7 +1346,6 @@ sub app_html {
|
||
| 1325 | 1346 |
.login-card .brand h1 { font-size: 24px; }
|
| 1326 | 1347 |
.login-card .brand p { font-size: 14px; }
|
| 1327 | 1348 |
.login-card form { padding-bottom: 0; }
|
| 1328 |
- #login-error { margin-top: -24px; }
|
|
| 1329 | 1349 |
} |
| 1330 | 1350 |
@media (max-width: 430px) {
|
| 1331 | 1351 |
#login-screen { padding: 24px 16px 120px; }
|
@@ -1341,7 +1361,6 @@ sub app_html {
|
||
| 1341 | 1361 |
#login-screen { padding-top: 28px; padding-bottom: 96px; }
|
| 1342 | 1362 |
.login-card { padding-top: 34px; padding-bottom: 34px; gap: 20px; }
|
| 1343 | 1363 |
.login-card form { padding-bottom: 0; }
|
| 1344 |
- #login-error { margin-top: -32px; }
|
|
| 1345 | 1364 |
} |
| 1346 | 1365 |
|
| 1347 | 1366 |
/* ── App shell (hidden until authenticated) ── */ |
@@ -1389,27 +1408,46 @@ sub app_html {
|
||
| 1389 | 1408 |
.ca-detail { display: grid; gap: 6px; min-width: 0; }
|
| 1390 | 1409 |
.ca-fingerprint { overflow-wrap: anywhere; }
|
| 1391 | 1410 |
.ca-empty { padding: 12px 14px; }
|
| 1392 |
- .build-badge {
|
|
| 1411 |
+ .build-control {
|
|
| 1393 | 1412 |
position: fixed; |
| 1394 | 1413 |
right: 10px; |
| 1395 | 1414 |
bottom: 8px; |
| 1396 | 1415 |
z-index: 5; |
| 1416 |
+ display: inline-flex; |
|
| 1417 |
+ align-items: center; |
|
| 1418 |
+ gap: 4px; |
|
| 1419 |
+ } |
|
| 1420 |
+ .build-badge, .build-copy {
|
|
| 1397 | 1421 |
color: rgba(255,255,255,.46); |
| 1398 | 1422 |
background: rgba(19,24,42,.28); |
| 1399 | 1423 |
border: 1px solid rgba(255,255,255,.08); |
| 1400 | 1424 |
border-radius: 4px; |
| 1401 |
- padding: 2px 5px; |
|
| 1402 | 1425 |
font-size: 10px; |
| 1403 | 1426 |
line-height: 1.2; |
| 1427 |
+ } |
|
| 1428 |
+ .build-badge {
|
|
| 1429 |
+ padding: 2px 5px; |
|
| 1404 | 1430 |
cursor: text; |
| 1405 |
- pointer-events: auto; |
|
| 1406 | 1431 |
user-select: text; |
| 1407 | 1432 |
} |
| 1408 |
- body.is-app .build-badge {
|
|
| 1433 |
+ .build-copy {
|
|
| 1434 |
+ min-height: 0; |
|
| 1435 |
+ padding: 2px 5px; |
|
| 1436 |
+ cursor: pointer; |
|
| 1437 |
+ } |
|
| 1438 |
+ .build-copy:hover {
|
|
| 1439 |
+ color: rgba(255,255,255,.72); |
|
| 1440 |
+ border-color: rgba(255,255,255,.24); |
|
| 1441 |
+ } |
|
| 1442 |
+ body.is-app .build-badge, body.is-app .build-copy {
|
|
| 1409 | 1443 |
color: rgba(100,112,132,.58); |
| 1410 | 1444 |
background: rgba(255,255,255,.72); |
| 1411 | 1445 |
border-color: rgba(216,222,232,.72); |
| 1412 | 1446 |
} |
| 1447 |
+ body.is-app .build-copy:hover {
|
|
| 1448 |
+ color: rgba(21,32,51,.78); |
|
| 1449 |
+ border-color: rgba(100,112,132,.42); |
|
| 1450 |
+ } |
|
| 1413 | 1451 |
.problems { padding: 10px 14px; display: grid; gap: 8px; }
|
| 1414 | 1452 |
.problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
|
| 1415 | 1453 |
.work-order-card { display: grid; gap: 8px; min-width: 0; }
|
@@ -1646,7 +1684,10 @@ sub app_html {
|
||
| 1646 | 1684 |
</div> |
| 1647 | 1685 |
</div> |
| 1648 | 1686 |
|
| 1649 |
- <div class="build-badge" title="Running build __HOST_MANAGER_BUILD_TITLE__">build __HOST_MANAGER_BUILD__</div> |
|
| 1687 |
+ <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__"> |
|
| 1688 |
+ <span class="build-badge">__HOST_MANAGER_BUILD__</span> |
|
| 1689 |
+ <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button> |
|
| 1690 |
+ </div> |
|
| 1650 | 1691 |
|
| 1651 | 1692 |
<script> |
| 1652 | 1693 |
let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
|
@@ -2067,6 +2108,7 @@ sub app_html {
|
||
| 2067 | 2108 |
|
| 2068 | 2109 |
otpDigits.forEach((input, idx) => {
|
| 2069 | 2110 |
input.addEventListener('input', () => {
|
| 2111 |
+ $('login-error').textContent = '';
|
|
| 2070 | 2112 |
// A single box may receive several digits at once (autofill / typing fast). |
| 2071 | 2113 |
if (input.value.replace(/\D/g, '').length > 1) {
|
| 2072 | 2114 |
fillOtp(input.value, idx); |
@@ -2080,6 +2122,7 @@ sub app_html {
|
||
| 2080 | 2122 |
|
| 2081 | 2123 |
input.addEventListener('paste', (e) => {
|
| 2082 | 2124 |
e.preventDefault(); |
| 2125 |
+ $('login-error').textContent = '';
|
|
| 2083 | 2126 |
const text = (e.clipboardData || window.clipboardData).getData('text');
|
| 2084 | 2127 |
fillOtp(text, idx); |
| 2085 | 2128 |
}); |
@@ -2087,6 +2130,7 @@ sub app_html {
|
||
| 2087 | 2130 |
input.addEventListener('keydown', (e) => {
|
| 2088 | 2131 |
if (e.key === 'Backspace') {
|
| 2089 | 2132 |
e.preventDefault(); |
| 2133 |
+ $('login-error').textContent = '';
|
|
| 2090 | 2134 |
if (input.value) { setOtpDigit(idx, ''); }
|
| 2091 | 2135 |
else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
|
| 2092 | 2136 |
syncOtpFields(); |
@@ -2117,6 +2161,31 @@ sub app_html {
|
||
| 2117 | 2161 |
|
| 2118 | 2162 |
window.addEventListener('popstate', () => showPage(currentPage()));
|
| 2119 | 2163 |
|
| 2164 |
+ async function copyText(text) {
|
|
| 2165 |
+ if (navigator.clipboard && window.isSecureContext) {
|
|
| 2166 |
+ await navigator.clipboard.writeText(text); |
|
| 2167 |
+ return; |
|
| 2168 |
+ } |
|
| 2169 |
+ const input = document.createElement('textarea');
|
|
| 2170 |
+ input.value = text; |
|
| 2171 |
+ input.setAttribute('readonly', '');
|
|
| 2172 |
+ input.style.position = 'fixed'; |
|
| 2173 |
+ input.style.left = '-10000px'; |
|
| 2174 |
+ document.body.appendChild(input); |
|
| 2175 |
+ input.select(); |
|
| 2176 |
+ document.execCommand('copy');
|
|
| 2177 |
+ document.body.removeChild(input); |
|
| 2178 |
+ } |
|
| 2179 |
+ |
|
| 2180 |
+ $('copy-build').addEventListener('click', async () => {
|
|
| 2181 |
+ try {
|
|
| 2182 |
+ await copyText($('copy-build').dataset.buildDetails || '');
|
|
| 2183 |
+ if (state.authenticated) msg('build details copied');
|
|
| 2184 |
+ } catch (e) {
|
|
| 2185 |
+ if (state.authenticated) msg('copy failed');
|
|
| 2186 |
+ } |
|
| 2187 |
+ }); |
|
| 2188 |
+ |
|
| 2120 | 2189 |
$('login-form').addEventListener('submit', async (event) => {
|
| 2121 | 2190 |
event.preventDefault(); |
| 2122 | 2191 |
if (!otpReady() || $('login-form').classList.contains('busy')) return;
|
@@ -2209,5 +2278,6 @@ sub app_html {
|
||
| 2209 | 2278 |
HTML |
| 2210 | 2279 |
$html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g; |
| 2211 | 2280 |
$html =~ s/__HOST_MANAGER_BUILD__/$build/g; |
| 2281 |
+ $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g; |
|
| 2212 | 2282 |
return $html; |
| 2213 | 2283 |
} |