1/* docs/components/verify/verify.js 2 * 3 * TOTP challenge page â mounted into #content-widget when user navigates to #verify. 4 * Reads the totp_challenge cookie (HttpOnly, set by auth worker) and POSTs the code 5 * to auth.haiphen.io/totp/verify or /totp/recovery. 6 */ 7(function () { 8 'use strict'; 9 10 const NS = (window.HAIPHEN = window.HAIPHEN || {}); 11 const LOG = '[verify]'; 12 const AUTH_ORIGIN = 'https://auth.haiphen.io'; 13 const MOUNT_ID = 'content-widget'; 14 const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes 15 16 function safeRedirect(raw) { 17 if (!raw) return 'https://haiphen.io/'; 18 try { 19 if (/^https:\/\/([a-z0-9-]+\.)*haiphen\.io(\/|#|$)/i.test(String(raw))) return String(raw); 20 } catch (_) {} 21 return 'https://haiphen.io/'; 22 } 23 24 let mounted = false; 25 let timerHandle = null; 26 27 function qs(sel, root = document) { 28 return root.querySelector(sel); 29 } 30 31 async function fetchText(url) { 32 const r = await fetch(url, { cache: 'no-store' }); 33 if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`); 34 return await r.text(); 35 } 36 37 function injectCssOnce(href) { 38 if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return; 39 const link = document.createElement('link'); 40 link.rel = 'stylesheet'; 41 link.href = href; 42 document.head.appendChild(link); 43 } 44 45 function setStatus(root, msg) { 46 const el = qs('[data-verify-status]', root); 47 if (!el) return; 48 if (!msg) { el.hidden = true; el.textContent = ''; return; } 49 el.hidden = false; 50 el.textContent = msg; 51 } 52 53 function startTimer(root) { 54 const timerEl = qs('[data-verify-timer]', root); 55 if (!timerEl) return; 56 57 const startTime = Date.now(); 58 59 function tick() { 60 const elapsed = Date.now() - startTime; 61 const remaining = Math.max(0, CHALLENGE_TTL_MS - elapsed); 62 63 if (remaining <= 0) { 64 timerEl.textContent = 'Challenge expired. Please log in again.'; 65 // Disable inputs 66 const codeInput = qs('[data-verify-code]', root); 67 const backupInput = qs('[data-verify-backup-code]', root); 68 const submitBtn = qs('[data-verify-submit]', root); 69 const backupBtn = qs('[data-verify-backup-submit]', root); 70 if (codeInput) codeInput.disabled = true; 71 if (backupInput) backupInput.disabled = true; 72 if (submitBtn) submitBtn.disabled = true; 73 if (backupBtn) backupBtn.disabled = true; 74 return; 75 } 76 77 const mins = Math.floor(remaining / 60000); 78 const secs = Math.floor((remaining % 60000) / 1000); 79 timerEl.textContent = `Challenge expires in ${mins}:${String(secs).padStart(2, '0')}`; 80 timerHandle = setTimeout(tick, 1000); 81 } 82 83 tick(); 84 } 85 86 function wire(root) { 87 const codeInput = qs('[data-verify-code]', root); 88 const submitBtn = qs('[data-verify-submit]', root); 89 const backupInput = qs('[data-verify-backup-code]', root); 90 const backupSubmitBtn = qs('[data-verify-backup-submit]', root); 91 const toggleBtn = qs('[data-verify-toggle-backup]', root); 92 const totpForm = qs('[data-verify-totp-form]', root); 93 const backupForm = qs('[data-verify-backup-form]', root); 94 95 // TOTP input: only digits, auto-submit at 6 chars 96 if (codeInput) { 97 codeInput.addEventListener('input', () => { 98 codeInput.value = codeInput.value.replace(/[^0-9]/g, '').slice(0, 6); 99 if (submitBtn) submitBtn.disabled = codeInput.value.length !== 6; 100 if (codeInput.value.length === 6) { 101 submitVerify(root, codeInput.value); 102 } 103 }); 104 } 105 106 if (submitBtn) { 107 submitBtn.addEventListener('click', () => { 108 const code = codeInput?.value || ''; 109 if (code.length === 6) submitVerify(root, code); 110 }); 111 } 112 113 // Backup code input 114 if (backupInput) { 115 backupInput.addEventListener('input', () => { 116 backupInput.value = backupInput.value.replace(/[^a-z0-9]/gi, '').slice(0, 8).toLowerCase(); 117 if (backupSubmitBtn) backupSubmitBtn.disabled = backupInput.value.length !== 8; 118 }); 119 } 120 121 if (backupSubmitBtn) { 122 backupSubmitBtn.addEventListener('click', () => { 123 const code = backupInput?.value || ''; 124 if (code.length === 8) submitRecovery(root, code); 125 }); 126 } 127 128 // Toggle between TOTP and backup code 129 if (toggleBtn) { 130 toggleBtn.addEventListener('click', () => { 131 const showingBackup = !backupForm.hidden; 132 if (showingBackup) { 133 backupForm.hidden = true; 134 totpForm.hidden = false;
135 toggleBtn.textContent = 'Use a backup code instead'; 136 codeInput?.focus(); 137 } else { 138 totpForm.hidden = true; 139 backupForm.hidden = false; 140 toggleBtn.textContent = 'Use authenticator app instead'; 141 backupInput?.focus(); 142 } 143 setStatus(root, ''); 144 }); 145 } 146 147 startTimer(root); 148 } 149 150 async function submitVerify(root, code) { 151 const submitBtn = qs('[data-verify-submit]', root); 152 const codeInput = qs('[data-verify-code]', root); 153 if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; } 154 if (codeInput) codeInput.disabled = true; 155 setStatus(root, ''); 156 157 try { 158 const res = await fetch(`${AUTH_ORIGIN}/totp/verify`, { 159 method: 'POST', 160 credentials: 'include', 161 headers: { 'Content-Type': 'application/json' }, 162 body: JSON.stringify({ code }), 163 }); 164 const data = await res.json().catch(() => ({})); 165 166 if (data.ok) { 167 setStatus(root, ''); 168 window.location.assign(safeRedirect(data.redirect)); 169 return; 170 } 171 172 setStatus(root, data.error || 'Verification failed. Try again.'); 173 } catch (e) { 174 setStatus(root, 'Network error. Please try again.'); 175 console.error(LOG, 'verify failed', e); 176 } finally { 177 if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Verify'; } 178 if (codeInput) { codeInput.disabled = false; codeInput.value = ''; codeInput.focus(); } 179 } 180 } 181 182 async function submitRecovery(root, code) { 183 const submitBtn = qs('[data-verify-backup-submit]', root); 184 const backupInput = qs('[data-verify-backup-code]', root); 185 if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Verifying...'; } 186 if (backupInput) backupInput.disabled = true; 187 setStatus(root, ''); 188 189 try { 190 const res = await fetch(`${AUTH_ORIGIN}/totp/recovery`, { 191 method: 'POST', 192 credentials: 'include', 193 headers: { 'Content-Type': 'application/json' }, 194 body: JSON.stringify({ code }), 195 }); 196 const data = await res.json().catch(() => ({})); 197 198 if (data.ok) { 199 setStatus(root, ''); 200 window.location.assign(safeRedirect(data.redirect)); 201 return; 202 } 203 204 setStatus(root, data.error || 'Invalid backup code. Try again.'); 205 } catch (e) { 206 setStatus(root, 'Network error. Please try again.'); 207 console.error(LOG, 'recovery failed', e); 208 } finally { 209 if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Verify backup code'; } 210 if (backupInput) { backupInput.disabled = false; backupInput.value = ''; backupInput.focus(); } 211 } 212 } 213 214 async function showVerify() { 215 try { 216 injectCssOnce('assets/base.css'); 217 injectCssOnce('components/verify/verify.css'); 218 219 const mount = document.getElementById(MOUNT_ID); 220 if (!mount) { console.warn(LOG, 'missing #' + MOUNT_ID); return; } 221 222 // Clear any timer from a previous mount 223 if (timerHandle) { clearTimeout(timerHandle); timerHandle = null; } 224 225 const html = await fetchText('components/verify/verify.html'); 226 mount.innerHTML = html; 227 mount.classList.add('active'); 228 mounted = true; 229 230 wire(mount); 231 232 // Focus the input 233 requestAnimationFrame(() => { 234 qs('[data-verify-code]', mount)?.focus(); 235 }); 236 } catch (e) { 237 console.error(LOG, 'failed to mount verify', e); 238 } 239 } 240 241 NS.showVerify = showVerify; 242 243 // Auto-open if user lands on #verify 244 window.addEventListener('DOMContentLoaded', () => { 245 if (String(window.location.hash).toLowerCase() === '#verify') { 246 showVerify(); 247 } 248 }); 249})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.