import '../lib/presets.js'; import { getApiBase, getPremiumPlansPageUrl, getLegalPagesBaseUrl, getEllyLicenseUrl, loginVrilsoft, verifyVrilsoftMfa, registerVrilsoftAccount, fetchAmIPremium, protectPlaintext, getJwt, setJwt, getLastLoginEmail, getProtectedCredentialPresence, EllyStorageKeys } from './ellyPremium.js'; import { getTopBlockedSitesForUi, tryPostQuietStatsReport } from '../lib/ellyQuietStats.js'; import { LEGACY_DEFAULT_PARENT_PIN, isAcceptableNewAccessPin, isAcceptableNewParentPin, isForbiddenAccessPin, isForbiddenParentPin, normalizePinDigits, stripSecretsFromStorageSnapshot, importContainsPlaintextParentPin } from '../lib/ellyAccessPin.js'; /** Show plaintext fields even when encrypted blobs exist (after Replace). */ const ellyKeyReveal = { apiKey: false, secret: false }; /** Pending MFA ticket from login (session memory only). */ let ellyPendingMfaTicket = null; /** Email used for the pending MFA login (greeting after verify). */ let ellyPendingMfaEmail = ''; // Initialize global charts object window.charts = window.charts || { dailyBlocks: null, timeSaved: null, category: null }; // Footer helper for Version // Update version from manifest function updateVersionFromManifest() { const manifest = chrome.runtime.getManifest(); const version = manifest.version; // Find and update version element in footer const footerInfo = document.querySelector('.footer-info'); if (footerInfo) { const versionSpan = footerInfo.querySelector('span:first-child'); if (versionSpan) { versionSpan.innerHTML = ` Elly v${version}`; } } } // Helper function for chart management function createOrUpdateChart(canvasId, chartType, data, options) { const canvas = document.getElementById(canvasId); if (!canvas) { console.error(`Canvas element with ID '${canvasId}' not found`); return null; } const ctx = canvas.getContext('2d'); // Check if chart already exists and destroy it if (window.charts[canvasId]) { try { window.charts[canvasId].destroy(); } catch (e) { console.log('Error destroying chart:', e); } window.charts[canvasId] = null; } // Also check Chart.js instances const existingChart = Chart.getChart(canvasId); if (existingChart) { try { existingChart.destroy(); } catch (e) { console.log('Error destroying existing chart:', e); } } // Create new chart window.charts[canvasId] = new Chart(ctx, { type: chartType, data: data, options: options }); return window.charts[canvasId]; } const DM_SESSION_KEY = 'ellyDmUnlocked'; const BLOCKLIST_SESSION_KEY = 'ellyBlocklistUnlocked'; const LOCAL_PARENT_PIN_KEY = 'ellyParentPinConfigured'; /** Parent / override PIN (block page, settings, data tools). Not the 8-digit extension access PIN. */ const PARENT_PIN_MIN_LEN = 8; const PARENT_PIN_MAX_LEN = 12; /** * Applies the visible tab without PIN checks (caller must authorize first when needed). * @param {string} tabId */ function switchTab(tabId) { const ellyPremiumPanel = document.getElementById('ellyPremiumPanel'); if (ellyPremiumPanel) { ellyPremiumPanel.hidden = true; } console.log('Switching to tab:', tabId); // Update active button document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); document.querySelector(`.tab-btn[data-tab="${tabId}"]`)?.classList.add('active'); // Show selected tab document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); if (content.id === `${tabId}-tab`) { content.classList.add('active'); } }); // Destroy charts when leaving stats tab if (tabId !== 'stats') { // Safely destroy all charts Object.keys(window.charts).forEach(chartKey => { if (window.charts[chartKey]) { try { window.charts[chartKey].destroy(); } catch (e) { console.log('Error destroying chart:', e); } window.charts[chartKey] = null; } }); // Also clean up any Chart.js instances if (typeof Chart !== 'undefined' && Chart.instances) { Object.values(Chart.instances).forEach(instance => { try { instance.destroy(); } catch (e) { console.log('Error destroying Chart.js instance:', e); } }); } } // Load data for the tab if needed if (tabId === 'stats') { loadStatistics(); } } /** * @param {string} tabId * @returns {Promise} */ async function requestSwitchTab(tabId) { if (tabId === 'blocklist') { const ok = await ensureBlocklistAccess( 'Enter your Parent PIN to open the Blocklist.', 'Incorrect Parent PIN. Blocklist stays locked.' ); if (!ok) { return false; } } switchTab(tabId); return true; } function initializeTabs() { const tabButtons = document.querySelectorAll('.tab-btn'); tabButtons.forEach(button => { button.addEventListener('click', () => { const tabId = button.dataset.tab; if (tabId) { requestSwitchTab(tabId).catch((err) => { console.error('Tab switch failed:', err); }); } }); }); } /** * @param {string} pin * @returns {boolean} */ function isValidParentPin(pin) { return typeof pin === 'string' && /^\d{8,12}$/.test(pin); } /** * @param {{ blocklistPinLock?: boolean, strictMode?: boolean } | null | undefined} settings * @returns {boolean} */ function isBlocklistPinLockActive(settings) { return !!(settings?.blocklistPinLock || settings?.strictMode); } /** * Grants a short-lived background token so storage shrinks of blockedSites are not reverted. * @param {string} [pin] Required when PIN lock / Strict Mode is active. * @returns {Promise} */ async function grantBlocklistMutationAuth(pin) { try { const response = await chrome.runtime.sendMessage({ action: 'authorizeBlocklistMutation', pin: pin || null }); return !!response?.success; } catch (err) { console.error('authorizeBlocklistMutation failed:', err); return false; } } /** * Revokes background mutation auth so further removals need a fresh Parent PIN. * @returns {Promise} */ async function revokeBlocklistMutationAuth() { try { await chrome.runtime.sendMessage({ action: 'revokeBlocklistMutationAuth' }); } catch (err) { console.error('revokeBlocklistMutationAuth failed:', err); } } /** * Updates the lock icon on the Blocklist nav button. * @param {boolean} locked */ function updateBlocklistTabLockIcon(locked) { const icon = document.getElementById('blocklistTabLockIcon'); if (icon) { icon.hidden = !locked; } } /** * True when sensitive areas currently require a Parent PIN (lock setting on + session not unlocked). * @returns {Promise} */ async function isSensitiveAreasLockedNow() { const data = await chrome.storage.sync.get(['settings']); if (!isBlocklistPinLockActive(data.settings)) { return false; } if (sessionStorage.getItem(BLOCKLIST_SESSION_KEY) !== '1') { return true; } try { const status = await chrome.runtime.sendMessage({ action: 'hasBlocklistMutationAuth' }); return !status?.active; } catch { return true; } } /** * Syncs the header Lock / Unlock button with the current lock state. * @returns {Promise} */ async function updateHeaderLockButton() { const btn = document.getElementById('btnHeaderLock'); const icon = document.getElementById('btnHeaderLockIcon'); const label = document.getElementById('btnHeaderLockLabel'); if (!btn || !icon || !label) { return; } const lockedNow = await isSensitiveAreasLockedNow(); const data = await chrome.storage.sync.get(['settings']); const pinLockOn = isBlocklistPinLockActive(data.settings); btn.classList.toggle('is-locked', lockedNow); btn.classList.toggle('is-unlocked', !lockedNow); btn.setAttribute('aria-pressed', lockedNow ? 'true' : 'false'); if (lockedNow) { icon.className = 'ti ti-lock'; label.textContent = 'Locked'; btn.title = 'Click to unlock sensitive areas with your Parent PIN'; } else if (pinLockOn) { icon.className = 'ti ti-lock-open'; label.textContent = 'Lock'; btn.title = 'Lock sensitive areas again (requires PIN to reopen)'; } else { icon.className = 'ti ti-lock-open'; label.textContent = 'Lock'; btn.title = 'Lock down Blocklist, whitelist, and data tools behind your Parent PIN'; } updateBlocklistTabLockIcon(pinLockOn); } /** * Locks sensitive areas: enables PIN lock, clears session unlocks, revokes mutation auth. * @returns {Promise} */ async function lockSensitiveAreasNow() { const data = await chrome.storage.sync.get(['settings']); const settings = { ...(data.settings || {}) }; settings.blocklistPinLock = true; await chrome.storage.sync.set({ settings }); sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); sessionStorage.removeItem(DM_SESSION_KEY); await revokeBlocklistMutationAuth(); updateSecurityToggleUi(settings); updateDataMgmtLockUI(); if (document.getElementById('blocklist-tab')?.classList.contains('active')) { switchTab('settings'); } await updateHeaderLockButton(); showEllyToast('Locked. Parent PIN is required for Blocklist and other sensitive areas.', 'success'); } /** * Unlocks sensitive areas for this options session after Parent PIN verification. * @returns {Promise} */ async function unlockSensitiveAreasWithPin() { const pin = await requireParentPin( 'Enter your Parent PIN to unlock sensitive areas for this session.', 'Incorrect Parent PIN. Still locked.' ); if (pin == null) { return; } const granted = await grantBlocklistMutationAuth(pin); if (!granted) { showEllyToast('Could not unlock. Try again.', 'warning'); return; } sessionStorage.setItem(BLOCKLIST_SESSION_KEY, '1'); sessionStorage.setItem(DM_SESSION_KEY, '1'); updateDataMgmtLockUI(); await updateHeaderLockButton(); showEllyToast('Unlocked for this session. Use Lock when you step away.', 'success'); } /** * Header Lock button: lock immediately, or unlock with Parent PIN. * @returns {Promise} */ async function onHeaderLockButtonClick() { const lockedNow = await isSensitiveAreasLockedNow(); if (lockedNow) { await unlockSensitiveAreasWithPin(); return; } await lockSensitiveAreasNow(); } /** * When PIN lock / Strict Mode is on and this session is not unlocked, leave Blocklist for Settings. * @returns {Promise} */ async function enforceBlocklistTabOnLoad() { const data = await chrome.storage.sync.get(['settings']); const locked = isBlocklistPinLockActive(data.settings); updateBlocklistTabLockIcon(locked); updateSecurityToggleUi(data.settings || {}); await updateHeaderLockButton(); if (!locked) { return; } if (sessionStorage.getItem(BLOCKLIST_SESSION_KEY) === '1') { return; } const blocklistTab = document.getElementById('blocklist-tab'); if (blocklistTab?.classList.contains('active')) { switchTab('settings'); showEllyToast('Blocklist is PIN-locked. Enter your Parent PIN from the Blocklist tab or use Unlock.', 'info'); } } /** * @param {{ blocklistPinLock?: boolean, strictMode?: boolean }} settings */ function updateSecurityToggleUi(settings) { const pinLockEl = /** @type {HTMLInputElement|null} */ (document.getElementById('blocklistPinLock')); const strictEl = /** @type {HTMLInputElement|null} */ (document.getElementById('strictMode')); if (!pinLockEl || !strictEl) { return; } const strict = !!settings.strictMode; pinLockEl.checked = strict ? true : !!settings.blocklistPinLock; pinLockEl.disabled = strict; strictEl.checked = strict; updateBlocklistTabLockIcon(isBlocklistPinLockActive({ blocklistPinLock: pinLockEl.checked, strictMode: strict })); } /** * @param {string} promptMessage * @param {string} wrongPinToast * @returns {Promise} */ async function ensureBlocklistAccess(promptMessage, wrongPinToast) { const data = await chrome.storage.sync.get(['settings']); if (!isBlocklistPinLockActive(data.settings)) { await grantBlocklistMutationAuth(); return true; } if (sessionStorage.getItem(BLOCKLIST_SESSION_KEY) === '1') { try { const status = await chrome.runtime.sendMessage({ action: 'hasBlocklistMutationAuth' }); if (status?.active) { await grantBlocklistMutationAuth(); return true; } } catch (err) { console.error('hasBlocklistMutationAuth failed:', err); } sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); } const pin = await requireParentPin(promptMessage, wrongPinToast); if (pin == null) { return false; } const granted = await grantBlocklistMutationAuth(pin); if (!granted) { showEllyToast('Could not authorize Blocklist changes. Try again.', 'warning'); return false; } sessionStorage.setItem(BLOCKLIST_SESSION_KEY, '1'); await updateHeaderLockButton(); return true; } document.addEventListener('DOMContentLoaded', () => { initializeTabs(); setupEventListeners(); initEllyPremiumUi(); ensureAccessPinGate() .then((ok) => { if (!ok) { return; } return loadAllData().then(() => enforceBlocklistTabOnLoad()); }) .catch((err) => { console.error('Initial load failed:', err); }); // Auto-save every 30 seconds (only meaningful after unlock) setInterval(() => { if (sessionStorage.getItem('ellyAccessUnlocked') === '1') { saveAllSettings(); } }, 30000); }); window.addEventListener('pagehide', () => { try { chrome.runtime.sendMessage({ action: 'lockAccessSession' }).catch(() => {}); } catch { /* ignore */ } }); /** * Shows or hides an options modal overlay. * @param {string} id * @param {boolean} visible * @returns {void} */ function setModalVisible(id, visible) { const modal = document.getElementById(id); if (!modal) { return; } modal.classList.toggle('modal-overlay--visible', visible); } /** * Blocks main options UI until Access PIN is configured and verified for this SW session. * @returns {Promise} */ async function ensureAccessPinGate() { const status = await chrome.runtime.sendMessage({ action: 'getAppStatus' }); const configured = !!(status?.configured ?? status?.pinSet); const verified = !!status?.verified; if (!configured) { setModalVisible('accessPinUnlockModal', false); setModalVisible('accessPinSetupModal', true); return false; } if (!verified) { setModalVisible('accessPinSetupModal', false); setModalVisible('accessPinUnlockModal', true); return false; } setModalVisible('accessPinSetupModal', false); setModalVisible('accessPinUnlockModal', false); sessionStorage.setItem('ellyAccessUnlocked', '1'); return true; } /** * Saves a new Access PIN from the setup modal (unconfigured installs). * @returns {Promise} */ async function saveAccessPinSetupFromModal() { const errEl = document.getElementById('accessPinSetupError'); const a = normalizePinDigits(document.getElementById('accessPinSetupNew')?.value); const b = normalizePinDigits(document.getElementById('accessPinSetupConfirm')?.value); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } if (!isAcceptableNewAccessPin(a)) { if (errEl) { errEl.textContent = isForbiddenAccessPin(a) ? 'That PIN is not allowed. Choose a different 8-digit PIN.' : 'Enter an 8-digit Access PIN.'; errEl.hidden = false; } return; } if (a !== b) { if (errEl) { errEl.textContent = 'PINs do not match.'; errEl.hidden = false; } return; } const response = await chrome.runtime.sendMessage({ action: 'setNewPIN', newPin: a }); if (!response?.success) { if (errEl) { errEl.textContent = response?.error || 'Could not save Access PIN'; errEl.hidden = false; } return; } const n = /** @type {HTMLInputElement|null} */ (document.getElementById('accessPinSetupNew')); const c = /** @type {HTMLInputElement|null} */ (document.getElementById('accessPinSetupConfirm')); if (n) n.value = ''; if (c) c.value = ''; setModalVisible('accessPinSetupModal', false); sessionStorage.setItem('ellyAccessUnlocked', '1'); await loadAllData(); await enforceBlocklistTabOnLoad(); } /** * Unlocks options with verifyAccessPin. * @returns {Promise} */ async function unlockAccessPinFromModal() { const errEl = document.getElementById('accessPinUnlockError'); const pin = normalizePinDigits(document.getElementById('accessPinUnlockInput')?.value); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } const response = await chrome.runtime.sendMessage({ action: 'verifyAccessPin', pin }); if (!response?.success) { if (errEl) { errEl.textContent = response?.error || 'Incorrect Access PIN'; errEl.hidden = false; } return; } const input = /** @type {HTMLInputElement|null} */ (document.getElementById('accessPinUnlockInput')); if (input) { input.value = ''; } setModalVisible('accessPinUnlockModal', false); sessionStorage.setItem('ellyAccessUnlocked', '1'); await loadAllData(); await enforceBlocklistTabOnLoad(); } /** * Changes Access PIN via resetPIN (current + new + confirm). * @returns {Promise} */ async function changeAccessPinFromSecurity() { const errEl = document.getElementById('accessPinChangeError'); const oldPin = normalizePinDigits(document.getElementById('accessPinCurrent')?.value); const newPin = normalizePinDigits(document.getElementById('accessPinNew')?.value); const confirm = normalizePinDigits(document.getElementById('accessPinConfirm')?.value); if (errEl) { errEl.hidden = true; errEl.textContent = ''; } if (!isAcceptableNewAccessPin(newPin)) { if (errEl) { errEl.textContent = isForbiddenAccessPin(newPin) ? 'That PIN is not allowed. Choose a different 8-digit PIN.' : 'New Access PIN must be 8 digits.'; errEl.hidden = false; } return; } if (newPin !== confirm) { if (errEl) { errEl.textContent = 'New PINs do not match.'; errEl.hidden = false; } return; } const response = await chrome.runtime.sendMessage({ action: 'resetPIN', oldPin, newPin }); if (!response?.success) { if (errEl) { errEl.textContent = response?.error || 'Could not change Access PIN'; errEl.hidden = false; } showEllyToast(response?.error || 'Could not change Access PIN', 'warning'); return; } ['accessPinCurrent', 'accessPinNew', 'accessPinConfirm'].forEach((id) => { const el = /** @type {HTMLInputElement|null} */ (document.getElementById(id)); if (el) { el.value = ''; } }); showEllyToast('Access PIN updated.', 'success'); } /** * Opera search-page access instruction is plain text (no navigable extensions link). * @returns {void} */ function setupOperaExtensionsDetailsLink() { /* no-op: do not open chrome://extensions from Options */ } async function loadAllData() { try { const data = await chrome.storage.sync.get(null); // Update version from manifest first updateVersionFromManifest(); // Load blocklist loadBlocklist(data.blockedSites || []); loadWhitelist(data.whitelist || []); // Load time limits loadTimeLimits(data.limitedSites || {}); // Load schedules loadSchedules(data.schedules || []); // Load settings loadSettings(data.settings || {}); // Load focus sessions if (data.focusSessions) { updateFocusSessionUI(data.focusSessions); } // Update last sync time document.getElementById('lastSync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`; await initDataMgmtAccess(); } catch (error) { console.error('Error loading data:', error); await openEllyAlertModal({ title: 'Could not load settings', message: 'Error loading settings. Please try again.' }); } } async function migrateParentPinConfigured() { const local = await chrome.storage.local.get([LOCAL_PARENT_PIN_KEY]); if (local[LOCAL_PARENT_PIN_KEY]) { return; } try { const status = await chrome.runtime.sendMessage({ action: 'getAppStatus' }); if (status?.parentPinConfigured) { await chrome.storage.local.set({ [LOCAL_PARENT_PIN_KEY]: true }); } } catch (err) { console.warn('Elly: parent PIN configured check failed', err); } } function updateBlockThemePreview() { const sel = document.getElementById('themeSelect'); if (!sel) { return; } const v = sel.value; sel.setAttribute('data-block-theme', v); document.querySelectorAll('.preview-chip').forEach((chip) => { chip.classList.toggle('preview-chip--active', chip.dataset.theme === v); }); } function updateSyncFrequencyHint() { const sel = document.getElementById('syncFrequency'); const hint = document.getElementById('syncFrequencyHint'); if (!sel || !hint) { return; } const map = { instant: 'Changes propagate as soon as your browser syncs (typically within minutes).', hourly: 'About once per hour across your signed-in devices.', daily: 'About once per day across your signed-in devices.' }; hint.textContent = map[sel.value] || ''; } function updateDataMgmtLockUI() { const unlocked = sessionStorage.getItem(DM_SESSION_KEY) === '1'; ['exportData', 'importData', 'resetData', 'syncFrequency'].forEach((id) => { const el = document.getElementById(id); if (el) { el.disabled = !unlocked; } }); const unlockBtn = document.getElementById('unlockDataMgmt'); if (unlockBtn) { unlockBtn.style.display = unlocked ? 'none' : 'inline-flex'; } } async function initDataMgmtAccess() { await migrateParentPinConfigured(); const local = await chrome.storage.local.get([LOCAL_PARENT_PIN_KEY]); const modal = document.getElementById('initialPinModal'); if (!local[LOCAL_PARENT_PIN_KEY] && modal) { modal.classList.add('modal-overlay--visible'); updateDataMgmtLockUI(); return; } updateDataMgmtLockUI(); } async function assertDataMgmtUnlocked() { if (sessionStorage.getItem(DM_SESSION_KEY) === '1') { return true; } await openEllyAlertModal({ title: 'Data Management locked', message: 'Unlock Data Management with your Parent PIN first (button in that section).' }); return false; } async function saveInitialParentPin() { const errEl = document.getElementById('initialPinError'); const a = document.getElementById('initialPinNew')?.value?.trim() || ''; const b = document.getElementById('initialPinConfirm')?.value?.trim() || ''; if (errEl) { errEl.hidden = true; errEl.textContent = ''; } if (!isAcceptableNewParentPin(a)) { if (errEl) { errEl.textContent = isForbiddenParentPin(a) ? `Choose a PIN other than the default ${LEGACY_DEFAULT_PARENT_PIN}.` : `Enter ${PARENT_PIN_MIN_LEN}–${PARENT_PIN_MAX_LEN} numeric digits only.`; errEl.hidden = false; } return; } if (a !== b) { if (errEl) { errEl.textContent = 'PINs do not match.'; errEl.hidden = false; } return; } try { const response = await chrome.runtime.sendMessage({ action: 'setParentPin', currentPin: '', newPin: a }); if (!response?.success) { if (errEl) { errEl.textContent = response?.error || 'Could not save Parent PIN.'; errEl.hidden = false; } return; } } catch (err) { if (errEl) { errEl.textContent = err?.message || 'Could not save Parent PIN.'; errEl.hidden = false; } return; } await chrome.storage.local.set({ [LOCAL_PARENT_PIN_KEY]: true }); sessionStorage.setItem(DM_SESSION_KEY, '1'); const pinField = document.getElementById('overridePin'); if (pinField) { pinField.value = ''; pinField.placeholder = 'unchanged'; } const modal = document.getElementById('initialPinModal'); if (modal) { modal.classList.remove('modal-overlay--visible'); } updateDataMgmtLockUI(); } /** * Blocklist label for parental preset: real URL stays in storage; UI shows prohibited-N only. * @param {string} storedPattern * @returns {string} */ function getBlocklistDisplayLabel(storedPattern) { const presets = typeof window !== 'undefined' && window.ELLY_PRESETS && Array.isArray(window.ELLY_PRESETS.prohibited) ? window.ELLY_PRESETS.prohibited : []; const idx = presets.indexOf(storedPattern); if (idx >= 0) { return `prohibited-${idx + 1}`; } return storedPattern; } function loadBlocklist(blockedSites) { const blocklistElement = document.getElementById('blocklist'); const blockCount = document.getElementById('blockCount'); blocklistElement.innerHTML = ''; blockCount.textContent = blockedSites.length; blockedSites.forEach((site) => { const li = document.createElement('li'); const span = document.createElement('span'); span.className = 'blocklist-site-label'; const label = getBlocklistDisplayLabel(site); span.textContent = label; if (label !== site) { span.title = 'Parental block (label hidden)'; } const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'remove-btn'; btn.dataset.site = site; btn.textContent = 'Remove'; li.appendChild(span); li.appendChild(btn); blocklistElement.appendChild(li); }); document.querySelectorAll('#blocklist .remove-btn').forEach((btn) => { btn.addEventListener('click', async (e) => { const site = e.currentTarget.dataset.site; if (site) { await removeFromBlocklist(site); } }); }); } function loadWhitelist(whitelist) { const whitelistElement = document.getElementById('whitelist'); const countEl = document.getElementById('whitelistCount'); if (countEl) { countEl.textContent = whitelist.length; } whitelistElement.replaceChildren(); whitelist.forEach((site) => { const li = document.createElement('li'); const span = document.createElement('span'); span.textContent = site; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'remove-btn'; btn.dataset.site = site; btn.textContent = 'Remove'; li.appendChild(span); li.appendChild(btn); whitelistElement.appendChild(li); }); document.querySelectorAll('#whitelist .remove-btn').forEach((btn) => { btn.addEventListener('click', async (e) => { const site = e.currentTarget.dataset.site; if (site) { await removeFromWhitelist(site); } }); }); } async function removeFromBlocklist(site) { const ok = await ensureBlocklistAccess( 'Enter your Parent PIN to remove a site from the Blocklist.', 'Incorrect Parent PIN. Site was not removed.' ); if (!ok) { return; } const data = await chrome.storage.sync.get(['blockedSites']); const blockedSites = (data.blockedSites || []).filter(s => s !== site); await chrome.storage.sync.set({ blockedSites }); loadBlocklist(blockedSites); } async function removeFromWhitelist(site) { const ok = await ensureBlocklistAccess( 'Enter your Parent PIN to change the whitelist (it bypasses the Blocklist).', 'Incorrect Parent PIN. Whitelist was not changed.' ); if (!ok) { return; } const data = await chrome.storage.sync.get(['whitelist']); const whitelist = (data.whitelist || []).filter(s => s !== site); await chrome.storage.sync.set({ whitelist }); loadWhitelist(whitelist); } function loadTimeLimits(limitedSites) { const limitsList = document.getElementById('limitsList'); const limitsCount = document.getElementById('limitsCount'); limitsList.replaceChildren(); limitsCount.textContent = Object.keys(limitedSites).length; for (const [domain, limit] of Object.entries(limitedSites)) { const limitCard = document.createElement('div'); limitCard.className = 'limit-card'; const header = document.createElement('div'); header.className = 'limit-header'; const domainEl = document.createElement('span'); domainEl.className = 'limit-domain'; domainEl.textContent = domain; const timeEl = document.createElement('span'); timeEl.className = 'limit-time'; timeEl.textContent = `${limit.limitMinutes} min/day`; header.appendChild(domainEl); header.appendChild(timeEl); const progressContainer = document.createElement('div'); progressContainer.className = 'progress-container'; const progressBar = document.createElement('div'); progressBar.className = 'progress-bar'; progressBar.style.width = '50%'; progressContainer.appendChild(progressBar); const usageInfo = document.createElement('div'); usageInfo.className = 'usage-info'; const used = document.createElement('span'); used.textContent = 'Used: 0 min'; const sep = document.createElement('span'); sep.textContent = ' | '; const remaining = document.createElement('span'); remaining.textContent = `Remaining: ${limit.limitMinutes} min`; usageInfo.appendChild(used); usageInfo.appendChild(sep); usageInfo.appendChild(remaining); const actions = document.createElement('div'); actions.className = 'limit-actions'; const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'btn-secondary'; editBtn.dataset.domain = domain; editBtn.textContent = 'Edit'; const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'btn-danger'; removeBtn.dataset.domain = domain; removeBtn.textContent = 'Remove'; actions.appendChild(editBtn); actions.appendChild(removeBtn); limitCard.appendChild(header); limitCard.appendChild(progressContainer); limitCard.appendChild(usageInfo); limitCard.appendChild(actions); limitsList.appendChild(limitCard); } document.querySelectorAll('.limit-actions .btn-danger').forEach((btn) => { btn.addEventListener('click', async (e) => { const domain = e.currentTarget.dataset.domain; if (domain) { await removeTimeLimit(domain); } }); }); } async function removeTimeLimit(domain) { const data = await chrome.storage.sync.get(['limitedSites', 'settings']); if (isBlocklistPinLockActive(data.settings)) { const ok = await requireParentPinOk( 'Enter your Parent PIN to remove this time limit.', 'Incorrect Parent PIN. Time limit was not removed.' ); if (!ok) { return; } } const limitedSites = { ...(data.limitedSites || {}) }; delete limitedSites[domain]; await chrome.storage.sync.set({ limitedSites }); loadTimeLimits(limitedSites); } function loadSchedules(schedules) { const schedulesList = document.getElementById('schedulesList'); schedulesList.innerHTML = ''; schedules.forEach(schedule => { const scheduleCard = createScheduleCard(schedule); schedulesList.appendChild(scheduleCard); }); } function createScheduleCard(schedule) { const card = document.createElement('div'); card.className = 'schedule-card'; card.dataset.id = schedule.id; const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const header = document.createElement('div'); header.className = 'schedule-header'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.className = 'schedule-name'; nameInput.value = schedule.name || 'New Schedule'; const toggleLabel = document.createElement('label'); toggleLabel.className = 'toggle'; const enabledInput = document.createElement('input'); enabledInput.type = 'checkbox'; enabledInput.className = 'schedule-enabled'; enabledInput.checked = !!schedule.enabled; const slider = document.createElement('span'); slider.className = 'toggle-slider'; toggleLabel.appendChild(enabledInput); toggleLabel.appendChild(slider); header.appendChild(nameInput); header.appendChild(toggleLabel); const body = document.createElement('div'); body.className = 'schedule-body'; const timeRange = document.createElement('div'); timeRange.className = 'time-range'; const timeLabel = document.createElement('label'); timeLabel.textContent = 'Time Range:'; const startInput = document.createElement('input'); startInput.type = 'time'; startInput.className = 'schedule-start'; startInput.value = schedule.startTime || '09:00'; const toSpan = document.createElement('span'); toSpan.textContent = 'to'; const endInput = document.createElement('input'); endInput.type = 'time'; endInput.className = 'schedule-end'; endInput.value = schedule.endTime || '17:00'; timeRange.appendChild(timeLabel); timeRange.appendChild(startInput); timeRange.appendChild(toSpan); timeRange.appendChild(endInput); const daysSelector = document.createElement('div'); daysSelector.className = 'days-selector'; const daysLabel = document.createElement('label'); daysLabel.textContent = 'Days:'; const daysButtons = document.createElement('div'); daysButtons.className = 'days-buttons'; for (let day = 0; day < 7; day++) { const dayBtn = document.createElement('button'); dayBtn.type = 'button'; dayBtn.className = 'day-btn'; if (schedule.days?.includes(day)) { dayBtn.classList.add('active'); } dayBtn.dataset.day = String(day); dayBtn.textContent = dayNames[day]; daysButtons.appendChild(dayBtn); } daysSelector.appendChild(daysLabel); daysSelector.appendChild(daysButtons); const scheduleType = document.createElement('div'); scheduleType.className = 'schedule-type'; const blockLabel = document.createElement('label'); const blockRadio = document.createElement('input'); blockRadio.type = 'radio'; blockRadio.name = `type_${schedule.id}`; blockRadio.value = 'block'; blockRadio.checked = schedule.type === 'block'; blockLabel.appendChild(blockRadio); blockLabel.appendChild(document.createTextNode(' Block all except:')); const allowLabel = document.createElement('label'); const allowRadio = document.createElement('input'); allowRadio.type = 'radio'; allowRadio.name = `type_${schedule.id}`; allowRadio.value = 'allow'; allowRadio.checked = schedule.type === 'allow'; allowLabel.appendChild(allowRadio); allowLabel.appendChild(document.createTextNode(' Allow all except:')); const urlsArea = document.createElement('textarea'); urlsArea.className = 'schedule-urls'; const urlList = (schedule.type === 'block' ? schedule.allowedSites : schedule.blockedSites) || []; urlsArea.value = Array.isArray(urlList) ? urlList.join('\n') : ''; scheduleType.appendChild(blockLabel); scheduleType.appendChild(allowLabel); scheduleType.appendChild(urlsArea); const actions = document.createElement('div'); actions.className = 'schedule-actions'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.className = 'btn-save'; saveBtn.textContent = 'Save'; const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'btn-delete'; deleteBtn.textContent = 'Delete'; actions.appendChild(saveBtn); actions.appendChild(deleteBtn); body.appendChild(timeRange); body.appendChild(daysSelector); body.appendChild(scheduleType); body.appendChild(actions); card.appendChild(header); card.appendChild(body); saveBtn.addEventListener('click', () => saveSchedule(card)); deleteBtn.addEventListener('click', () => deleteSchedule(schedule.id)); card.querySelectorAll('.day-btn').forEach((btn) => { btn.addEventListener('click', () => { btn.classList.toggle('active'); }); }); return card; } /** * @returns {Promise} */ async function requireEllySchedulePremiumOrBlock() { const pr = await fetchAmIPremium(); if (pr.isPremium) { return true; } showEllyToast('Schedules are a Premium feature. Sign in on Premium & API and subscribe to save.', 'warning'); const dlg = document.getElementById('ellyUpgradeModal'); const plansUrl = await getPremiumPlansPageUrl(); const link = document.getElementById('ellyUpgradeModalLink'); if (link) { link.href = plansUrl; } if (dlg && typeof dlg.showModal === 'function') { dlg.showModal(); } else { await openEllyAlertModal({ title: 'Premium required', message: 'Schedules require Elly Premium. Open Premium & API in the header, then subscribe on Vrilsoft.' }); } return false; } /** * @param {string} message * @param {'info'|'success'|'warning'} kind */ function showEllyToast(message, kind = 'info') { const host = document.getElementById('ellyToastHost'); if (!host) { return; } const el = document.createElement('div'); el.className = `elly-toast elly-toast--${kind}`; el.textContent = message; host.appendChild(el); requestAnimationFrame(() => { el.classList.add('elly-toast--show'); }); setTimeout(() => { el.classList.remove('elly-toast--show'); setTimeout(() => el.remove(), 400); }, 5200); } /** * Tabler-style informational modal (replaces window.alert). * @param {{ title?: string, message: string, confirmLabel?: string }} opts * @returns {Promise} */ function openEllyAlertModal(opts) { const title = opts.title ?? 'Notice'; const message = opts.message ?? ''; const confirmLabel = opts.confirmLabel ?? 'OK'; const dlg = document.getElementById('ellyAlertModal'); const titleEl = document.getElementById('ellyAlertModalTitle'); const textEl = document.getElementById('ellyAlertModalText'); const okBtn = document.getElementById('ellyAlertModalOk'); if (!dlg || !titleEl || !textEl || !okBtn) { return Promise.resolve(); } titleEl.textContent = ''; const titleIcon = document.createElement('i'); titleIcon.className = 'ti ti-info-circle'; titleEl.appendChild(titleIcon); titleEl.appendChild(document.createTextNode(` ${title}`)); textEl.textContent = message; okBtn.replaceChildren(); const okIcon = document.createElement('i'); okIcon.className = 'ti ti-check'; okBtn.appendChild(okIcon); okBtn.appendChild(document.createTextNode(` ${confirmLabel}`)); return new Promise((resolve) => { let done = false; const cleanup = () => { okBtn.removeEventListener('click', onOk); dlg.removeEventListener('cancel', onCancel); }; const finish = () => { if (done) { return; } done = true; dlg.close(); cleanup(); resolve(); }; const onOk = () => finish(); const onCancel = (e) => { e.preventDefault(); finish(); }; okBtn.addEventListener('click', onOk); dlg.addEventListener('cancel', onCancel); dlg.showModal(); }); } /** * Tabler-style confirm modal (replaces window.confirm). * @param {{ title?: string, message: string, confirmLabel?: string, cancelLabel?: string, danger?: boolean }} opts * @returns {Promise} */ function openEllyConfirmModal(opts) { const title = opts.title ?? 'Confirm'; const message = opts.message ?? ''; const confirmLabel = opts.confirmLabel ?? 'OK'; const cancelLabel = opts.cancelLabel ?? 'Cancel'; const danger = !!opts.danger; const dlg = document.getElementById('ellyConfirmModal'); const titleEl = document.getElementById('ellyConfirmModalTitle'); const textEl = document.getElementById('ellyConfirmModalText'); const okBtn = document.getElementById('ellyConfirmModalOk'); const cancelBtn = document.getElementById('ellyConfirmModalCancel'); if (!dlg || !titleEl || !textEl || !okBtn || !cancelBtn) { return Promise.resolve(false); } titleEl.textContent = ''; const titleIcon = document.createElement('i'); titleIcon.className = danger ? 'ti ti-alert-triangle' : 'ti ti-help'; titleEl.appendChild(titleIcon); titleEl.appendChild(document.createTextNode(` ${title}`)); textEl.textContent = message; okBtn.className = danger ? 'btn-danger' : 'btn-primary'; okBtn.replaceChildren(); const okIcon = document.createElement('i'); okIcon.className = 'ti ti-check'; okBtn.appendChild(okIcon); okBtn.appendChild(document.createTextNode(` ${confirmLabel}`)); cancelBtn.textContent = cancelLabel; return new Promise((resolve) => { let done = false; const cleanup = () => { okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); dlg.removeEventListener('cancel', onCancelEsc); }; const finish = (/** @type {boolean} */ v) => { if (done) { return; } done = true; dlg.close(); cleanup(); resolve(v); }; const onOk = () => finish(true); const onCancel = () => finish(false); const onCancelEsc = (e) => { e.preventDefault(); finish(false); }; okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); dlg.addEventListener('cancel', onCancelEsc); dlg.showModal(); }); } /** * Shows sign-in fields or the signed-in greeting block based on JWT. * @returns {Promise} */ async function syncEllyAuthPanels() { const signedOut = document.getElementById('ellyAuthSignedOut'); const signedIn = document.getElementById('ellyAuthSignedIn'); const greetingLine = document.getElementById('ellyGreetingLine'); if (!signedOut || !signedIn) { return; } const jwt = await getJwt(); const email = await getLastLoginEmail(); if (jwt) { signedOut.hidden = true; signedIn.hidden = false; if (greetingLine) { greetingLine.textContent = email ? `You're signed in as ${email}.` : "You're signed in to your Vril One account."; } } else { signedOut.hidden = false; signedIn.hidden = true; if (greetingLine) { greetingLine.textContent = ''; } } } /** * Hides plaintext API key/secret inputs when encrypted blobs exist; Replace reveals a field again. * Enables inputs only when JWT is present. Key block stays visible when Premium panel is open. * @returns {Promise} */ async function syncEllyKeyRowsVisibility() { const jwt = await getJwt(); const signedIn = !!jwt; const { hasApiKey, hasSecret } = await getProtectedCredentialPresence(); const keyIn = document.getElementById('ellyApiKeyRow'); const keyStored = document.getElementById('ellyApiKeyStoredRow'); const secIn = document.getElementById('ellyApiSecretRow'); const secStored = document.getElementById('ellyApiSecretStoredRow'); const keyPlain = /** @type {HTMLInputElement|null} */ (document.getElementById('ellyApiKeyPlain')); const secretPlain = /** @type {HTMLInputElement|null} */ (document.getElementById('ellyApiSecretPlain')); const saveKeys = /** @type {HTMLButtonElement|null} */ (document.getElementById('ellyBtnSaveKeys')); const hint = document.getElementById('ellyKeySignInHint'); const keyBlock = document.getElementById('ellyKeyBlock'); if (keyBlock) { keyBlock.hidden = false; } if (hint) { hint.hidden = signedIn; } const showKeyInput = !signedIn || !hasApiKey || ellyKeyReveal.apiKey; const showSecretInput = !signedIn || !hasSecret || ellyKeyReveal.secret; if (keyIn) { keyIn.hidden = !showKeyInput; } if (keyStored) { keyStored.hidden = !signedIn || showKeyInput; } if (secIn) { secIn.hidden = !showSecretInput; } if (secStored) { secStored.hidden = !signedIn || showSecretInput; } if (keyPlain) { keyPlain.disabled = !signedIn; } if (secretPlain) { secretPlain.disabled = !signedIn; } if (saveKeys) { saveKeys.disabled = !signedIn; } } /** * Shows or hides the MFA code UI after password login. * @param {boolean} visible * @returns {void} */ function setEllyMfaUiVisible(visible) { const block = document.getElementById('ellyMfaBlock'); if (block) { block.hidden = !visible; } if (!visible) { const codeEl = /** @type {HTMLInputElement|null} */ (document.getElementById('ellyMfaCode')); if (codeEl) { codeEl.value = ''; } ellyPendingMfaTicket = null; ellyPendingMfaEmail = ''; } } async function initEllyPremiumUi() { const baseInput = document.getElementById('ellyApiBase'); const panel = document.getElementById('ellyPremiumPanel'); const btnTop = document.getElementById('btnEllyPremium'); const upgrade = document.getElementById('ellyLinkUpgrade'); const btnSignIn = document.getElementById('ellyBtnSignIn'); const btnSignOut = document.getElementById('ellyBtnSignOut'); const statusLine = document.getElementById('ellyPremiumStatusLine'); const keyBlock = document.getElementById('ellyKeyBlock'); const saveKeys = document.getElementById('ellyBtnSaveKeys'); const modalClose = document.getElementById('ellyUpgradeModalClose'); const base = await getApiBase(); const plansUrlInit = await getPremiumPlansPageUrl(); if (baseInput) { baseInput.value = base; } if (upgrade) { upgrade.href = plansUrlInit; } const mlink = document.getElementById('ellyUpgradeModalLink'); if (mlink) { mlink.href = plansUrlInit; } const legalBase = await getLegalPagesBaseUrl(); document.getElementById('ellyRegLinkTos')?.setAttribute('href', `${legalBase}/Legal/Terms`); document.getElementById('ellyRegLinkRefund')?.setAttribute('href', `${legalBase}/Legal/Refund`); document.getElementById('ellyRegLinkPrivacy')?.setAttribute('href', `${legalBase}/Legal/Privacy`); const licenseUrl = await getEllyLicenseUrl(); document.getElementById('ellyRegLinkLicense')?.setAttribute('href', licenseUrl); document.getElementById('ellyLicenseFooterLink')?.setAttribute('href', licenseUrl); const regModal = /** @type {HTMLDialogElement|null} */ (document.getElementById('ellyRegisterModal')); const regErr = document.getElementById('ellyRegisterError'); document.getElementById('ellyBtnRegister')?.addEventListener('click', () => { if (regErr) { regErr.hidden = true; regErr.textContent = ''; } regModal?.showModal(); }); document.getElementById('ellyRegisterCancel')?.addEventListener('click', () => regModal?.close()); document.getElementById('ellyRegisterSubmit')?.addEventListener('click', async () => { const realName = document.getElementById('ellyRegRealName')?.value?.trim() || ''; const userName = document.getElementById('ellyRegUserName')?.value?.trim() || ''; const email = document.getElementById('ellyRegEmail')?.value?.trim() || ''; const mobile = document.getElementById('ellyRegMobile')?.value?.trim() || ''; const password = document.getElementById('ellyRegPassword')?.value || ''; const confirmPassword = document.getElementById('ellyRegPassword2')?.value || ''; const acceptTos = document.getElementById('ellyRegTos')?.checked === true; const acceptRefund = document.getElementById('ellyRegRefund')?.checked === true; const acceptPrivacy = document.getElementById('ellyRegPrivacy')?.checked === true; const acceptLicense = document.getElementById('ellyRegLicense')?.checked === true; if (regErr) { regErr.hidden = true; } if (!acceptLicense) { if (regErr) { regErr.textContent = 'You must accept the Elly Software License.'; regErr.hidden = false; } return; } const r = await registerVrilsoftAccount({ realName, userName, email, mobile, password, confirmPassword, acceptTos, acceptRefund, acceptPrivacy }); if (!r.ok) { if (regErr) { regErr.textContent = r.error || 'Registration failed.'; regErr.hidden = false; } return; } showEllyToast(r.message || 'Account created. Check your email to activate.', 'success'); ['ellyRegRealName', 'ellyRegUserName', 'ellyRegEmail', 'ellyRegMobile', 'ellyRegPassword', 'ellyRegPassword2'].forEach((id) => { const el = document.getElementById(id); if (el) { el.value = ''; } }); const cTos = document.getElementById('ellyRegTos'); const cRef = document.getElementById('ellyRegRefund'); const cPrv = document.getElementById('ellyRegPrivacy'); if (cTos) { cTos.checked = false; } if (cRef) { cRef.checked = false; } if (cPrv) { cPrv.checked = false; } regModal?.close(); }); btnTop?.addEventListener('click', async () => { const ok = await requireParentPinForPremiumPanel(); if (!ok) { return; } if (panel) { panel.hidden = !panel.hidden; } await refreshEllyPremiumLine(statusLine, keyBlock); }); btnSignIn?.addEventListener('click', async () => { const email = document.getElementById('ellyLoginEmail')?.value?.trim() || ''; const password = document.getElementById('ellyLoginPass')?.value || ''; if (!email || !password) { showEllyToast('Enter email and password to sign in.', 'warning'); return; } setEllyMfaUiVisible(false); const r = await loginVrilsoft(email, password); if (r.requiresMfaEnrollment) { showEllyToast( 'This account requires authenticator MFA enrollment. Enable MFA on your Vrilsoft / Vril One account, then sign in again.', 'warning' ); if (statusLine) { statusLine.textContent = 'MFA enrollment required — open Vrilsoft / Vril One account security to enable authenticator MFA, then return here.'; } return; } if (r.requiresMfa && r.mfaTicket) { ellyPendingMfaTicket = r.mfaTicket; ellyPendingMfaEmail = email; setEllyMfaUiVisible(true); showEllyToast('Enter your authenticator code to finish signing in.', 'info'); if (statusLine) { statusLine.textContent = 'MFA required — enter the code from your authenticator app.'; } return; } if (!r.ok) { showEllyToast(r.error || 'Login failed', 'warning'); return; } showEllyToast('Signed in to your Vril One account.', 'success'); const passEl = document.getElementById('ellyLoginPass'); if (passEl) { passEl.value = ''; } await syncEllyAuthPanels(); await refreshEllyPremiumLine(statusLine, keyBlock); }); document.getElementById('ellyBtnMfaVerify')?.addEventListener('click', async () => { const code = document.getElementById('ellyMfaCode')?.value?.trim() || ''; if (!ellyPendingMfaTicket) { showEllyToast('Sign in with email and password first to start MFA.', 'warning'); return; } if (!code) { showEllyToast('Enter your authenticator code.', 'warning'); return; } const r = await verifyVrilsoftMfa(ellyPendingMfaTicket, code, ellyPendingMfaEmail); if (!r.ok) { showEllyToast(r.error || 'MFA verification failed', 'warning'); return; } setEllyMfaUiVisible(false); showEllyToast('Signed in to your Vril One account.', 'success'); const passEl = document.getElementById('ellyLoginPass'); if (passEl) { passEl.value = ''; } await syncEllyAuthPanels(); await refreshEllyPremiumLine(statusLine, keyBlock); }); btnSignOut?.addEventListener('click', async () => { ellyKeyReveal.apiKey = false; ellyKeyReveal.secret = false; setEllyMfaUiVisible(false); await setJwt(null); const passEl = document.getElementById('ellyLoginPass'); if (passEl) { passEl.value = ''; } showEllyToast('Signed out from your Vril One account.', 'info'); await syncEllyAuthPanels(); await refreshEllyPremiumLine(statusLine, keyBlock); }); document.getElementById('ellyBtnReplaceApiKey')?.addEventListener('click', async () => { ellyKeyReveal.apiKey = true; await syncEllyKeyRowsVisibility(); }); document.getElementById('ellyBtnReplaceSecret')?.addEventListener('click', async () => { ellyKeyReveal.secret = true; await syncEllyKeyRowsVisibility(); }); saveKeys?.addEventListener('click', async () => { if (!(await getJwt())) { showEllyToast('Sign in above to encrypt & store keys.', 'warning'); return; } const k = document.getElementById('ellyApiKeyPlain')?.value || ''; const s = document.getElementById('ellyApiSecretPlain')?.value || ''; if (!k && !s) { showEllyToast('Enter at least an API key or secret.', 'warning'); return; } const out = {}; if (k) { const pk = await protectPlaintext(k); if (!pk.ok) { showEllyToast(pk.error || 'Could not protect API key', 'warning'); return; } out[EllyStorageKeys.encApiKey] = pk.protectedPayload; } if (s) { const ps = await protectPlaintext(s); if (!ps.ok) { showEllyToast(ps.error || 'Could not protect API secret', 'warning'); return; } out[EllyStorageKeys.encSecret] = ps.protectedPayload; } await chrome.storage.local.set(out); showEllyToast('Encrypted credentials saved in this extension.', 'success'); const pkInput = document.getElementById('ellyApiKeyPlain'); const psInput = document.getElementById('ellyApiSecretPlain'); if (pkInput) { pkInput.value = ''; } if (psInput) { psInput.value = ''; } ellyKeyReveal.apiKey = false; ellyKeyReveal.secret = false; await syncEllyKeyRowsVisibility(); }); modalClose?.addEventListener('click', () => { const dlg = document.getElementById('ellyUpgradeModal'); if (dlg && typeof dlg.close === 'function') { dlg.close(); } }); await syncEllyAuthPanels(); await syncEllyKeyRowsVisibility(); } /** * @param {HTMLElement | null} statusLine * @param {HTMLElement | null} keyBlock */ async function refreshEllyPremiumLine(statusLine, keyBlock) { try { let jwt = await getJwt(); if (keyBlock) { keyBlock.hidden = false; } if (!statusLine) { return; } if (!jwt) { statusLine.textContent = 'Not signed in. Sign in above to encrypt & store API keys.'; return; } const pr = await fetchAmIPremium(); jwt = await getJwt(); if (keyBlock) { keyBlock.hidden = false; } if (!jwt) { statusLine.textContent = 'Session expired — sign in again.'; return; } if (pr.error === 'not_logged_in') { statusLine.textContent = 'Not signed in.'; return; } if (pr.error) { statusLine.textContent = `Could not load premium status: ${pr.error}`; return; } const r = /** @type {Record} */ (pr.raw || {}); const planLabel = (typeof r.tierDisplayName === 'string' && r.tierDisplayName.trim()) || (typeof r.planKey === 'string' && r.planKey.trim()) || 'unknown'; statusLine.textContent = pr.isPremium ? `Premium active — plan: ${planLabel}${r.isPastDue ? ' (payment past due — update billing on Vrilsoft)' : ''}.` : 'Signed in — no active premium plan detected for this account (cart purchase or Stripe subscription).'; } finally { await syncEllyAuthPanels(); await syncEllyKeyRowsVisibility(); } } /** * @param {number} n * @returns {number} */ function clampPinBoxes(n) { return Math.max(1, Math.min(12, Number.isFinite(n) ? n : 4)); } /** * Opens the reusable Tabler-style PIN modal and returns entered digits. * @param {{ title: string, message: string, pinLength: number, minLength?: number, maxLength?: number, confirmText?: string }} opts * @returns {Promise} */ async function promptPinWithModal(opts) { const dialog = /** @type {HTMLDialogElement|null} */ (document.getElementById('ellyPinPromptModal')); const titleEl = document.getElementById('ellyPinPromptTitle'); const msgEl = document.getElementById('ellyPinPromptText'); const wrap = document.getElementById('ellyPinPromptInputs'); const errEl = document.getElementById('ellyPinPromptError'); const btnCancel = document.getElementById('ellyPinPromptCancel'); const btnConfirm = document.getElementById('ellyPinPromptConfirm'); if (!dialog || !titleEl || !msgEl || !wrap || !errEl || !btnCancel || !btnConfirm) { return null; } const pinLen = clampPinBoxes(opts.pinLength); const minLen = Math.max(1, Math.min(pinLen, opts.minLength ?? pinLen)); const maxLen = Math.max(minLen, Math.min(pinLen, opts.maxLength ?? pinLen)); titleEl.innerHTML = ` ${opts.title}`; msgEl.textContent = opts.message; btnConfirm.innerHTML = opts.confirmText ? ` ${opts.confirmText}` : ' Confirm'; errEl.hidden = true; errEl.textContent = ''; wrap.innerHTML = ''; wrap.style.setProperty('--pin-len', String(pinLen)); /** * @returns {HTMLInputElement[]} */ function inputs() { return Array.from(wrap.querySelectorAll('.elly-pin-digit')); } /** * @returns {string} */ function readPin() { return inputs().map(i => i.value || '').join(''); } /** * @param {number} fromIndex * @param {string} chars */ function applyChars(fromIndex, chars) { const list = inputs(); const clean = (chars || '').replace(/\D/g, ''); if (!clean) { return; } let idx = fromIndex; for (const ch of clean) { if (idx >= list.length) { break; } list[idx].value = ch; idx++; } const next = list[Math.min(idx, list.length - 1)]; next?.focus(); next?.select(); } for (let i = 0; i < pinLen; i++) { const box = document.createElement('input'); box.type = 'password'; box.inputMode = 'numeric'; box.autocomplete = 'off'; box.maxLength = 1; box.className = 'elly-pin-digit'; box.setAttribute('aria-label', `PIN digit ${i + 1}`); box.addEventListener('keydown', (e) => { if (e.key === 'Backspace') { e.preventDefault(); box.value = ''; const list = inputs(); const prev = list[i - 1]; if (prev) { prev.value = ''; prev.focus(); prev.select(); } return; } if (e.key === 'ArrowLeft') { e.preventDefault(); const prev = inputs()[i - 1]; prev?.focus(); prev?.select(); return; } if (e.key === 'ArrowRight') { e.preventDefault(); const next = inputs()[i + 1]; next?.focus(); next?.select(); return; } if (e.key.length === 1 && !/\d/.test(e.key)) { e.preventDefault(); } }); box.addEventListener('input', () => { const val = (box.value || '').replace(/\D/g, ''); if (!val) { box.value = ''; return; } if (val.length > 1) { box.value = ''; applyChars(i, val); return; } box.value = val[0]; const next = inputs()[i + 1]; if (next) { next.focus(); next.select(); } }); box.addEventListener('paste', (e) => { e.preventDefault(); const text = e.clipboardData?.getData('text') || ''; applyChars(i, text); }); wrap.appendChild(box); } const first = inputs()[0]; const settle = () => { first?.focus(); first?.select(); }; return await new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) return; settled = true; cleanup(); if (dialog.open) { dialog.close(); } resolve(value); }; const onCancelBtn = () => finish(null); const onDialogCancel = (e) => { e.preventDefault(); finish(null); }; const onConfirm = () => { const entered = readPin(); if (entered.length < minLen || entered.length > maxLen || !/^\d+$/.test(entered)) { errEl.textContent = minLen === maxLen ? `Enter all ${pinLen} digits to continue.` : `Enter ${minLen}–${maxLen} digits to continue.`; errEl.hidden = false; return; } errEl.hidden = true; finish(entered); }; const onWrapKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); onConfirm(); } }; const cleanup = () => { btnCancel.removeEventListener('click', onCancelBtn); btnConfirm.removeEventListener('click', onConfirm); dialog.removeEventListener('cancel', onDialogCancel); wrap.removeEventListener('keydown', onWrapKeyDown); }; btnCancel.addEventListener('click', onCancelBtn); btnConfirm.addEventListener('click', onConfirm); dialog.addEventListener('cancel', onDialogCancel); wrap.addEventListener('keydown', onWrapKeyDown); if (!dialog.open) { dialog.showModal(); } requestAnimationFrame(settle); }); } /** * @param {string} promptMessage * @param {string} wrongPinToast * @returns {Promise} Entered PIN on success; null if cancelled or wrong. */ async function requireParentPin(promptMessage, wrongPinToast) { const entered = await promptPinWithModal({ title: 'Parent PIN Required', message: promptMessage, pinLength: PARENT_PIN_MAX_LEN, minLength: PARENT_PIN_MIN_LEN, maxLength: PARENT_PIN_MAX_LEN, confirmText: 'Verify' }); if (entered == null) { return null; } const trimmed = normalizePinDigits(entered); if (!isValidParentPin(trimmed) || isForbiddenParentPin(trimmed)) { showEllyToast(wrongPinToast, 'warning'); return null; } try { const verified = await chrome.runtime.sendMessage({ action: 'verifyParentPinForBlocklist', pin: trimmed }); if (!verified?.success) { showEllyToast(verified?.error || wrongPinToast, 'warning'); return null; } return trimmed; } catch { showEllyToast(wrongPinToast, 'warning'); return null; } } /** * @param {string} promptMessage * @param {string} wrongPinToast * @returns {Promise} */ async function requireParentPinOk(promptMessage, wrongPinToast) { const pin = await requireParentPin(promptMessage, wrongPinToast); return pin != null; } /** * @returns {Promise} */ async function requireParentPinForPremiumPanel() { return requireParentPinOk( 'Enter your parent override PIN to open Premium & API settings.', 'Incorrect parent PIN.' ); } /** * @param {Event} e * @returns {Promise} */ async function onBlocklistPinLockChange(e) { const el = /** @type {HTMLInputElement} */ (e.target); const data = await chrome.storage.sync.get(['settings']); const settings = { ...(data.settings || {}) }; if (settings.strictMode) { el.checked = true; showEllyToast('Strict Mode keeps PIN lock on. Turn off Strict Mode first.', 'warning'); return; } if (!el.checked) { const pin = await requireParentPin( 'Enter your Parent PIN to disable Blocklist PIN lock.', 'Incorrect Parent PIN. PIN lock stays on.' ); if (pin == null) { el.checked = true; return; } sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); const response = await chrome.runtime.sendMessage({ action: 'updateSecuritySettings', pin, settings: { blocklistPinLock: false } }); if (!response?.success) { el.checked = true; showEllyToast(response?.error || 'Could not authorize that change. Try again.', 'warning'); return; } updateSecurityToggleUi({ ...settings, blocklistPinLock: false }); await updateHeaderLockButton(); showEllyToast('Blocklist PIN lock disabled.', 'success'); return; } const response = await chrome.runtime.sendMessage({ action: 'updateSecuritySettings', settings: { blocklistPinLock: true } }); if (!response?.success) { el.checked = false; showEllyToast(response?.error || 'Could not enable PIN lock.', 'warning'); return; } sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); if (document.getElementById('blocklist-tab')?.classList.contains('active')) { switchTab('settings'); } updateSecurityToggleUi({ ...settings, blocklistPinLock: true }); await updateHeaderLockButton(); showEllyToast('Blocklist PIN lock enabled.', 'success'); } /** * @param {Event} e * @returns {Promise} */ async function onStrictModeChange(e) { const el = /** @type {HTMLInputElement} */ (e.target); const data = await chrome.storage.sync.get(['settings']); const settings = { ...(data.settings || {}) }; if (!el.checked) { const pin = await requireParentPin( 'Enter your Parent PIN to turn off Strict Mode.', 'Incorrect Parent PIN. Strict Mode stays on.' ); if (pin == null) { el.checked = true; return; } const response = await chrome.runtime.sendMessage({ action: 'updateSecuritySettings', pin, settings: { strictMode: false } }); if (!response?.success) { el.checked = true; showEllyToast(response?.error || 'Could not authorize that change. Try again.', 'warning'); return; } updateSecurityToggleUi({ ...settings, strictMode: false }); await updateHeaderLockButton(); showEllyToast('Strict Mode disabled.', 'success'); return; } const response = await chrome.runtime.sendMessage({ action: 'updateSecuritySettings', settings: { strictMode: true, blocklistPinLock: true } }); if (!response?.success) { el.checked = false; showEllyToast(response?.error || 'Could not enable Strict Mode.', 'warning'); return; } sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); if (document.getElementById('blocklist-tab')?.classList.contains('active')) { switchTab('settings'); } updateSecurityToggleUi({ ...settings, strictMode: true, blocklistPinLock: true }); await updateHeaderLockButton(); showEllyToast('Strict Mode enabled. Temporary overrides are off; Blocklist stays PIN-locked.', 'success'); } async function saveSchedule(cardElement) { const allow = await requireEllySchedulePremiumOrBlock(); if (!allow) { return; } const dataPre = await chrome.storage.sync.get(['settings']); if (isBlocklistPinLockActive(dataPre.settings)) { const pinOk = await requireParentPinOk( 'Enter your Parent PIN to save this schedule.', 'Incorrect Parent PIN. Schedule was not saved.' ); if (!pinOk) { return; } } const scheduleId = cardElement.dataset.id; const name = cardElement.querySelector('.schedule-name').value; const enabled = cardElement.querySelector('.schedule-enabled').checked; const startTime = cardElement.querySelector('.schedule-start').value; const endTime = cardElement.querySelector('.schedule-end').value; const type = cardElement.querySelector(`input[name="type_${scheduleId}"]:checked`).value; const urls = cardElement.querySelector('.schedule-urls').value .split('\n') .map(url => url.trim()) .filter(url => url); const days = Array.from(cardElement.querySelectorAll('.day-btn.active')) .map(btn => parseInt(btn.dataset.day)); const schedule = { id: scheduleId, name, enabled, startTime, endTime, type, days: days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6], [type === 'block' ? 'allowedSites' : 'blockedSites']: urls }; const data = await chrome.storage.sync.get(['schedules']); const schedules = data.schedules || []; const index = schedules.findIndex(s => s.id === scheduleId); if (index >= 0) { schedules[index] = schedule; } else { schedules.push(schedule); } await chrome.storage.sync.set({ schedules }); showEllyToast('Schedule saved.', 'success'); } async function deleteSchedule(scheduleId) { const ok = await openEllyConfirmModal({ title: 'Delete schedule', message: 'Are you sure you want to delete this schedule?', confirmLabel: 'Delete', danger: true }); if (!ok) { return; } const data = await chrome.storage.sync.get(['schedules', 'settings']); if (isBlocklistPinLockActive(data.settings)) { const pinOk = await requireParentPinOk( 'Enter your Parent PIN to delete this schedule.', 'Incorrect Parent PIN. Schedule was not deleted.' ); if (!pinOk) { return; } } const schedules = (data.schedules || []).filter(s => s.id !== scheduleId); await chrome.storage.sync.set({ schedules }); loadSchedules(schedules); } function loadSettings(settings) { // Security — never display stored Parent PIN in cleartext const overrideEl = /** @type {HTMLInputElement|null} */ (document.getElementById('overridePin')); if (overrideEl) { overrideEl.value = ''; overrideEl.placeholder = 'unchanged'; } updateSecurityToggleUi(settings); // Behavior document.getElementById('autoCloseTabs').checked = settings.autoCloseTabs !== false; document.getElementById('showNotifications').checked = settings.showNotifications !== false; document.getElementById('allowSearchEngineResults').checked = settings.allowSearchEngineResults !== false; const themeVal = settings.theme === 'dark' || settings.theme === 'blue' || settings.theme === 'light' ? settings.theme : 'light'; document.getElementById('themeSelect').value = themeVal; // Focus sessions document.getElementById('pomodoroMinutes').value = settings.pomodoroMinutes || 25; document.getElementById('breakMinutes').value = settings.breakMinutes || 5; // Sync frequency const syncVal = settings.syncFrequency === 'hourly' || settings.syncFrequency === 'daily' || settings.syncFrequency === 'instant' ? settings.syncFrequency : 'instant'; document.getElementById('syncFrequency').value = syncVal; updateBlockThemePreview(); updateSyncFrequencyHint(); } function updateFocusSessionUI(focusSessions) { const activeSession = focusSessions.find(s => s.active); if (activeSession) { document.getElementById('startFocus').textContent = `Focus Session Active (${activeSession.duration} min)`; document.getElementById('startFocus').disabled = true; } } function setupEventListeners() { // Blocklist management document.getElementById('addBlockUrl').addEventListener('click', addBlockUrl); document.getElementById('newBlockUrl').addEventListener('keypress', (e) => { if (e.key === 'Enter') addBlockUrl(); }); // Whitelist management document.getElementById('addWhiteUrl').addEventListener('click', addWhiteUrl); document.getElementById('newWhiteUrl').addEventListener('keypress', (e) => { if (e.key === 'Enter') addWhiteUrl(); }); // Time limits document.getElementById('addLimit').addEventListener('click', addTimeLimit); // Preset buttons document.querySelectorAll('.preset-btn').forEach(btn => { btn.addEventListener('click', (e) => { const preset = e.currentTarget?.dataset?.preset; if (preset) { applyPreset(preset); } }); }); // Add new schedule document.getElementById('addSchedule').addEventListener('click', addNewSchedule); // Settings document.getElementById('updatePin').addEventListener('click', updatePin); document.getElementById('resetAccessPin')?.addEventListener('click', () => { changeAccessPinFromSecurity().catch((err) => console.error(err)); }); document.getElementById('accessPinSetupSave')?.addEventListener('click', () => { saveAccessPinSetupFromModal().catch((err) => console.error(err)); }); document.getElementById('accessPinUnlockSubmit')?.addEventListener('click', () => { unlockAccessPinFromModal().catch((err) => console.error(err)); }); document.getElementById('accessPinUnlockInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { unlockAccessPinFromModal().catch((err) => console.error(err)); } }); document.getElementById('blocklistPinLock')?.addEventListener('change', (e) => { onBlocklistPinLockChange(e).catch((err) => console.error(err)); }); document.getElementById('strictMode')?.addEventListener('change', (e) => { onStrictModeChange(e).catch((err) => console.error(err)); }); document.getElementById('btnHeaderLock')?.addEventListener('click', () => { onHeaderLockButtonClick().catch((err) => console.error(err)); }); document.getElementById('exportData').addEventListener('click', () => exportData()); document.getElementById('importData').addEventListener('click', () => importData()); document.getElementById('resetData').addEventListener('click', () => resetData()); document.getElementById('startFocus').addEventListener('click', startFocusSession); document.getElementById('unlockDataMgmt')?.addEventListener('click', async () => { const pin = await requireParentPin( `Enter your parent PIN (${PARENT_PIN_MIN_LEN}–${PARENT_PIN_MAX_LEN} digits, same as Security → Parent PIN).`, 'Your parental Pin is required to unlock Data Management.' ); if (pin != null) { sessionStorage.setItem(DM_SESSION_KEY, '1'); await grantBlocklistMutationAuth(pin); updateDataMgmtLockUI(); await updateHeaderLockButton(); } }); document.getElementById('initialPinSave')?.addEventListener('click', () => saveInitialParentPin()); document.getElementById('themeSelect')?.addEventListener('change', () => { updateBlockThemePreview(); saveAllSettings(); }); document.getElementById('syncFrequency')?.addEventListener('change', () => { updateSyncFrequencyHint(); saveAllSettings(); }); // Statistics document.getElementById('exportStats').addEventListener('click', exportStatistics); document.getElementById('clearStats').addEventListener('click', clearStatistics); // Save all document.getElementById('saveAll').addEventListener('click', saveAllSettings); } async function addBlockUrl() { const input = document.getElementById('newBlockUrl'); const url = input.value.trim(); if (!url) { await openEllyAlertModal({ title: 'Blocklist', message: 'Please enter a URL' }); return; } const data = await chrome.storage.sync.get(['blockedSites']); const blockedSites = data.blockedSites || []; if (!blockedSites.includes(url)) { blockedSites.push(url); await chrome.storage.sync.set({ blockedSites }); loadBlocklist(blockedSites); input.value = ''; } else { await openEllyAlertModal({ title: 'Blocklist', message: 'This URL is already in the blocklist' }); } } async function addWhiteUrl() { const input = document.getElementById('newWhiteUrl'); const url = input.value.trim(); if (!url) { await openEllyAlertModal({ title: 'Whitelist', message: 'Please enter a URL' }); return; } const ok = await ensureBlocklistAccess( 'Enter your Parent PIN to add a whitelist bypass (it skips the Blocklist).', 'Incorrect Parent PIN. Whitelist was not changed.' ); if (!ok) { return; } const data = await chrome.storage.sync.get(['whitelist']); const whitelist = data.whitelist || []; if (!whitelist.includes(url)) { whitelist.push(url); await chrome.storage.sync.set({ whitelist }); loadWhitelist(whitelist); input.value = ''; } else { await openEllyAlertModal({ title: 'Whitelist', message: 'This URL is already in the whitelist' }); } } async function addTimeLimit() { const urlInput = document.getElementById('limitUrl'); const minutesInput = document.getElementById('limitMinutes'); const domain = urlInput.value.trim(); const minutes = parseInt(minutesInput.value); if (!domain || isNaN(minutes) || minutes < 1) { await openEllyAlertModal({ title: 'Time limits', message: 'Please enter a valid domain and time limit' }); return; } const data = await chrome.storage.sync.get(['limitedSites', 'settings']); if (isBlocklistPinLockActive(data.settings)) { const pinOk = await requireParentPinOk( 'Enter your Parent PIN to change time limits.', 'Incorrect Parent PIN. Time limit was not saved.' ); if (!pinOk) { return; } } const limitedSites = data.limitedSites || {}; limitedSites[domain] = { limitMinutes: minutes, added: new Date().toISOString() }; await chrome.storage.sync.set({ limitedSites }); loadTimeLimits(limitedSites); urlInput.value = ''; minutesInput.value = ''; } async function applyPreset(preset) { const presets = typeof window !== 'undefined' && window.ELLY_PRESETS ? window.ELLY_PRESETS : { social: [ '*.facebook.com', '*.twitter.com', '*.x.com', '*.instagram.com', '*.tiktok.com', '*.reddit.com', '*.linkedin.com', '*.discord.com', 'discord.com', 'vrilchat.com', '*.vrilchat.com', 'vrilone.com', '*.vrilone.com', ], entertainment: ['*.youtube.com', '*.netflix.com', '*.twitch.tv', '*.hulu.com', '*.disneyplus.com'], shopping: ['*.amazon.com', '*.ebay.com', '*.aliexpress.com', '*.etsy.com', '*.bestbuy.com'], gaming: ['*.steampowered.com', '*.epicgames.com', '*.xbox.com', '*.playstation.com'], news: ['*.cnn.com', '*.foxnews.com', '*.nytimes.com', '*.bbc.com', '*.reuters.com'], prohibited: [], }; const presetUrls = presets[preset]; if (!presetUrls) return; const data = await chrome.storage.sync.get(['blockedSites']); const blockedSites = data.blockedSites || []; const newUrls = presetUrls.filter(url => !blockedSites.includes(url)); if (newUrls.length > 0) { blockedSites.push(...newUrls); await chrome.storage.sync.set({ blockedSites }); loadBlocklist(blockedSites); await openEllyAlertModal({ title: 'Preset applied', message: `Added ${newUrls.length} ${preset} sites to blocklist` }); } else { await openEllyAlertModal({ title: 'Preset', message: `All ${preset} sites are already in your blocklist` }); } } function addNewSchedule() { const schedulesList = document.getElementById('schedulesList'); const newSchedule = { id: Date.now().toString(), name: 'New Schedule', enabled: true, startTime: '09:00', endTime: '17:00', type: 'block', days: [1, 2, 3, 4, 5], // Weekdays by default allowedSites: [] }; const scheduleCard = createScheduleCard(newSchedule); schedulesList.appendChild(scheduleCard); } async function updatePin() { const newPin = normalizePinDigits(document.getElementById('overridePin')?.value); if (!newPin) { showEllyToast('Enter a new Parent PIN, or leave blank to keep the current one.', 'warning'); return; } if (!isAcceptableNewParentPin(newPin)) { showEllyToast( isForbiddenParentPin(newPin) ? `Choose a PIN other than the default ${LEGACY_DEFAULT_PARENT_PIN}.` : `PIN must be ${PARENT_PIN_MIN_LEN}-${PARENT_PIN_MAX_LEN} digits (numbers only).`, 'warning' ); return; } let hasUsable = false; try { const status = await chrome.runtime.sendMessage({ action: 'getAppStatus' }); hasUsable = !!status?.parentPinConfigured; } catch { hasUsable = false; } let currentPin = ''; if (hasUsable) { const entered = await requireParentPin( 'Enter your current Parent PIN to set a new one.', 'Incorrect Parent PIN. Parent PIN was not changed.' ); if (entered == null) { return; } currentPin = entered; } try { const response = await chrome.runtime.sendMessage({ action: 'setParentPin', currentPin, newPin }); if (!response?.success) { showEllyToast(response?.error || 'Could not update Parent PIN.', 'warning'); return; } } catch (err) { showEllyToast(err?.message || 'Could not update Parent PIN.', 'warning'); return; } await chrome.storage.local.set({ [LOCAL_PARENT_PIN_KEY]: true }); const pinField = /** @type {HTMLInputElement|null} */ (document.getElementById('overridePin')); if (pinField) { pinField.value = ''; pinField.placeholder = 'unchanged'; } showEllyToast('Parent PIN updated successfully.', 'success'); } async function exportData() { if (!(await assertDataMgmtUnlocked())) { return; } chrome.storage.sync.get(null, (data) => { const safe = stripSecretsFromStorageSnapshot(data); const json = JSON.stringify(safe, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `nanny-settings-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } async function importData() { if (!(await assertDataMgmtUnlocked())) { return; } const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (importContainsPlaintextParentPin(data)) { await openEllyAlertModal({ title: 'Import refused', message: 'This file contains a plaintext Parent PIN. Remove it and export again from a current Elly build, or set the Parent PIN manually after importing a clean file.' }); return; } const proceed = await openEllyConfirmModal({ title: 'Import settings', message: 'This will overwrite all current settings (PIN hashes and JWT are never imported). Continue?', confirmLabel: 'Import', danger: true }); if (!proceed) { return; } const response = await chrome.runtime.sendMessage({ action: 'importSettings', data: stripSecretsFromStorageSnapshot(data) }); if (!response?.success) { await openEllyAlertModal({ title: 'Import failed', message: response?.error || 'Error importing settings.' }); return; } sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); await loadAllData(); await enforceBlocklistTabOnLoad(); await openEllyAlertModal({ title: 'Import complete', message: 'Settings imported successfully!' }); } catch (error) { await openEllyAlertModal({ title: 'Import failed', message: 'Error importing settings. Invalid file format.' }); } }; input.click(); } async function resetData() { if (!(await assertDataMgmtUnlocked())) { return; } const ok = await openEllyConfirmModal({ title: 'Reset all data', message: 'This will reset ALL settings and statistics. This cannot be undone!', confirmLabel: 'Reset everything', danger: true }); if (ok) { const defaultSettings = { blockedSites: Array.isArray(window.ELLY_DEFAULT_BLOCKED_SITES) ? [...window.ELLY_DEFAULT_BLOCKED_SITES] : [], limitedSites: {}, schedules: [], settings: { strictMode: true, blocklistPinLock: true, autoCloseTabs: true, showNotifications: true, theme: "light", allowSearchEngineResults: true, syncFrequency: "instant" }, statistics: {}, ellyBlockTallyByHost: {}, whitelist: [], focusSessions: [] }; await grantBlocklistMutationAuth(); await chrome.storage.sync.set(defaultSettings); await chrome.storage.local.remove([LOCAL_PARENT_PIN_KEY]); sessionStorage.removeItem(DM_SESSION_KEY); sessionStorage.removeItem(BLOCKLIST_SESSION_KEY); await loadAllData(); await enforceBlocklistTabOnLoad(); await openEllyAlertModal({ title: 'Reset complete', message: 'All settings have been reset to defaults (Strict Mode and PIN lock on). Set a new Parent PIN under Security. Your Access PIN is unchanged — change it under Security if needed.' }); } } async function startFocusSession() { const minutes = parseInt(document.getElementById('pomodoroMinutes').value); const breakMinutes = parseInt(document.getElementById('breakMinutes').value); const data = await chrome.storage.sync.get(['focusSessions']); const sessions = data.focusSessions || []; sessions.push({ start: new Date().toISOString(), duration: minutes, break: breakMinutes, active: true }); await chrome.storage.sync.set({ focusSessions: sessions }); updateFocusSessionUI(sessions); // Notify background script chrome.runtime.sendMessage({ action: 'startFocusSession', duration: minutes }); showEllyToast(`Focus session started for ${minutes} minutes!`, 'success'); // Timer to end session setTimeout(async () => { const data = await chrome.storage.sync.get(['focusSessions']); const updatedSessions = data.focusSessions || []; const currentSession = updatedSessions.find(s => s.active); if (currentSession) { currentSession.active = false; currentSession.end = new Date().toISOString(); await chrome.storage.sync.set({ focusSessions: updatedSessions }); updateFocusSessionUI(updatedSessions); await openEllyAlertModal({ title: 'Focus session ended', message: `Focus session ended! Take a ${breakMinutes} minute break.` }); // Start break timer setTimeout(async () => { await openEllyAlertModal({ title: 'Break over', message: 'Break time is over! Ready for another session?' }); }, breakMinutes * 60000); } }, minutes * 60000); } /** Min interval between successful quiet-stats POSTs when opening the Stats tab (ms). */ const QUIET_STATS_TAB_COOLDOWN_MS = 6 * 60 * 60 * 1000; /** * If signed in, occasionally POST stats so the server is not only updated on the daily alarm. * @returns {Promise} */ async function maybePostQuietStatsAfterStatsTabLoad() { try { const jwt = await getJwt(); if (!jwt) { return; } const { ellyLastQuietStatsPostAt } = await chrome.storage.local.get(['ellyLastQuietStatsPostAt']); const now = Date.now(); if (ellyLastQuietStatsPostAt && now - ellyLastQuietStatsPostAt < QUIET_STATS_TAB_COOLDOWN_MS) { return; } const ok = await tryPostQuietStatsReport(); if (ok) { await chrome.storage.local.set({ ellyLastQuietStatsPostAt: now }); } } catch { /* ignore */ } } /** * @param {string} s * @returns {string} */ function escapeHtmlForUi(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } async function loadStatistics() { const data = await chrome.storage.sync.get(['statistics', 'ellyBlockTallyByHost']); const stats = data.statistics || {}; // Calculate totals const totalBlocks = Object.values(stats).reduce((sum, day) => sum + (day.blocks || 0), 0); const totalTimeSaved = Object.values(stats).reduce((sum, day) => sum + (day.timeSaved || 0), 0); const daysActive = Object.keys(stats).length; document.getElementById('totalBlocks').textContent = totalBlocks; document.getElementById('totalTimeSaved').textContent = `${Math.floor(totalTimeSaved / 3600)}h`; document.getElementById('daysActive').textContent = daysActive; // Productivity score (0-100%) const productivityScore = Math.min(100, totalBlocks * 0.5); document.getElementById('productivityScore').textContent = `${productivityScore}%`; // Create charts if Chart.js is loaded if (typeof Chart !== 'undefined') { createCharts(stats); } // Load top sites (live tallies from background; same source as POST /i/v1/e/s) loadTopSites(data.ellyBlockTallyByHost || {}); void maybePostQuietStatsAfterStatsTabLoad(); } function createCharts(stats) { const last7Days = getLast7Days(); const dailyBlocksData = last7Days.map(day => stats[day]?.blocks || 0); const timeSavedData = last7Days.map(day => Math.floor((stats[day]?.timeSaved || 0) / 3600)); // Daily Blocks Chart createOrUpdateChart('dailyBlocksChart', 'bar', { labels: last7Days.map(d => new Date(d).toLocaleDateString('en-US', { weekday: 'short' })), datasets: [{ label: 'Blocks', data: dailyBlocksData, backgroundColor: 'rgba(102, 126, 234, 0.5)', borderColor: 'rgb(102, 126, 234)', borderWidth: 1 }] }, { responsive: true, plugins: { legend: { display: false } } }); // Time Saved Chart createOrUpdateChart('timeSavedChart', 'line', { labels: last7Days.map(d => new Date(d).toLocaleDateString('en-US', { weekday: 'short' })), datasets: [{ label: 'Hours Saved', data: timeSavedData, borderColor: 'rgb(46, 213, 115)', backgroundColor: 'rgba(46, 213, 115, 0.1)', fill: true, tension: 0.4 }] }, { responsive: true }); } function getLast7Days() { const days = []; for (let i = 6; i >= 0; i--) { const date = new Date(); date.setDate(date.getDate() - i); days.push(date.toDateString()); } return days; } /** * @param {Record} tally Per-host block counts (`ellyBlockTallyByHost` in sync storage). */ function loadTopSites(tally) { const topSitesList = document.getElementById('topSitesList'); if (!topSitesList) { return; } const rows = getTopBlockedSitesForUi(tally, 10); if (rows.length === 0) { topSitesList.innerHTML = '

