The div-overlay approach didn't reproduce pbx-mgmt's working behavior. Revert to six real <input maxlength="1"> boxes (pbx-mgmt structure) so the user can start typing in any box and paste anywhere. Put autocomplete= "one-time-code" only on the first box and autocomplete="off" on the rest, so Safari anchors its OTP autofill to box 1 instead of latching onto each box. The input handler distributes a multi-digit value (autofill drops the full code into box 1) across all six boxes, then auto-submits after 300ms. Add a busy guard to the submit handler to avoid a double POST. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1292,30 +1292,21 @@ sub app_html {
|
||
| 1292 | 1292 |
padding: 0; |
| 1293 | 1293 |
border: 0; |
| 1294 | 1294 |
} |
| 1295 |
- /* OTP row: transparent overlay input + 6 visual-only div boxes */ |
|
| 1295 |
+ /* 6 separate OTP digit boxes — Safari detects the group and anchors |
|
| 1296 |
+ its OTP autofill to the first box (same pattern as pbx-mgmt). */ |
|
| 1296 | 1297 |
.otp-row {
|
| 1297 |
- position: relative; |
|
| 1298 | 1298 |
display: flex; |
| 1299 | 1299 |
gap: var(--otp-gap); |
| 1300 | 1300 |
justify-content: center; |
| 1301 | 1301 |
} |
| 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 |
+ .otp-row input {
|
|
| 1310 | 1303 |
width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px; |
| 1311 |
- font-size: 22px; font-weight: 600; color: var(--ink); |
|
| 1312 |
- background: #f8fafc; |
|
| 1304 |
+ font-size: 22px; font-weight: 600; text-align: center; color: var(--ink); |
|
| 1305 |
+ background: #f8fafc; caret-color: transparent; outline: none; |
|
| 1313 | 1306 |
transition: border-color .15s, background .15s; |
| 1314 |
- display: flex; align-items: center; justify-content: center; |
|
| 1315 |
- user-select: none; pointer-events: none; |
|
| 1316 | 1307 |
} |
| 1317 |
- .otp-digit.active { border-color: var(--accent); background: #fff; }
|
|
| 1318 |
- .otp-digit.filled { border-color: #b3c6f0; background: #fff; }
|
|
| 1308 |
+ .otp-row input:focus { border-color: var(--accent); background: #fff; }
|
|
| 1309 |
+ .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
|
|
| 1319 | 1310 |
#login-error {
|
| 1320 | 1311 |
color: var(--bad); font-size: 13px; text-align: center; |
| 1321 | 1312 |
min-height: 18px; margin-top: -52px; |
@@ -1339,7 +1330,7 @@ sub app_html {
|
||
| 1339 | 1330 |
--otp-gap: 12px; |
| 1340 | 1331 |
padding: 36px 22px 34px; |
| 1341 | 1332 |
} |
| 1342 |
- .otp-digit { height: 52px; }
|
|
| 1333 |
+ .otp-row input { height: 52px; }
|
|
| 1343 | 1334 |
.login-card form { padding-bottom: 0; }
|
| 1344 | 1335 |
} |
| 1345 | 1336 |
@media (max-height: 720px) {
|
@@ -1505,13 +1496,12 @@ sub app_html {
|
||
| 1505 | 1496 |
<input type="hidden" id="otp-hidden" name="otp"> |
| 1506 | 1497 |
</div> |
| 1507 | 1498 |
<div class="otp-row"> |
| 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> |
|
| 1499 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" aria-label="Digit 1"> |
|
| 1500 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Digit 2"> |
|
| 1501 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Digit 3"> |
|
| 1502 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Digit 4"> |
|
| 1503 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Digit 5"> |
|
| 1504 |
+ <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" pattern="[0-9]*" autocomplete="off" aria-label="Digit 6"> |
|
| 1515 | 1505 |
</div> |
| 1516 | 1506 |
</form> |
| 1517 | 1507 |
<div id="login-error"></div> |
@@ -2011,7 +2001,6 @@ sub app_html {
|
||
| 2011 | 2001 |
|
| 2012 | 2002 |
// OTP digit boxes — auto-advance, backspace, paste |
| 2013 | 2003 |
const otpDigits = Array.from(document.querySelectorAll('.otp-digit'));
|
| 2014 |
- const otpAutofill = $('otp-autofill');
|
|
| 2015 | 2004 |
const otpHidden = $('otp-hidden');
|
| 2016 | 2005 |
const loginAccount = $('login-account');
|
| 2017 | 2006 |
|
@@ -2024,73 +2013,78 @@ sub app_html {
|
||
| 2024 | 2013 |
}); |
| 2025 | 2014 |
} |
| 2026 | 2015 |
|
| 2027 |
- if (otpAutofill) otpAutofill.focus(); |
|
| 2028 |
- highlightActiveBox(0); |
|
| 2029 |
- |
|
| 2030 |
- function highlightActiveBox(idx) {
|
|
| 2031 |
- otpDigits.forEach((d, i) => d.classList.toggle('active', i === idx));
|
|
| 2032 |
- } |
|
| 2033 |
- |
|
| 2034 | 2016 |
function setOtpDigit(idx, value) {
|
| 2035 | 2017 |
const digit = (value || '').replace(/\D/g, '').slice(0, 1); |
| 2036 |
- otpDigits[idx].textContent = digit; |
|
| 2018 |
+ otpDigits[idx].value = digit; |
|
| 2037 | 2019 |
otpDigits[idx].classList.toggle('filled', !!digit);
|
| 2038 | 2020 |
} |
| 2039 | 2021 |
|
| 2022 |
+ // Spread multiple digits across boxes starting at startIdx. Used for paste |
|
| 2023 |
+ // and for Safari OTP autofill, which drops the whole code into the first box. |
|
| 2040 | 2024 |
function fillOtp(text, startIdx = 0) {
|
| 2041 |
- const digits = (text || '').replace(/\D/g, '').slice(0, otpDigits.length); |
|
| 2042 |
- if (!digits) return; |
|
| 2043 |
- if (digits.length >= otpDigits.length) {
|
|
| 2044 |
- otpDigits.forEach((_, i) => setOtpDigit(i, digits[i] || '')); |
|
| 2045 |
- } else {
|
|
| 2046 |
- digits.split('').forEach((ch, offset) => {
|
|
| 2047 |
- const targetIdx = startIdx + offset; |
|
| 2048 |
- if (targetIdx < otpDigits.length) setOtpDigit(targetIdx, ch); |
|
| 2049 |
- }); |
|
| 2025 |
+ const digits = (text || '').replace(/\D/g, '').split('');
|
|
| 2026 |
+ if (!digits.length) return; |
|
| 2027 |
+ let last = startIdx; |
|
| 2028 |
+ for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
|
|
| 2029 |
+ last = startIdx + i; |
|
| 2030 |
+ setOtpDigit(last, digits[i]); |
|
| 2050 | 2031 |
} |
| 2051 |
- const next = Math.min((digits.length >= otpDigits.length ? digits.length : startIdx + digits.length), otpDigits.length - 1); |
|
| 2052 |
- if (otpAutofill) otpAutofill.value = getOtp(); |
|
| 2053 | 2032 |
syncOtpFields(); |
| 2054 |
- highlightActiveBox(next); |
|
| 2033 |
+ if (last < otpDigits.length - 1) otpDigits[last + 1].focus(); |
|
| 2034 |
+ else otpDigits[last].focus(); |
|
| 2055 | 2035 |
maybeSubmitOtp(); |
| 2056 | 2036 |
} |
| 2057 | 2037 |
|
| 2058 |
- function getOtp() { return otpDigits.map(i => i.textContent).join(''); }
|
|
| 2059 |
- function syncOtpFields() {
|
|
| 2060 |
- const otp = getOtp(); |
|
| 2061 |
- if (otpHidden) otpHidden.value = otp; |
|
| 2038 |
+ function getOtp() { return otpDigits.map(i => i.value).join(''); }
|
|
| 2039 |
+ function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
|
|
| 2040 |
+ function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
|
|
| 2041 |
+ function maybeSubmitOtp() {
|
|
| 2042 |
+ if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
|
|
| 2062 | 2043 |
} |
| 2063 |
- function otpReady() { return otpDigits.every(i => /^\d$/.test(i.textContent)); }
|
|
| 2064 |
- function maybeSubmitOtp() { if (otpReady()) $('login-form').requestSubmit(); }
|
|
| 2065 | 2044 |
function clearOtp() {
|
| 2066 |
- otpDigits.forEach(i => { i.textContent = ''; i.classList.remove('filled', 'active'); });
|
|
| 2067 |
- if (otpAutofill) { otpAutofill.value = ''; otpAutofill.focus(); }
|
|
| 2045 |
+ otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
|
|
| 2068 | 2046 |
if (otpHidden) otpHidden.value = ''; |
| 2069 |
- highlightActiveBox(0); |
|
| 2047 |
+ otpDigits[0].focus(); |
|
| 2070 | 2048 |
} |
| 2071 | 2049 |
|
| 2072 |
- if (otpAutofill) {
|
|
| 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] || '')); |
|
| 2050 |
+ otpDigits.forEach((input, idx) => {
|
|
| 2051 |
+ input.addEventListener('input', () => {
|
|
| 2052 |
+ // A single box may receive several digits at once (autofill / typing fast). |
|
| 2053 |
+ if (input.value.replace(/\D/g, '').length > 1) {
|
|
| 2054 |
+ fillOtp(input.value, idx); |
|
| 2055 |
+ return; |
|
| 2056 |
+ } |
|
| 2057 |
+ setOtpDigit(idx, input.value); |
|
| 2082 | 2058 |
syncOtpFields(); |
| 2083 |
- highlightActiveBox(Math.min(val.length, otpDigits.length - 1)); |
|
| 2059 |
+ if (input.value && idx < otpDigits.length - 1) otpDigits[idx + 1].focus(); |
|
| 2084 | 2060 |
maybeSubmitOtp(); |
| 2085 | 2061 |
}); |
| 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(); |
|
| 2062 |
+ |
|
| 2063 |
+ input.addEventListener('paste', (e) => {
|
|
| 2064 |
+ e.preventDefault(); |
|
| 2065 |
+ const text = (e.clipboardData || window.clipboardData).getData('text');
|
|
| 2066 |
+ fillOtp(text, idx); |
|
| 2092 | 2067 |
}); |
| 2093 |
- } |
|
| 2068 |
+ |
|
| 2069 |
+ input.addEventListener('keydown', (e) => {
|
|
| 2070 |
+ if (e.key === 'Backspace') {
|
|
| 2071 |
+ e.preventDefault(); |
|
| 2072 |
+ if (input.value) { setOtpDigit(idx, ''); }
|
|
| 2073 |
+ else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
|
|
| 2074 |
+ syncOtpFields(); |
|
| 2075 |
+ } else if (e.key === 'ArrowLeft' && idx > 0) {
|
|
| 2076 |
+ e.preventDefault(); |
|
| 2077 |
+ otpDigits[idx - 1].focus(); |
|
| 2078 |
+ } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
|
|
| 2079 |
+ e.preventDefault(); |
|
| 2080 |
+ otpDigits[idx + 1].focus(); |
|
| 2081 |
+ } |
|
| 2082 |
+ }); |
|
| 2083 |
+ }); |
|
| 2084 |
+ |
|
| 2085 |
+ // Focus the first box: it carries autocomplete="one-time-code", so Safari |
|
| 2086 |
+ // anchors its OTP autofill there instead of latching onto each box. |
|
| 2087 |
+ otpDigits[0].focus(); |
|
| 2094 | 2088 |
|
| 2095 | 2089 |
document.querySelectorAll('[data-page-link]').forEach(link => {
|
| 2096 | 2090 |
link.addEventListener('click', (event) => {
|
@@ -2103,7 +2097,7 @@ sub app_html {
|
||
| 2103 | 2097 |
|
| 2104 | 2098 |
$('login-form').addEventListener('submit', async (event) => {
|
| 2105 | 2099 |
event.preventDefault(); |
| 2106 |
- if (!otpReady()) return; |
|
| 2100 |
+ if (!otpReady() || $('login-form').classList.contains('busy')) return;
|
|
| 2107 | 2101 |
$('login-form').classList.add('busy');
|
| 2108 | 2102 |
$('login-error').textContent = '';
|
| 2109 | 2103 |
try {
|