/** * ExoSecure — User Session Lock Overlay * Version: 1.0.0 * * Reusable security module for ExoHub Pro and ExoticPet Portal. * Provides optional PIN + email OTP protection for individual user sessions. * * Integration: * * window.ExoSecure.init({ * userEmail: 'user@example.com', * hubPin: '', * twoFaEnabled: false, * mode: 'hub', // 'hub' | 'portal' * onSuccess: function() { ... } * }); * * API: * ExoSecure.init(config) — call on page load * ExoSecure.setupPin(cfg) — open PIN setup UI from settings panel * ExoSecure.clearPin(cfg) — remove pin, disable lock * * Rules enforced: * - No inline onclick handlers (addEventListener only) * - No service-role key (anon key only) * - All async operations in async functions * - Apostrophes in single-quoted strings escaped as \' * - SHA-256 hashing via SubtleCrypto API * - PIN salt: 'ExoHub_User_2026' */ (function(window) { 'use strict'; /* ───────────────────────────────────────────── CONSTANTS ───────────────────────────────────────────── */ var NETLIFY_SEND_EMAIL = 'https://app.avianexotics.vet/.netlify/functions/send-email'; var SUPABASE_URL = 'https://cewmmfxaawlhhydtlgmv.supabase.co'; var ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNld21tZnhhYXdsaGh5ZHRsZ212Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQwNDYxMjcsImV4cCI6MjA4OTYyMjEyN30.LP8aLtyq5CWjXx-Rhgw3oMVLfCQcuJ-NQ53zPzQs4vQ'; var PIN_SALT = 'ExoHub_User_2026'; var LOCKOUT_ATTEMPTS = 5; var LOCKOUT_DURATION_MS = 15 * 60 * 1000; /* 15 minutes */ var OTP_EXPIRY_MS = 10 * 60 * 1000; /* 10 minutes */ var OTP_MAX_ATTEMPTS = 3; var SESSION_FLAG = 'exohub_user_unlocked'; /* ───────────────────────────────────────────── INTERNAL STATE ───────────────────────────────────────────── */ var _cfg = null; /* active init config */ var _overlay = null; /* DOM overlay element */ var _otpCode = ''; var _otpExpiry = 0; var _otpAttempts = 0; var _resendTimer = null; var _lockoutTimer = null; /* ───────────────────────────────────────────── CRYPTO HELPERS ───────────────────────────────────────────── */ async function hashPin(pin) { var data = new TextEncoder().encode(pin + PIN_SALT); var buf = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(buf)) .map(function(b) { return b.toString(16).padStart(2, '0'); }) .join(''); } /* ───────────────────────────────────────────── SUPABASE PATCH HELPER (anon key only) ───────────────────────────────────────────── */ async function sbPatch(table, filter, body) { var r = await fetch(SUPABASE_URL + '/rest/v1/' + table + '?' + filter, { method: 'PATCH', headers: { 'apikey': ANON_KEY, 'Authorization': 'Bearer ' + ANON_KEY, 'Content-Type': 'application/json', 'Prefer': 'return=minimal' }, body: JSON.stringify(body) }); if (!r.ok) { var txt = await r.text(); throw new Error('Supabase PATCH ' + r.status + ': ' + txt.slice(0, 100)); } return true; } /* ───────────────────────────────────────────── BRUTE-FORCE LOCKOUT (localStorage per email) ───────────────────────────────────────────── */ function lockoutKey(email) { return 'exosecure_lockout_' + (email || 'user'); } function attemptsKey(email) { return 'exosecure_attempts_' + (email || 'user'); } function getLockout(email) { return { count: parseInt(localStorage.getItem(attemptsKey(email)) || '0', 10), until: parseInt(localStorage.getItem(lockoutKey(email)) || '0', 10) }; } function setLockout(email, count, until) { localStorage.setItem(attemptsKey(email), String(count)); localStorage.setItem(lockoutKey(email), String(until)); } function clearLockout(email) { localStorage.removeItem(attemptsKey(email)); localStorage.removeItem(lockoutKey(email)); } /* ───────────────────────────────────────────── THEMING — returns CSS vars per mode ───────────────────────────────────────────── */ function getModeTheme(mode) { if (mode === 'portal') { return { primary: '#7c3aed', bg: '#0f0b1a', surface: '#1a1429', border: '#2d2240', text: '#e5e0f0', muted: '#9d91b8', title: 'ExoticPet Portal', subtitle: 'Session Lock' }; } /* default: hub */ return { primary: '#3db0d4', bg: '#0d1117', surface: '#161b22', border: '#21262d', text: '#e6edf3', muted: '#8b949e', title: 'ExoHub Pro', subtitle: 'Session Lock' }; } /* ───────────────────────────────────────────── MASK EMAIL user@domain.com -> u***@domain.com ───────────────────────────────────────────── */ function maskEmail(email) { if (!email) return '****'; return email.replace(/^(.)(.*)(@.*)$/, function(_, first, mid, domain) { return first + mid.replace(/./g, '*') + domain; }); } /* ───────────────────────────────────────────── BUILD & INJECT OVERLAY DOM ───────────────────────────────────────────── */ function buildOverlay(theme, mode) { if (document.getElementById('exosecure-overlay')) return; var style = document.createElement('style'); style.id = 'exosecure-styles'; style.textContent = [ '#exosecure-overlay{', ' position:fixed;inset:0;z-index:99999;', ' background:' + theme.bg + ';', ' display:flex;align-items:center;justify-content:center;', ' font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',sans-serif;', ' font-size:14px;color:' + theme.text + ';', '}', '#exosecure-overlay .es-box{', ' background:' + theme.surface + ';border:1px solid ' + theme.border + ';', ' border-radius:14px;padding:40px;text-align:center;width:340px;', ' box-shadow:0 20px 60px rgba(0,0,0,.5);', '}', '#exosecure-overlay .es-logo{', ' font-size:24px;font-weight:700;color:' + theme.primary + ';margin-bottom:4px;', '}', '#exosecure-overlay .es-sub{', ' font-size:11px;text-transform:uppercase;letter-spacing:1.5px;', ' color:' + theme.muted + ';margin-bottom:8px;', '}', '#exosecure-overlay .es-email{', ' font-size:12px;color:' + theme.muted + ';margin-bottom:20px;', '}', '#exosecure-overlay .es-dots{', ' display:flex;gap:12px;justify-content:center;margin-bottom:20px;', '}', '#exosecure-overlay .es-dot{', ' width:16px;height:16px;border-radius:50%;', ' border:2px solid ' + theme.border + ';background:transparent;transition:.15s;', '}', '#exosecure-overlay .es-dot.filled{background:' + theme.primary + ';border-color:' + theme.primary + ';}', '#exosecure-overlay .es-keypad{', ' display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:4px;', '}', '#exosecure-overlay .es-key{', ' background:' + theme.bg + ';border:1px solid ' + theme.border + ';', ' border-radius:8px;color:' + theme.text + ';padding:14px;', ' font-size:18px;cursor:pointer;transition:.15s;', '}', '#exosecure-overlay .es-key:hover{background:' + theme.border + ';}', '#exosecure-overlay .es-key.wide{grid-column:span 2;}', '#exosecure-overlay .es-error{color:#ef4444;font-size:12px;margin-top:10px;min-height:16px;}', '#exosecure-overlay .es-lockout{color:#f59e0b;font-size:12px;margin-top:6px;min-height:16px;}', '#exosecure-overlay .es-forgot{', ' font-size:12px;color:' + theme.primary + ';cursor:pointer;', ' text-decoration:underline;margin-top:14px;display:inline-block;', '}', /* OTP screen */ '#exosecure-overlay .es-otp-box{display:none;}', '#exosecure-overlay .es-otp-box.active{display:block;}', '#exosecure-overlay .es-pin-box.hidden{display:none;}', '#exosecure-overlay .es-otp-title{font-size:18px;font-weight:600;color:' + theme.primary + ';margin-bottom:8px;}', '#exosecure-overlay .es-otp-sub{font-size:12px;color:' + theme.muted + ';margin-bottom:18px;}', '#exosecure-overlay .es-otp-digits{display:flex;gap:7px;justify-content:center;margin-bottom:16px;}', '#exosecure-overlay .es-otp-digit{', ' width:42px;height:52px;background:' + theme.bg + ';', ' border:2px solid ' + theme.border + ';border-radius:8px;', ' color:' + theme.text + ';font-size:20px;font-weight:700;', ' text-align:center;outline:none;caret-color:' + theme.primary + ';transition:.15s;', '}', '#exosecure-overlay .es-otp-digit:focus{border-color:' + theme.primary + ';}', '#exosecure-overlay .es-verify-btn{', ' width:100%;padding:12px;', ' background:' + theme.primary + ';color:#fff;', ' border:none;border-radius:8px;font-size:14px;font-weight:600;', ' cursor:pointer;transition:.15s;margin-bottom:10px;', '}', '#exosecure-overlay .es-verify-btn:hover{opacity:.85;}', '#exosecure-overlay .es-verify-btn:disabled{opacity:.45;cursor:default;}', '#exosecure-overlay .es-resend{font-size:12px;color:' + theme.muted + ';}', '#exosecure-overlay .es-resend-link{color:' + theme.primary + ';cursor:pointer;text-decoration:underline;}', '#exosecure-overlay .es-otp-error{color:#ef4444;font-size:12px;margin-top:8px;min-height:16px;}', '#exosecure-overlay .es-otp-status{font-size:12px;color:' + theme.muted + ';margin-top:6px;min-height:14px;}', /* Setup screen */ '#exosecure-overlay .es-setup-box{display:none;}', '#exosecure-overlay .es-setup-box.active{display:block;}', '#exosecure-overlay .es-setup-title{font-size:18px;font-weight:600;color:' + theme.primary + ';margin-bottom:12px;}', '#exosecure-overlay .es-setup-row{', ' display:flex;gap:8px;justify-content:center;margin-bottom:12px;', '}', '#exosecure-overlay .es-setup-len-btn{', ' padding:8px 18px;border-radius:8px;border:2px solid ' + theme.border + ';', ' background:' + theme.bg + ';color:' + theme.text + ';cursor:pointer;font-size:13px;font-weight:500;', '}', '#exosecure-overlay .es-setup-len-btn.selected{border-color:' + theme.primary + ';color:' + theme.primary + ';}', '#exosecure-overlay .es-setup-input{', ' width:100%;background:' + theme.bg + ';border:1px solid ' + theme.border + ';', ' border-radius:8px;color:' + theme.text + ';padding:10px 12px;', ' font-size:18px;letter-spacing:6px;text-align:center;outline:none;margin-bottom:10px;', '}', '#exosecure-overlay .es-setup-input:focus{border-color:' + theme.primary + ';}', '#exosecure-overlay .es-setup-btn{', ' width:100%;padding:11px;background:' + theme.primary + ';color:#fff;', ' border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;', '}', '#exosecure-overlay .es-setup-btn:hover{opacity:.85;}', '#exosecure-overlay .es-setup-toggle{', ' display:flex;align-items:center;gap:8px;margin-bottom:12px;font-size:13px;color:' + theme.muted + ';', ' cursor:pointer;', '}', '#exosecure-overlay .es-setup-toggle input{width:16px;height:16px;accent-color:' + theme.primary + ';cursor:pointer;}', '#exosecure-overlay .es-setup-msg{font-size:12px;margin-top:8px;min-height:16px;}', '#exosecure-overlay .es-back{font-size:12px;color:' + theme.muted + ';cursor:pointer;text-decoration:underline;margin-top:12px;display:inline-block;}', '@keyframes es-shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-8px)}40%,80%{transform:translateX(8px)}}', '.es-shake{animation:es-shake .4s;}' ].join('\n'); document.head.appendChild(style); var pinDotsHtml = ''; var dotCount = 6; /* max, shown dynamically */ for (var d = 0; d < dotCount; d++) { pinDotsHtml += '
'; } var otpDigitsHtml = ''; for (var od = 0; od < 6; od++) { var ac = od === 0 ? ' autocomplete="one-time-code"' : ''; otpDigitsHtml += ''; } var div = document.createElement('div'); div.id = 'exosecure-overlay'; div.innerHTML = [ '
', /* ── PIN SCREEN ── */ '
', ' ', '
' + esc(theme.subtitle) + '
', '
', '
' + pinDotsHtml + '
', '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '
', '
', '
', ' Forgot PIN? Reset via email', '
', /* ── OTP SCREEN ── */ '
', '
Verify Your Identity
', '
A 6-digit code was sent to your email
', '
' + otpDigitsHtml + '
', ' ', '
', ' Resend available in 30s', ' ', '
', '
', '
', '
', /* ── SETUP SCREEN ── */ '
', '
Enable Session Lock
', '
Choose a PIN to protect your session
', '
', ' ', ' ', '
', ' ', ' ', ' ', ' ', '
', ' Cancel', '
', '
' /* .es-box */ ].join(''); document.body.appendChild(div); _overlay = div; } function esc(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /* ───────────────────────────────────────────── SCREEN SWITCHER ───────────────────────────────────────────── */ function showScreen(name) { var pinBox = document.getElementById('es-pin-box'); var otpBox = document.getElementById('es-otp-box'); var setupBox = document.getElementById('es-setup-box'); if (!pinBox) return; pinBox.classList.remove('hidden'); otpBox.classList.remove('active'); setupBox.classList.remove('active'); if (name === 'pin') { /* default — pin box visible */ } else if (name === 'otp') { pinBox.classList.add('hidden'); otpBox.classList.add('active'); } else if (name === 'setup') { pinBox.classList.add('hidden'); setupBox.classList.add('active'); } } /* ───────────────────────────────────────────── LOCKOUT COUNTDOWN ───────────────────────────────────────────── */ function startLockoutCountdown(email) { var el = document.getElementById('es-lockout'); var kp = document.getElementById('es-keypad'); if (kp) { kp.style.opacity = '0.3'; kp.style.pointerEvents = 'none'; } if (_lockoutTimer) clearInterval(_lockoutTimer); _lockoutTimer = setInterval(function() { var s = getLockout(email); var rem = Math.ceil((s.until - Date.now()) / 1000); if (rem <= 0) { clearInterval(_lockoutTimer); _lockoutTimer = null; clearLockout(email); if (kp) { kp.style.opacity = '1'; kp.style.pointerEvents = ''; } if (el) el.textContent = ''; } else { var mins = Math.floor(rem / 60); var secs = rem % 60; if (el) el.textContent = 'Locked: ' + mins + 'm ' + String(secs).padStart(2, '0') + 's remaining'; } }, 1000); } /* ───────────────────────────────────────────── PIN INPUT HANDLING ───────────────────────────────────────────── */ var _currentPin = ''; var _pinLength = 4; /* determined from hubPin or setup config */ function resetPinDots() { for (var i = 0; i < 6; i++) { var d = document.getElementById('es-dot-' + i); if (d) { d.classList.remove('filled'); d.style.display = i < _pinLength ? 'block' : 'none'; } } _currentPin = ''; } async function handleKey(k) { if (!_cfg) return; var email = _cfg.userEmail; var box = document.getElementById('es-pin-box'); var errEl = document.getElementById('es-pin-error'); /* Check lockout */ var state = getLockout(email); if (state.until > Date.now()) { startLockoutCountdown(email); return; } if (k === 'clear') { _currentPin = ''; resetPinDots(); if (errEl) errEl.textContent = ''; return; } if (_currentPin.length >= _pinLength) return; _currentPin += k; var dot = document.getElementById('es-dot-' + (_currentPin.length - 1)); if (dot) dot.classList.add('filled'); if (_currentPin.length === _pinLength) { var entered = _currentPin; _currentPin = ''; setTimeout(resetPinDots, 200); var hash = await hashPin(entered); if (hash === _cfg.hubPin) { clearLockout(email); /* PIN correct */ if (_cfg.twoFaEnabled) { showScreen('otp'); await sendUserOtp(); } else { unlockSuccess(); } } else { state = getLockout(email); var newCount = state.count + 1; if (newCount >= LOCKOUT_ATTEMPTS) { var lockUntil = Date.now() + LOCKOUT_DURATION_MS; setLockout(email, newCount, lockUntil); startLockoutCountdown(email); if (errEl) errEl.textContent = ''; } else { setLockout(email, newCount, 0); var rem = LOCKOUT_ATTEMPTS - newCount; if (errEl) errEl.textContent = 'Incorrect PIN. ' + rem + ' attempt' + (rem === 1 ? '' : 's') + ' left.'; if (box) { box.classList.add('es-shake'); setTimeout(function() { box.classList.remove('es-shake'); }, 500); } } } } } /* ───────────────────────────────────────────── OTP SEND & VERIFY ───────────────────────────────────────────── */ async function sendUserOtp() { _otpCode = String(Math.floor(Math.random() * 900000) + 100000); _otpExpiry = Date.now() + OTP_EXPIRY_MS; _otpAttempts = 0; var email = _cfg ? _cfg.userEmail : ''; var html = '

Your ExoHub verification code is: ' + _otpCode + '

' + '

This code expires in 10 minutes.

' + '

If you did not request this, contact support immediately.

'; var statusEl = document.getElementById('es-otp-status'); if (statusEl) statusEl.textContent = 'Sending code...'; try { await fetch(NETLIFY_SEND_EMAIL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: email, subject: 'ExoHub \u2014 Your Login Verification Code', html: html, from: 'Dr. de Armas & the ExoHub Team ' }) }); if (statusEl) statusEl.textContent = 'Code sent to ' + maskEmail(email); } catch (err) { if (statusEl) statusEl.textContent = 'Failed to send code. Check your connection.'; } /* Reset OTP input fields */ for (var i = 0; i < 6; i++) { var inp = document.getElementById('es-otp-d' + i); if (inp) inp.value = ''; } var vbtn = document.getElementById('es-verify-btn'); if (vbtn) vbtn.disabled = true; var subEl = document.getElementById('es-otp-sub'); if (subEl) subEl.textContent = 'A 6-digit code was sent to ' + maskEmail(email); startResendCountdown(); var firstDigit = document.getElementById('es-otp-d0'); if (firstDigit) firstDigit.focus(); } async function sendResetOtp() { /* Same as sendUserOtp but purpose is PIN reset */ _otpCode = String(Math.floor(Math.random() * 900000) + 100000); _otpExpiry = Date.now() + OTP_EXPIRY_MS; _otpAttempts = 0; var email = _cfg ? _cfg.userEmail : ''; var html = '

Your ExoHub PIN reset code is: ' + _otpCode + '

' + '

Enter this code to reset your session PIN. It expires in 10 minutes.

'; try { await fetch(NETLIFY_SEND_EMAIL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: email, subject: 'ExoHub \u2014 PIN Reset Code', html: html, from: 'Dr. de Armas & the ExoHub Team ' }) }); } catch (e) { /* ignore send errors */ } showScreen('otp'); /* Mark OTP screen as reset-mode via flag */ _otpResetMode = true; var subEl = document.getElementById('es-otp-sub'); if (subEl) subEl.textContent = 'Enter the reset code sent to ' + maskEmail(email); var statusEl = document.getElementById('es-otp-status'); if (statusEl) statusEl.textContent = 'Code sent — verify to clear your PIN'; for (var i = 0; i < 6; i++) { var inp = document.getElementById('es-otp-d' + i); if (inp) inp.value = ''; } var vbtn = document.getElementById('es-verify-btn'); if (vbtn) vbtn.disabled = true; startResendCountdown(); var firstDigit = document.getElementById('es-otp-d0'); if (firstDigit) firstDigit.focus(); } var _otpResetMode = false; function startResendCountdown() { var countdown = document.getElementById('es-resend-countdown'); var timerSpan = document.getElementById('es-resend-timer'); var resendLink = document.getElementById('es-resend-link'); if (!countdown) return; countdown.style.display = 'inline'; resendLink.style.display = 'none'; var secs = 30; timerSpan.textContent = String(secs); if (_resendTimer) clearInterval(_resendTimer); _resendTimer = setInterval(function() { secs--; timerSpan.textContent = String(secs); if (secs <= 0) { clearInterval(_resendTimer); _resendTimer = null; countdown.style.display = 'none'; resendLink.style.display = 'inline'; } }, 1000); } /* ───────────────────────────────────────────── UNLOCK SUCCESS ───────────────────────────────────────────── */ function unlockSuccess() { sessionStorage.setItem(SESSION_FLAG, '1'); removeOverlay(); if (_cfg && typeof _cfg.onSuccess === 'function') { _cfg.onSuccess(); } } function removeOverlay() { var ov = document.getElementById('exosecure-overlay'); if (ov) ov.remove(); var st = document.getElementById('exosecure-styles'); if (st) st.remove(); _overlay = null; } /* ───────────────────────────────────────────── WIRE UP EVENT LISTENERS (called after DOM inject) ───────────────────────────────────────────── */ function wireListeners() { /* PIN keypad */ var keypad = document.getElementById('es-keypad'); if (keypad) { keypad.addEventListener('click', function(e) { var btn = e.target.closest('[data-k]'); if (btn) handleKey(btn.dataset.k); }); } /* Forgot PIN */ var forgotLink = document.getElementById('es-forgot-link'); if (forgotLink) { forgotLink.addEventListener('click', async function() { await sendResetOtp(); }); } /* OTP digit inputs — auto-advance */ var otpDigitsContainer = document.getElementById('es-otp-digits'); if (otpDigitsContainer) { otpDigitsContainer.addEventListener('input', function(e) { var tgt = e.target; if (!/^[0-9]$/.test(tgt.value)) { tgt.value = ''; return; } var idx = parseInt(tgt.id.replace('es-otp-d', ''), 10); if (idx < 5) { var next = document.getElementById('es-otp-d' + (idx + 1)); if (next) next.focus(); } /* Check if all filled */ var allFilled = true; for (var i = 0; i < 6; i++) { var inp = document.getElementById('es-otp-d' + i); if (!inp || !inp.value) { allFilled = false; break; } } var vbtn = document.getElementById('es-verify-btn'); if (vbtn) vbtn.disabled = !allFilled; }); otpDigitsContainer.addEventListener('keydown', function(e) { var tgt = e.target; if (e.key === 'Backspace' && !tgt.value) { var idx = parseInt(tgt.id.replace('es-otp-d', ''), 10); if (idx > 0) { var prev = document.getElementById('es-otp-d' + (idx - 1)); if (prev) prev.focus(); } } }); } /* OTP verify button */ var verifyBtn = document.getElementById('es-verify-btn'); if (verifyBtn) { verifyBtn.addEventListener('click', async function() { var entered = ''; for (var i = 0; i < 6; i++) { var inp = document.getElementById('es-otp-d' + i); entered += (inp ? inp.value : ''); } var errEl = document.getElementById('es-otp-error'); /* Expiry check */ if (Date.now() > _otpExpiry) { if (errEl) errEl.textContent = 'Code expired. Please resend.'; _otpCode = ''; return; } if (entered === _otpCode) { _otpCode = ''; _otpAttempts = 0; if (_otpResetMode) { /* Reset mode: clear pin in Supabase and show setup */ _otpResetMode = false; await clearUserPinInDb(_cfg.userEmail); _cfg.hubPin = null; _cfg.twoFaEnabled = false; showScreen('setup'); } else { unlockSuccess(); } } else { _otpAttempts++; if (_otpAttempts >= OTP_MAX_ATTEMPTS) { _otpCode = ''; _otpAttempts = 0; if (errEl) errEl.textContent = 'Too many attempts. Return to PIN entry.'; setTimeout(function() { showScreen('pin'); if (errEl) errEl.textContent = ''; }, 1500); } else { var rem = OTP_MAX_ATTEMPTS - _otpAttempts; if (errEl) errEl.textContent = 'Incorrect code. ' + rem + ' attempt' + (rem === 1 ? '' : 's') + ' remaining.'; } } }); } /* OTP resend */ var resendLink = document.getElementById('es-resend-link'); if (resendLink) { resendLink.addEventListener('click', async function() { var errEl = document.getElementById('es-otp-error'); if (errEl) errEl.textContent = ''; for (var i = 0; i < 6; i++) { var inp = document.getElementById('es-otp-d' + i); if (inp) inp.value = ''; } var vbtn = document.getElementById('es-verify-btn'); if (vbtn) vbtn.disabled = true; if (_otpResetMode) { await sendResetOtp(); } else { await sendUserOtp(); } }); } /* Setup screen — PIN length selector */ var len4btn = document.getElementById('es-len-4'); var len6btn = document.getElementById('es-len-6'); var setupPinInput1 = document.getElementById('es-setup-pin1'); var setupPinInput2 = document.getElementById('es-setup-pin2'); if (len4btn) { len4btn.addEventListener('click', function() { len4btn.classList.add('selected'); if (len6btn) len6btn.classList.remove('selected'); if (setupPinInput1) setupPinInput1.maxLength = 4; if (setupPinInput2) setupPinInput2.maxLength = 4; }); } if (len6btn) { len6btn.addEventListener('click', function() { len6btn.classList.add('selected'); if (len4btn) len4btn.classList.remove('selected'); if (setupPinInput1) setupPinInput1.maxLength = 6; if (setupPinInput2) setupPinInput2.maxLength = 6; }); } /* Setup save button */ var setupSaveBtn = document.getElementById('es-setup-save-btn'); if (setupSaveBtn) { setupSaveBtn.addEventListener('click', async function() { var msgEl = document.getElementById('es-setup-msg'); var p1 = setupPinInput1 ? setupPinInput1.value : ''; var p2 = setupPinInput2 ? setupPinInput2.value : ''; var twoFa = document.getElementById('es-2fa-toggle'); var twoFaChecked = twoFa ? twoFa.checked : false; var chosenLen = len6btn && len6btn.classList.contains('selected') ? 6 : 4; if (!p1 || p1.length !== chosenLen || !/^\d+$/.test(p1)) { if (msgEl) msgEl.innerHTML = 'PIN must be ' + chosenLen + ' digits'; return; } if (p1 !== p2) { if (msgEl) msgEl.innerHTML = 'PINs do not match'; return; } if (msgEl) msgEl.innerHTML = 'Saving...'; setupSaveBtn.disabled = true; try { var hash = await hashPin(p1); await saveUserPinToDb(_cfg.userEmail, hash, twoFaChecked); if (_cfg) { _cfg.hubPin = hash; _cfg.twoFaEnabled = twoFaChecked; } if (msgEl) msgEl.innerHTML = 'Session lock enabled!'; setTimeout(function() { unlockSuccess(); }, 800); } catch (err) { if (msgEl) msgEl.innerHTML = 'Error: ' + esc(err.message) + ''; } finally { setupSaveBtn.disabled = false; } }); } /* Setup cancel */ var cancelBtn = document.getElementById('es-setup-cancel'); if (cancelBtn) { cancelBtn.addEventListener('click', function() { removeOverlay(); if (_cfg && typeof _cfg.onSuccess === 'function') { _cfg.onSuccess(); } }); } } /* ───────────────────────────────────────────── SUPABASE PIN SAVE / CLEAR ───────────────────────────────────────────── */ async function saveUserPinToDb(email, hashVal, twoFaEnabled) { /* Try beta_testers first, then hub_users */ var body = { hub_pin: hashVal, two_fa_enabled: twoFaEnabled }; try { await sbPatch('beta_testers', 'email=eq.' + encodeURIComponent(email), body); } catch (e) { /* Fall back to hub_users table */ await sbPatch('hub_users', 'email=eq.' + encodeURIComponent(email), body); } } async function clearUserPinInDb(email) { var body = { hub_pin: null, two_fa_enabled: false }; try { await sbPatch('beta_testers', 'email=eq.' + encodeURIComponent(email), body); } catch (e) { try { await sbPatch('hub_users', 'email=eq.' + encodeURIComponent(email), body); } catch (e2) { /* ignore */ } } } /* ───────────────────────────────────────────── PUBLIC API ───────────────────────────────────────────── */ /** * ExoSecure.init(config) * * config = { * userEmail: string — user\'s email address * hubPin: string|null — SHA-256 hash from DB (null = no lock) * twoFaEnabled: boolean * mode: \'hub\' | \'portal\' * onSuccess: function — called when lock cleared * supabaseTable: string — optional override (\'beta_testers\'|\'hub_users\') * } * * Modes: * - No hubPin: do nothing, call onSuccess immediately * - hubPin set, twoFaEnabled false: show PIN gate only * - hubPin set, twoFaEnabled true: show PIN gate, then OTP */ function init(config) { if (!config) return; _cfg = config; /* Mode 3: no lock */ if (!config.hubPin) { if (typeof config.onSuccess === 'function') config.onSuccess(); return; } /* Check if already unlocked this session */ if (sessionStorage.getItem(SESSION_FLAG) === '1') { if (typeof config.onSuccess === 'function') config.onSuccess(); return; } /* Determine PIN length from hash prefix length (we store length separately via the 'hub_pin_len' field ideally, but we default to 4 here) */ _pinLength = config.pinLength || 4; var theme = getModeTheme(config.mode || 'hub'); buildOverlay(theme, config.mode); /* Set email display */ var emailEl = document.getElementById('es-email-display'); if (emailEl) emailEl.textContent = maskEmail(config.userEmail); /* Show correct number of dots */ resetPinDots(); /* Check lockout */ var state = getLockout(config.userEmail); if (state.until > Date.now()) { startLockoutCountdown(config.userEmail); } wireListeners(); showScreen('pin'); } /** * ExoSecure.setupPin(config) * * Call from user settings panel to open PIN setup UI. * config = { userEmail, mode, onSuccess } */ function setupPin(config) { if (!config) return; _cfg = config; _cfg.hubPin = config.hubPin || null; /* may already have one */ var theme = getModeTheme(config.mode || 'hub'); buildOverlay(theme, config.mode); var emailEl = document.getElementById('es-email-display'); if (emailEl) emailEl.textContent = maskEmail(config.userEmail); wireListeners(); showScreen('setup'); } /** * ExoSecure.clearPin(config) * * Removes PIN from DB, disables lock. Calls onSuccess when done. * config = { userEmail, onSuccess } */ async function clearPin(config) { if (!config || !config.userEmail) return; try { await clearUserPinInDb(config.userEmail); } catch (e) { /* ignore */ } sessionStorage.removeItem(SESSION_FLAG); if (typeof config.onSuccess === 'function') config.onSuccess(); } /* ───────────────────────────────────────────── EXPOSE PUBLIC API ───────────────────────────────────────────── */ window.ExoSecure = { init: init, setupPin: setupPin, clearPin: clearPin }; })(window);