No per-site data yet. Counts appear as Elly blocks tabs by policy.

'; return; } topSitesList.innerHTML = rows .map( (r, i) => `
${i + 1}. ${escapeHtmlForUi(r.host)} ${r.count} blocks
` ) .join(''); } function exportStatistics() { chrome.storage.sync.get(['statistics'], (data) => { const csv = convertToCSV(data.statistics); const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `nanny-stats-${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }); } function convertToCSV(stats) { const rows = [['Date', 'Blocks', 'Time Saved (seconds)', 'Time Saved (hours)']]; for (const [date, data] of Object.entries(stats)) { rows.push([ date, data.blocks || 0, data.timeSaved || 0, Math.floor((data.timeSaved || 0) / 3600) ]); } return rows.map(row => row.join(',')).join('\n'); } async function clearStatistics() { const ok = await requireParentPinOk( 'Enter your parent override PIN to clear all statistics.', 'Your parental Pin is required to remove stats.' ); if (!ok) { return; } await chrome.storage.sync.set({ statistics: {}, ellyBlockTallyByHost: {} }); loadStatistics(); showEllyToast('Statistics cleared.', 'success'); } async function saveAllSettings() { try { // Gather all settings from the UI const currentData = await chrome.storage.sync.get(['settings']); const prev = currentData.settings || {}; // Security toggles are saved only via dedicated handlers (PIN-gated). // Preserve stored values here so auto-save cannot bypass Strict Mode / PIN lock. // Parent PIN: never write overridePIN from the field — use setParentPin / Update PIN only. const settings = { strictMode: !!prev.strictMode, blocklistPinLock: !!(prev.strictMode || prev.blocklistPinLock), autoCloseTabs: document.getElementById('autoCloseTabs').checked, showNotifications: document.getElementById('showNotifications').checked, allowSearchEngineResults: document.getElementById('allowSearchEngineResults').checked, theme: document.getElementById('themeSelect').value, pomodoroMinutes: parseInt(document.getElementById('pomodoroMinutes').value), breakMinutes: parseInt(document.getElementById('breakMinutes').value), syncFrequency: document.getElementById('syncFrequency').value }; const updatedSettings = { ...prev, ...settings }; // Never re-persist Parent PIN material from options auto-save. delete updatedSettings.overridePIN; delete updatedSettings.overridePinHash; if (typeof prev.overridePinHash === 'string' && prev.overridePinHash) { updatedSettings.overridePinHash = prev.overridePinHash; } await chrome.storage.sync.set({ settings: updatedSettings }); updateSecurityToggleUi(updatedSettings); // Update last sync time document.getElementById('lastSync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`; console.log('All settings saved'); } catch (error) { console.error('Error saving settings:', error); await openEllyAlertModal({ title: 'Save failed', message: 'Error saving settings. Please try again.' }); } }