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 = `<i class="ti ti-shield-lock"></i> 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<boolean>}
*/
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<boolean>}
*/
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<void>}
*/
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<boolean>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>}
*/
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<void>}
*/
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<boolean>}
*/
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<void>}
*/
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<void>}
*/
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<string, unknown>} */ (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<string|null>}
*/
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 = `<i class="ti ti-key"></i> ${opts.title}`;
msgEl.textContent = opts.message;
btnConfirm.innerHTML = opts.confirmText
? `<i class="ti ti-check"></i> ${opts.confirmText}`
: '<i class="ti ti-check"></i> 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<string|null>} 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<boolean>}
*/
async function requireParentPinOk(promptMessage, wrongPinToast) {
const pin = await requireParentPin(promptMessage, wrongPinToast);
return pin != null;
}
/**
* @returns {Promise<boolean>}
*/
async function requireParentPinForPremiumPanel() {
return requireParentPinOk(
'Enter your parent override PIN to open Premium & API settings.',
'Incorrect parent PIN.'
);
}
/**
* @param {Event} e
* @returns {Promise<void>}
*/
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<void>}
*/
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<void>}
*/
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<string, number>} 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 =
'<p class="text-secondary small mb-0">No per-site data yet. Counts appear as Elly blocks tabs by policy.</p>';
return;
}
topSitesList.innerHTML = rows
.map(
(r, i) => `
<div class="top-site">
<span>${i + 1}. ${escapeHtmlForUi(r.host)}</span>
<span>${r.count} blocks</span>
</div>`
)
.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.'
});
}
}