PageSourceSearch

https://haiphen.io/components/verify/verify.js

js haiphen.io collected 2026-10-01 12:02:27 UTC 8,228 bytes, 249 lines download raw bytes

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.