From d8d15d77ad725518f4b362f1e8f3686ee66f0603 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 27 Jun 2026 22:49:50 +0200 Subject: [PATCH] feat(2fa): cross-device approval UI, polling JS, approve view - two_factor_challenge.php: QR now shown above the code field with data-poll-token/url attrs; approval status banner; updated caption - two_factor_approve.php: new mobile confirmation page (Approve/Cancel) - two_factor.js: adds poll loop every 2.5s; auto-redirect on approval; expires QR visually; stops polling on manual TOTP submit Co-authored-by: Cursor --- public/assets/js/two_factor.js | 91 +++++++++++++++++++++++++++++++++- views/two_factor_approve.php | 71 ++++++++++++++++++++++++++ views/two_factor_challenge.php | 31 +++++++++--- 3 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 views/two_factor_approve.php diff --git a/public/assets/js/two_factor.js b/public/assets/js/two_factor.js index 3e5ec56..408a26a 100644 --- a/public/assets/js/two_factor.js +++ b/public/assets/js/two_factor.js @@ -2,14 +2,16 @@ 'use strict'; function boot() { - var form = document.querySelector('form.login-card'); - var input = document.getElementById('twofa-code-input'); + var form = document.querySelector('form.login-card'); + var input = document.getElementById('twofa-code-input'); + var script = document.currentScript || document.querySelector('script[data-poll-token]'); if (!form || !input) { return; } input.focus(); input.select(); + // ── 6-digit TOTP input helpers ───────────────────────────────────────── function digitsOnly(val) { return String(val || '').replace(/\D/g, '').slice(0, 6); } @@ -56,6 +58,91 @@ input.value = digitsOnly(input.value + ev.key); maybeSubmit(); }); + + // ── Cross-device approval polling ────────────────────────────────────── + var pollToken = script && script.getAttribute('data-poll-token'); + var pollUrl = script && script.getAttribute('data-poll-url'); + if (!pollToken || !pollUrl) { + return; + } + + var statusEl = document.getElementById('twofa-approval-status'); + var qrWrap = document.getElementById('twofa-qr-wrap'); + var pollTimer = null; + var pollActive = true; + var INTERVAL = 2500; // ms + + function showStatus(msg) { + if (statusEl) { + statusEl.style.display = ''; + statusEl.textContent = msg; + } + } + + function stopPolling() { + pollActive = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + } + + function poll() { + if (!pollActive) { + return; + } + var url = pollUrl + '?token=' + encodeURIComponent(pollToken); + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.onload = function () { + if (!pollActive) { + return; + } + try { + var data = JSON.parse(xhr.responseText); + } catch (e) { + scheduleNext(); + return; + } + if (data.status === 'approved' && data.redirect) { + stopPolling(); + showStatus('✅ Approved — signing you in…'); + setTimeout(function () { + window.location.href = data.redirect; + }, 400); + return; + } + if (data.status === 'expired' || data.status === 'used' || data.status === 'not_found') { + stopPolling(); + showStatus('⚠️ QR code expired. Reload to get a new one.'); + if (qrWrap) { + qrWrap.style.opacity = '0.35'; + } + return; + } + // status === 'pending' — keep polling + showStatus('⏳ Waiting for mobile approval…'); + scheduleNext(); + }; + xhr.onerror = function () { + scheduleNext(); + }; + xhr.send(); + } + + function scheduleNext() { + if (pollActive) { + pollTimer = setTimeout(poll, INTERVAL); + } + } + + // Start polling after a short delay so the page renders first + pollTimer = setTimeout(poll, 1000); + + // Stop polling if the user submits the TOTP form manually + form.addEventListener('submit', function () { + stopPolling(); + }); } if (document.readyState === 'loading') { diff --git a/views/two_factor_approve.php b/views/two_factor_approve.php new file mode 100644 index 0000000..2471c9d --- /dev/null +++ b/views/two_factor_approve.php @@ -0,0 +1,71 @@ + + + + + + + Approve sign-in — <?= h(cfg('app_name')) ?> + + + + + + + + diff --git a/views/two_factor_challenge.php b/views/two_factor_challenge.php index e3a96ea..e81344f 100644 --- a/views/two_factor_challenge.php +++ b/views/two_factor_challenge.php @@ -19,7 +19,12 @@ $twofaQr = AuthTwoFactorPage::qrPayload(); - +