' /* .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);