Safari ignores autocomplete="off" on numeric inputs it deems OTP-shaped. Replace the 6 separate <input> elements with non-interactive <div> boxes and position the single otp-autofill <input autocomplete="one-time-code"> as a transparent overlay (opacity:0.01) covering the entire row. Safari now sees exactly one real input field in the viewport; all keyboard input and autofill flows through it, distributing digits to the visual boxes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1292,21 +1292,30 @@ sub app_html {
|
||
| 1292 | 1292 |
padding: 0; |
| 1293 | 1293 |
border: 0; |
| 1294 | 1294 |
} |
| 1295 |
- /* 6 separate OTP digit boxes */ |
|
| 1295 |
+ /* OTP row: transparent overlay input + 6 visual-only div boxes */ |
|
| 1296 | 1296 |
.otp-row {
|
| 1297 |
+ position: relative; |
|
| 1297 | 1298 |
display: flex; |
| 1298 | 1299 |
gap: var(--otp-gap); |
| 1299 | 1300 |
justify-content: center; |
| 1300 | 1301 |
} |
| 1301 |
- .otp-row input {
|
|
| 1302 |
+ #otp-autofill {
|
|
| 1303 |
+ position: absolute; inset: 0; width: 100%; height: 100%; |
|
| 1304 |
+ opacity: 0.01; cursor: text; z-index: 1; |
|
| 1305 |
+ font-size: 0; border: none; outline: none; |
|
| 1306 |
+ background: transparent; color: transparent; caret-color: transparent; |
|
| 1307 |
+ padding: 0; margin: 0; |
|
| 1308 |
+ } |
|
| 1309 |
+ .otp-digit {
|
|
| 1302 | 1310 |
width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px; |
| 1303 |
- font-size: 22px; font-weight: 600; text-align: center; color: var(--ink); |
|
| 1304 |
- background: #f8fafc; caret-color: transparent; outline: none; |
|
| 1311 |
+ font-size: 22px; font-weight: 600; color: var(--ink); |
|
| 1312 |
+ background: #f8fafc; |
|
| 1305 | 1313 |
transition: border-color .15s, background .15s; |
| 1314 |
+ display: flex; align-items: center; justify-content: center; |
|
| 1315 |
+ user-select: none; pointer-events: none; |
|
| 1306 | 1316 |
} |
| 1307 |
- .otp-row input:focus { border-color: var(--accent); background: #fff; }
|
|
| 1308 |
- .otp-row input.focus-proxy { border-color: var(--accent); background: #fff; }
|
|
| 1309 |
- .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
|
|
| 1317 |
+ .otp-digit.active { border-color: var(--accent); background: #fff; }
|
|
| 1318 |
+ .otp-digit.filled { border-color: #b3c6f0; background: #fff; }
|
|
| 1310 | 1319 |
#login-error {
|
| 1311 | 1320 |
color: var(--bad); font-size: 13px; text-align: center; |
| 1312 | 1321 |
min-height: 18px; margin-top: -52px; |
@@ -1330,7 +1339,7 @@ sub app_html {
|
||
| 1330 | 1339 |
--otp-gap: 12px; |
| 1331 | 1340 |
padding: 36px 22px 34px; |
| 1332 | 1341 |
} |
| 1333 |
- .otp-row input { height: 52px; }
|
|
| 1342 |
+ .otp-digit { height: 52px; }
|
|
| 1334 | 1343 |
.login-card form { padding-bottom: 0; }
|
| 1335 | 1344 |
} |
| 1336 | 1345 |
@media (max-height: 720px) {
|
@@ -1376,6 +1385,7 @@ sub app_html {
|
||
| 1376 | 1385 |
.pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
|
| 1377 | 1386 |
.pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
|
| 1378 | 1387 |
.pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
|
| 1388 |
+ .pill.derived { border-style: dashed; }
|
|
| 1379 | 1389 |
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
|
| 1380 | 1390 |
.span2 { grid-column: 1 / -1; }
|
| 1381 | 1391 |
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
@@ -1492,16 +1502,16 @@ sub app_html {
|
||
| 1492 | 1502 |
<form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate> |
| 1493 | 1503 |
<div class="pm-helper-fields" aria-hidden="true"> |
| 1494 | 1504 |
<input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false"> |
| 1495 |
- <input type="text" id="otp-autofill" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code"> |
|
| 1496 | 1505 |
<input type="hidden" id="otp-hidden" name="otp"> |
| 1497 | 1506 |
</div> |
| 1498 | 1507 |
<div class="otp-row"> |
| 1499 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 1"> |
|
| 1500 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 2"> |
|
| 1501 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 3"> |
|
| 1502 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 4"> |
|
| 1503 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 5"> |
|
| 1504 |
- <input type="text" inputmode="numeric" pattern="[0-9]*" class="otp-digit" autocomplete="off" aria-label="Digit 6"> |
|
| 1508 |
+ <input type="text" id="otp-autofill" name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6" aria-label="Verification code"> |
|
| 1509 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1510 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1511 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1512 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1513 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1514 |
+ <div class="otp-digit" aria-hidden="true"></div> |
|
| 1505 | 1515 |
</div> |
| 1506 | 1516 |
</form> |
| 1507 | 1517 |
<div id="login-error"></div> |
@@ -1890,6 +1900,8 @@ sub app_html {
|
||
| 1890 | 1900 |
function renderHosts() {
|
| 1891 | 1901 |
const filter = $('filter').value.toLowerCase();
|
| 1892 | 1902 |
$('hosts').innerHTML = state.hosts
|
| 1903 |
+ .slice() |
|
| 1904 |
+ .sort((a, b) => String(a.id || '').localeCompare(String(b.id || ''))) |
|
| 1893 | 1905 |
.filter(h => JSON.stringify(h).toLowerCase().includes(filter)) |
| 1894 | 1906 |
.map(h => {
|
| 1895 | 1907 |
const problems = state.problems.filter(p => p.host_id === h.id); |
@@ -1898,7 +1910,7 @@ sub app_html {
|
||
| 1898 | 1910 |
<td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
|
| 1899 | 1911 |
<td>${escapeHtml(h.hosts_ip || '')}</td>
|
| 1900 | 1912 |
<td>${escapeHtml(h.dns_ip || '')}</td>
|
| 1901 |
- <td>${(h.names || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
|
|
| 1913 |
+ <td>${renderNamePills(h)}</td>
|
|
| 1902 | 1914 |
<td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
|
| 1903 | 1915 |
<td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
|
| 1904 | 1916 |
<td>${escapeHtml(h.status || '')}</td>
|
@@ -1907,13 +1919,21 @@ sub app_html {
|
||
| 1907 | 1919 |
document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
|
| 1908 | 1920 |
} |
| 1909 | 1921 |
|
| 1922 |
+ function renderNamePills(host) {
|
|
| 1923 |
+ const declared = host.declared_names || host.names || []; |
|
| 1924 |
+ const derived = host.derived_names || []; |
|
| 1925 |
+ const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
|
|
| 1926 |
+ const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
|
|
| 1927 |
+ return declaredHtml + derivedHtml; |
|
| 1928 |
+ } |
|
| 1929 |
+ |
|
| 1910 | 1930 |
function editHost(id) {
|
| 1911 | 1931 |
const host = state.hosts.find(h => h.id === id); |
| 1912 | 1932 |
if (!host) return; |
| 1913 | 1933 |
const form = $('host-form');
|
| 1914 | 1934 |
clearHostFormMessage(); |
| 1915 | 1935 |
for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || ''; |
| 1916 |
- hostField('names').value = (host.names || []).join('\n');
|
|
| 1936 |
+ hostField('names').value = (host.declared_names || host.names || []).join('\n');
|
|
| 1917 | 1937 |
hostField('roles').value = (host.roles || []).join(' ');
|
| 1918 | 1938 |
hostField('sources').value = (host.sources || []).join(' ');
|
| 1919 | 1939 |
openHostModal('Edit host');
|
@@ -2004,38 +2024,16 @@ sub app_html {
|
||
| 2004 | 2024 |
}); |
| 2005 | 2025 |
} |
| 2006 | 2026 |
|
| 2007 |
- (otpAutofill || otpDigits[0]).focus(); |
|
| 2027 |
+ if (otpAutofill) otpAutofill.focus(); |
|
| 2028 |
+ highlightActiveBox(0); |
|
| 2008 | 2029 |
|
| 2009 |
- otpDigits.forEach((input, idx) => {
|
|
| 2010 |
- input.addEventListener('keydown', (e) => {
|
|
| 2011 |
- if (e.key === 'Backspace') {
|
|
| 2012 |
- if (input.value) setOtpDigit(idx, ''); |
|
| 2013 |
- else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
|
|
| 2014 |
- syncOtpFields(); |
|
| 2015 |
- e.preventDefault(); |
|
| 2016 |
- } |
|
| 2017 |
- }); |
|
| 2018 |
- input.addEventListener('input', () => {
|
|
| 2019 |
- const digits = input.value.replace(/\D/g, ''); |
|
| 2020 |
- if (digits.length > 1) {
|
|
| 2021 |
- fillOtp(digits, digits.length >= otpDigits.length ? 0 : idx); |
|
| 2022 |
- return; |
|
| 2023 |
- } |
|
| 2024 |
- setOtpDigit(idx, digits); |
|
| 2025 |
- syncOtpFields(); |
|
| 2026 |
- if (digits && idx < otpDigits.length - 1) otpDigits[idx + 1].focus(); |
|
| 2027 |
- maybeSubmitOtp(); |
|
| 2028 |
- }); |
|
| 2029 |
- input.addEventListener('paste', (e) => {
|
|
| 2030 |
- const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
|
|
| 2031 |
- e.preventDefault(); |
|
| 2032 |
- fillOtp(text, text.length >= otpDigits.length ? 0 : idx); |
|
| 2033 |
- }); |
|
| 2034 |
- }); |
|
| 2030 |
+ function highlightActiveBox(idx) {
|
|
| 2031 |
+ otpDigits.forEach((d, i) => d.classList.toggle('active', i === idx));
|
|
| 2032 |
+ } |
|
| 2035 | 2033 |
|
| 2036 | 2034 |
function setOtpDigit(idx, value) {
|
| 2037 | 2035 |
const digit = (value || '').replace(/\D/g, '').slice(0, 1); |
| 2038 |
- otpDigits[idx].value = digit; |
|
| 2036 |
+ otpDigits[idx].textContent = digit; |
|
| 2039 | 2037 |
otpDigits[idx].classList.toggle('filled', !!digit);
|
| 2040 | 2038 |
} |
| 2041 | 2039 |
|
@@ -2050,33 +2048,48 @@ sub app_html {
|
||
| 2050 | 2048 |
if (targetIdx < otpDigits.length) setOtpDigit(targetIdx, ch); |
| 2051 | 2049 |
}); |
| 2052 | 2050 |
} |
| 2053 |
- syncOtpFields(); |
|
| 2054 | 2051 |
const next = Math.min((digits.length >= otpDigits.length ? digits.length : startIdx + digits.length), otpDigits.length - 1); |
| 2055 |
- otpDigits[next].focus(); |
|
| 2052 |
+ if (otpAutofill) otpAutofill.value = getOtp(); |
|
| 2053 |
+ syncOtpFields(); |
|
| 2054 |
+ highlightActiveBox(next); |
|
| 2056 | 2055 |
maybeSubmitOtp(); |
| 2057 | 2056 |
} |
| 2058 | 2057 |
|
| 2059 |
- function getOtp() { return otpDigits.map(i => i.value).join(''); }
|
|
| 2058 |
+ function getOtp() { return otpDigits.map(i => i.textContent).join(''); }
|
|
| 2060 | 2059 |
function syncOtpFields() {
|
| 2061 | 2060 |
const otp = getOtp(); |
| 2062 | 2061 |
if (otpHidden) otpHidden.value = otp; |
| 2063 |
- if (otpAutofill && otpAutofill.value !== otp) otpAutofill.value = otp; |
|
| 2064 | 2062 |
} |
| 2065 |
- function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
|
|
| 2063 |
+ function otpReady() { return otpDigits.every(i => /^\d$/.test(i.textContent)); }
|
|
| 2066 | 2064 |
function maybeSubmitOtp() { if (otpReady()) $('login-form').requestSubmit(); }
|
| 2067 | 2065 |
function clearOtp() {
|
| 2068 |
- otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
|
|
| 2069 |
- if (otpAutofill) otpAutofill.value = ''; |
|
| 2066 |
+ otpDigits.forEach(i => { i.textContent = ''; i.classList.remove('filled', 'active'); });
|
|
| 2067 |
+ if (otpAutofill) { otpAutofill.value = ''; otpAutofill.focus(); }
|
|
| 2070 | 2068 |
if (otpHidden) otpHidden.value = ''; |
| 2071 |
- (otpAutofill || otpDigits[0]).focus(); |
|
| 2069 |
+ highlightActiveBox(0); |
|
| 2072 | 2070 |
} |
| 2073 | 2071 |
|
| 2074 | 2072 |
if (otpAutofill) {
|
| 2075 |
- const handleAutofill = () => fillOtp(otpAutofill.value, 0); |
|
| 2076 |
- otpAutofill.addEventListener('input', handleAutofill);
|
|
| 2077 |
- otpAutofill.addEventListener('change', handleAutofill);
|
|
| 2078 |
- otpAutofill.addEventListener('focus', () => otpDigits[0].classList.add('focus-proxy'));
|
|
| 2079 |
- otpAutofill.addEventListener('blur', () => otpDigits[0].classList.remove('focus-proxy'));
|
|
| 2073 |
+ otpAutofill.addEventListener('keydown', (e) => {
|
|
| 2074 |
+ if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) return; |
|
| 2075 |
+ if (e.metaKey || e.ctrlKey) return; |
|
| 2076 |
+ if (!/^\d$/.test(e.key)) e.preventDefault(); |
|
| 2077 |
+ }); |
|
| 2078 |
+ otpAutofill.addEventListener('input', () => {
|
|
| 2079 |
+ const val = (otpAutofill.value || '').replace(/\D/g, '').slice(0, otpDigits.length); |
|
| 2080 |
+ if (otpAutofill.value !== val) { otpAutofill.value = val; return; }
|
|
| 2081 |
+ otpDigits.forEach((_, i) => setOtpDigit(i, val[i] || '')); |
|
| 2082 |
+ syncOtpFields(); |
|
| 2083 |
+ highlightActiveBox(Math.min(val.length, otpDigits.length - 1)); |
|
| 2084 |
+ maybeSubmitOtp(); |
|
| 2085 |
+ }); |
|
| 2086 |
+ otpAutofill.addEventListener('change', () => {
|
|
| 2087 |
+ const val = (otpAutofill.value || '').replace(/\D/g, '').slice(0, otpDigits.length); |
|
| 2088 |
+ otpDigits.forEach((_, i) => setOtpDigit(i, val[i] || '')); |
|
| 2089 |
+ syncOtpFields(); |
|
| 2090 |
+ highlightActiveBox(Math.min(val.length, otpDigits.length - 1)); |
|
| 2091 |
+ maybeSubmitOtp(); |
|
| 2092 |
+ }); |
|
| 2080 | 2093 |
} |
| 2081 | 2094 |
|
| 2082 | 2095 |
document.querySelectorAll('[data-page-link]').forEach(link => {
|