import SubstitutionEngine from '../lib/substitution-engine.js';
import SmartPatterns from '../lib/smart-patterns.js';
import SecretScanner from '../lib/secret-scanner.js';
import AutoDetect from '../lib/auto-detect.js';
import Storage from '../lib/storage.js';
import SilentSendSync from '../lib/sync.js';
import SilentSendCrypto from '../lib/crypto.js';
import api from '../lib/browser-polyfill.js';
// --- State ---
let mappings = [];
let identity = {}; // merged identity (all active profiles)
let profiles = []; // all profiles
let currentProfileId = null; // currently selected profile for editing
let settings = {};
// --- DOM refs ---
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
// Check if locked BEFORE trying to read sensitive data
const locked = await Storage.isLocked();
if (locked) {
showLockedUI();
return;
}
await initUnlockedUI();
});
async function initUnlockedUI() {
// Hide locked overlay, show normal UI
$('#lockedOverlay').style.display = 'none';
mappings = await Storage.getMappings();
profiles = await Storage.getProfiles();
identity = await Storage.getIdentity();
settings = await Storage.getSettings();
// Initialize profiles — create default with first+last name rows
if (profiles.length === 0) {
const p = await Storage.addProfile('Personal');
// Pre-populate with first and last name rows
await Storage.updateProfile(p.id, {
names: [
{ real: '', substitute: '', type: 'first' },
{ real: '', substitute: '', type: 'last' },
],
});
profiles = await Storage.getProfiles();
}
currentProfileId = profiles[0].id;
renderProfileSelector();
loadIdentityForm();
renderMappings();
renderActivity();
updateStatusDot();
checkFirstRun();
$('#enableToggle').checked = settings.enabled;
// Tab switching
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
$$('.tab').forEach((t) => t.classList.remove('active'));
$$('.tab-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active');
$(`#tab-${tab.dataset.tab}`).classList.add('active');
if (tab.dataset.tab === 'activity') renderActivity();
if (tab.dataset.tab === 'test') {
// Reload merged identity from storage in case profiles were just saved
Storage.getIdentity().then((id) => {
identity = id;
updateIdentityStatus();
});
}
if (tab.dataset.tab === 'identity') {
// Reload profiles
Storage.getProfiles().then((p) => {
profiles = p;
renderProfileSelector();
loadIdentityForm();
});
}
});
});
// Enable toggle
$('#enableToggle').addEventListener('change', async (e) => {
settings.enabled = e.target.checked;
await Storage.saveSettings(settings);
updateStatusDot();
api.runtime.sendMessage({
type: 'update:settings',
settings,
});
});
// Reveal mode toggle
$('#btnReveal').addEventListener('click', async () => {
settings.revealMode = !settings.revealMode;
await Storage.saveSettings(settings);
$('#btnReveal').classList.toggle('active', settings.revealMode);
api.runtime.sendMessage({
type: 'update:settings',
settings,
});
});
$('#btnReveal').classList.toggle('active', settings.revealMode);
// Profile controls
$('#profileSelect').addEventListener('change', (e) => {
currentProfileId = e.target.value;
loadIdentityForm();
});
$('#btnAddProfile').addEventListener('click', async () => {
const name = prompt('Profile name:', `Profile ${profiles.length + 1}`);
if (!name) return;
const p = await Storage.addProfile(name);
// Pre-populate with first+last name rows
await Storage.updateProfile(p.id, {
names: [
{ real: '', substitute: '', type: 'first' },
{ real: '', substitute: '', type: 'last' },
],
});
profiles = await Storage.getProfiles();
currentProfileId = p.id;
renderProfileSelector();
loadIdentityForm();
checkFirstRun();
});
$('#btnRenameProfile').addEventListener('click', async () => {
const profile = profiles.find(p => p.id === currentProfileId);
if (!profile) return;
const name = prompt('Rename profile:', profile.name);
if (!name) return;
await Storage.updateProfile(currentProfileId, { name });
profiles = await Storage.getProfiles();
renderProfileSelector();
});
$('#btnDeleteProfile').addEventListener('click', async () => {
if (profiles.length <= 1) {
alert('Cannot delete the last profile.');
return;
}
const profile = profiles.find(p => p.id === currentProfileId);
if (!confirm(`Delete profile "${profile?.name}"?`)) return;
await Storage.deleteProfile(currentProfileId);
profiles = await Storage.getProfiles();
currentProfileId = profiles[0]?.id;
renderProfileSelector();
loadIdentityForm();
identity = await Storage.getIdentity();
checkFirstRun();
});
$('#profileActive').addEventListener('change', async (e) => {
await Storage.updateProfile(currentProfileId, { active: e.target.checked });
profiles = await Storage.getProfiles();
identity = await Storage.getIdentity();
renderProfileSelector();
checkFirstRun();
});
// Save identity
$('#btnSaveIdentity').addEventListener('click', saveIdentity);
// Add mapping
$('#btnAdd').addEventListener('click', addMapping);
$('#inputSub').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addMapping();
});
// Clear log
$('#btnClearLog').addEventListener('click', async () => {
await Storage.clearLog();
renderActivity();
});
// Test tab - live diff + reveal
$('#testInput').addEventListener('input', renderTestDiff);
$('#revealInput').addEventListener('input', renderRevealDiff);
$('#btnCopyRevealed').addEventListener('click', copyRevealedText);
// Test mode toggle (strip vs reveal)
$$('.test-mode-btn').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.test-mode-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
const mode = btn.dataset.mode;
$('#stripMode').style.display = mode === 'strip' ? 'block' : 'none';
$('#revealMode').style.display = mode === 'reveal' ? 'block' : 'none';
});
});
// Options link
$('#btnOptions').addEventListener('click', (e) => {
e.preventDefault();
api.runtime.openOptionsPage();
});
// Update privacy note based on encryption state
const encEnabled = await Storage._isAtRestEncryptionEnabled();
const encNote = $('#privacyEncNote');
if (encNote) {
encNote.style.display = encEnabled ? '' : 'none';
}
}
// --- Locked UI ---
async function showLockedUI() {
// Hide all normal UI elements
const lockedOverlay = $('#lockedOverlay');
lockedOverlay.style.display = 'block';
// Check if TOTP is configured
const encConfig = await SilentSendSync._getSyncEncryption();
if (encConfig?.totpSecret) {
$('#unlockTOTP').style.display = '';
}
// Check if WebAuthn is available
if (encConfig?.webauthn && SilentSendCrypto.isWebAuthnAvailable()) {
const hasCred = await SilentSendCrypto.hasWebAuthnCredential();
if (hasCred) {
$('#btnUnlockBiometric').style.display = '';
}
}
// Unlock with password (+ optional TOTP)
$('#btnUnlock').addEventListener('click', async () => {
const password = $('#unlockPassword').value;
const totpCode = $('#unlockTOTP').value;
if (!password) {
$('#unlockStatus').textContent = 'Enter your password.';
return;
}
$('#unlockStatus').textContent = 'Unlocking...';
$('#unlockStatus').style.color = '#6b7280';
const result = await SilentSendSync.authenticate(password, totpCode || undefined);
if (result.success) {
$('#unlockStatus').textContent = '';
// Notify background to clear LOCK badge
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
// Transition to normal UI
await initUnlockedUI();
} else {
$('#unlockStatus').textContent = result.reason;
$('#unlockStatus').style.color = '#dc2626';
}
});
// Enter key triggers unlock
$('#unlockPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btnUnlock').click();
});
$('#unlockTOTP').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btnUnlock').click();
});
// Unlock with biometric
$('#btnUnlockBiometric').addEventListener('click', async () => {
$('#unlockStatus').textContent = 'Waiting for biometric...';
$('#unlockStatus').style.color = '#6b7280';
const verified = await SilentSendCrypto.webAuthnAuthenticate();
if (verified) {
const ttlDays = encConfig?.ttlDays ?? 90;
await SilentSendCrypto.markVerified(ttlDays);
// The key should already be in IndexedDB from prior session
const cached = await SilentSendCrypto.getCachedKey();
if (cached) {
api.runtime.sendMessage({ type: 'vault:unlocked' }).catch(() => {});
await initUnlockedUI();
} else {
$('#unlockStatus').textContent = 'Key not found. Enter password.';
$('#unlockStatus').style.color = '#dc2626';
}
} else {
$('#unlockStatus').textContent = 'Biometric failed.';
$('#unlockStatus').style.color = '#dc2626';
}
});
// Focus password field
setTimeout(() => $('#unlockPassword').focus(), 100);
}
// --- Profiles ---
function renderProfileSelector() {
const select = $('#profileSelect');
select.innerHTML = profiles.map(p =>
``
).join('');
const profile = profiles.find(p => p.id === currentProfileId);
$('#profileActive').checked = profile?.active ?? true;
}
// --- Identity (dynamic multi-entry) ---
// Field configs for rendering
const FIELD_CONFIGS = {
names: {
container: 'idNames',
placeholderReal: 'Real name',
placeholderSub: 'Fake name',
typeOptions: [{ value: 'first', label: '1st' }, { value: 'last', label: 'Last' }, { value: 'middle', label: 'Mid' }, { value: 'nick', label: 'Nick' }],
defaultType: 'first',
},
emails: {
container: 'idEmails',
placeholderReal: 'you@gmail.com',
placeholderSub: 'fake@example.com',
},
usernames: {
container: 'idUsernames',
placeholderReal: 'jsmith',
placeholderSub: 'ademo',
},
hostnames: {
container: 'idHostnames',
placeholderReal: 'macbook-pro',
placeholderSub: 'mycomputer',
},
phones: {
container: 'idPhones',
placeholderReal: '(555) 123-4567',
placeholderSub: '(555) 000-0000',
},
};
function renderFieldList(fieldName, items) {
const config = FIELD_CONFIGS[fieldName];
const container = $(`#${config.container}`);
if (!container) return;
if (!items || items.length === 0) {
// Show one empty row
items = [{ real: '', substitute: '', type: config.defaultType || '' }];
}
container.innerHTML = items.map((item, i) => {
let typeHtml = '';
if (config.typeOptions) {
typeHtml = ``;
}
return `
${typeHtml}
→
`;
}).join('');
// Bind remove buttons
container.querySelectorAll('.btn-remove').forEach(btn => {
btn.addEventListener('click', () => {
const row = btn.closest('.id-entry-row');
if (container.querySelectorAll('.id-entry-row').length > 1) {
row.remove();
} else {
// Clear instead of remove if it's the last row
row.querySelectorAll('input').forEach(inp => inp.value = '');
}
});
});
}
function loadIdentityForm() {
const profile = profiles.find(p => p.id === currentProfileId);
if (!profile) return;
renderFieldList('names', profile.names || []);
renderFieldList('emails', profile.emails || []);
renderFieldList('usernames', profile.usernames || []);
renderFieldList('hostnames', profile.hostnames || []);
renderFieldList('phones', profile.phones || []);
$('#idCatchAllEmail').value = profile.catchAllEmail || '';
$('#profileActive').checked = profile.active ?? true;
// Bind add buttons
$$('.btn-add').forEach(btn => {
// Remove old listeners by cloning
const newBtn = btn.cloneNode(true);
btn.replaceWith(newBtn);
newBtn.addEventListener('click', () => {
const field = newBtn.dataset.field;
const config = FIELD_CONFIGS[field];
const container = $(`#${config.container}`);
const count = container.querySelectorAll('.id-entry-row').length;
const tempDiv = document.createElement('div');
let typeHtml = '';
if (config.typeOptions) {
// Alternate: if first row is 'first', next should be 'last', etc.
const defaultType = count % 2 === 0 ? 'first' : 'last';
typeHtml = ``;
}
tempDiv.innerHTML = `
${typeHtml}
→
`;
const row = tempDiv.firstElementChild;
container.appendChild(row);
row.querySelector('.btn-remove').addEventListener('click', () => {
if (container.querySelectorAll('.id-entry-row').length > 1) row.remove();
else row.querySelectorAll('input').forEach(inp => inp.value = '');
});
row.querySelector('.id-real').focus();
});
});
}
function escapeAttr(str) {
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/ {
const real = row.querySelector('.id-real')?.value.trim() || '';
const sub = row.querySelector('.id-sub')?.value.trim() || '';
if (real && sub) {
const entry = { real, substitute: sub };
const typeSelect = row.querySelector('.id-type-select');
if (typeSelect) entry.type = typeSelect.value;
entries.push(entry);
}
});
return entries;
}
// Track unsaved changes
let identityDirty = false;
function markDirty() {
if (!identityDirty) {
identityDirty = true;
const btn = $('#btnSaveIdentity');
btn.textContent = 'Save Identity *';
btn.style.background = '#b45309';
}
}
function markClean() {
identityDirty = false;
const btn = $('#btnSaveIdentity');
btn.textContent = 'Saved!';
btn.style.background = '#059669';
setTimeout(() => {
if (!identityDirty) {
btn.textContent = 'Save Identity';
btn.style.background = '';
}
}, 1500);
}
// Listen for input changes in identity tab to mark dirty
document.addEventListener('input', (e) => {
const target = e.target;
if (target.closest('#tab-identity')) {
markDirty();
}
}, true);
// Listen for Enter key in identity fields to save
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.closest('#tab-identity')) {
e.preventDefault();
saveIdentity();
}
}, true);
async function saveIdentity() {
const profileData = {
names: readFieldEntries('names'),
emails: readFieldEntries('emails'),
usernames: readFieldEntries('usernames'),
hostnames: readFieldEntries('hostnames'),
phones: readFieldEntries('phones'),
catchAllEmail: $('#idCatchAllEmail').value.trim(),
emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
await Storage.updateProfile(currentProfileId, profileData);
profiles = await Storage.getProfiles();
identity = await Storage.getIdentity();
checkFirstRun();
markClean();
}
// --- First-Run Check ---
function checkFirstRun() {
const hasNames = (identity.names || []).length > 0;
const hasEmails = (identity.emails || []).length > 0 || !!identity.catchAllEmail;
const hasUsernames = (identity.usernames || []).length > 0;
const hasMappings = mappings.length > 0;
// Show banner if nothing is configured at all
const isConfigured = hasNames || hasEmails || hasUsernames || hasMappings;
const banner = $('#firstRunBanner');
banner.style.display = isConfigured ? 'none' : 'flex';
// Also update the status dot — orange if unconfigured
const dot = $('#statusDot');
if (!isConfigured && settings.enabled) {
dot.classList.add('off-site');
dot.classList.remove('disabled');
} else {
dot.classList.remove('off-site');
}
}
// --- Add Mapping ---
async function addMapping() {
const real = $('#inputReal').value.trim();
const sub = $('#inputSub').value.trim();
const category = $('#inputCategory').value;
const caseSensitive = $('#inputCaseSensitive').checked;
if (!real || !sub) return;
const mapping = await Storage.addMapping({
real,
substitute: sub,
category,
caseSensitive,
});
mappings.push(mapping);
renderMappings();
// Clear inputs
$('#inputReal').value = '';
$('#inputSub').value = '';
$('#inputCaseSensitive').checked = false;
$('#inputReal').focus();
}
// --- Render Mappings ---
function renderMappings() {
const list = $('#mappingList');
if (mappings.length === 0) {
list.innerHTML = 'No mappings yet. Add your first one above.
';
return;
}
list.innerHTML = mappings
.map(
(m) => `
${m.category === 'password' ? '••••••••' : escapeHtml(m.real)}
→
${escapeHtml(m.substitute)}
${m.category}
`
)
.join('');
// Bind actions
list.querySelectorAll('.btn-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.closest('.mapping-item').dataset.id;
await Storage.deleteMapping(id);
mappings = mappings.filter((m) => m.id !== id);
renderMappings();
});
});
list.querySelectorAll('.btn-toggle').forEach((btn) => {
btn.addEventListener('click', async () => {
const item = btn.closest('.mapping-item');
const id = item.dataset.id;
const mapping = mappings.find((m) => m.id === id);
if (!mapping) return;
mapping.enabled = !mapping.enabled;
await Storage.updateMapping(id, { enabled: mapping.enabled });
renderMappings();
});
});
}
// --- Render Activity Log ---
async function renderActivity() {
const log = await Storage.getLog();
const list = $('#activityList');
const countEl = $('#sessionCount');
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
if (log.length === 0) {
list.innerHTML = 'No activity yet.
';
return;
}
list.innerHTML = log
.slice(0, 50)
.map((entry) => {
const time = new Date(entry.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
return `
${time}
${escapeHtml(entry.original)}
→
${escapeHtml(entry.replaced)}
`;
})
.join('');
}
// --- Test Diff (Strip: real → fake) ---
function renderTestDiff() {
const input = $('#testInput').value;
const output = $('#diffOutput');
const stats = $('#diffStats');
if (!input) {
output.innerHTML = '';
stats.textContent = '';
return;
}
const smartResult = SmartPatterns.substitute(input, identity);
const explicitResult = SubstitutionEngine.substitute(smartResult.text, mappings);
const secretResult = SecretScanner.redact(explicitResult.text);
const allReplacements = [
...smartResult.replacements,
...explicitResult.replacements,
...secretResult.redactions,
];
const finalText = secretResult.text;
if (finalText === input && secretResult.warnings.length === 0) {
output.textContent = input;
stats.textContent = 'No substitutions detected';
return;
}
let html = escapeHtml(finalText);
// Highlight identity + explicit substitutions in green
for (const r of [...smartResult.replacements, ...explicitResult.replacements]) {
const escapedReplaced = escapeHtml(r.replaced);
html = html.replace(
escapedReplaced,
`${escapedReplaced}`
);
}
// Highlight secret redactions in red
for (const r of secretResult.redactions) {
const escapedReplaced = escapeHtml(r.replaced);
html = html.replace(
escapedReplaced,
`${escapedReplaced}`
);
}
output.innerHTML = html;
const smartCount = smartResult.replacements.length;
const explicitCount = explicitResult.replacements.length;
const secretCount = secretResult.redactions.length;
const warnCount = secretResult.warnings.length;
const parts = [];
if (smartCount > 0) parts.push(`${smartCount} smart`);
if (explicitCount > 0) parts.push(`${explicitCount} explicit`);
if (secretCount > 0) parts.push(`${secretCount} secrets redacted`);
if (warnCount > 0) parts.push(`${warnCount} warnings`);
// Auto-detect unconfigured PPI in the final text
const ppiWarnings = AutoDetect.scan(finalText, identity);
if (ppiWarnings.length > 0) parts.push(`${ppiWarnings.length} PPI detected`);
stats.textContent = `${allReplacements.length} substitution${allReplacements.length !== 1 ? 's' : ''} (${parts.join(', ')})`;
// Show PPI warnings below stats
if (ppiWarnings.length > 0) {
stats.innerHTML += `
Unconfigured PPI detected:
${ppiWarnings.map(w => `
${escapeHtml(w.value)} — ${w.hint}
`).join('')}
`;
}
}
// --- Reveal Diff (fake → real) ---
function renderRevealDiff() {
const input = $('#revealInput').value;
const output = $('#revealOutput');
const stats = $('#revealStats');
if (!input) {
output.innerHTML = '';
stats.textContent = '';
return;
}
// Reverse: substitute → real using SmartPatterns reveal + explicit reveal
let result = input;
let totalCount = 0;
// Reverse explicit mappings (substitute → real)
const explicitRevealed = SubstitutionEngine.reveal(result, mappings);
// Count explicit reveals
for (const m of mappings) {
if (!m.enabled || !m.substitute) continue;
const regex = new RegExp(escapeRegex(m.substitute), m.caseSensitive ? 'g' : 'gi');
const matches = result.match(regex);
if (matches) totalCount += matches.length;
}
result = explicitRevealed;
// Reverse smart patterns (all identity substitutes → real)
const allSubs = gatherSmartSubstitutePairs(identity);
for (const pair of allSubs) {
const regex = new RegExp(escapeRegex(pair.substitute), 'gi');
const matches = result.match(regex);
if (matches) totalCount += matches.length;
result = result.replace(regex, pair.real);
}
if (result === input) {
output.textContent = input;
stats.textContent = 'No substituted values found to reveal';
return;
}
// Highlight revealed values
let html = escapeHtml(result);
for (const pair of [...allSubs, ...mappings.filter(m => m.enabled)]) {
const real = pair.real;
if (!real) continue;
const escapedReal = escapeHtml(real);
html = html.replace(
new RegExp(escapeRegex(escapedReal), 'gi'),
`${escapedReal}`
);
}
output.innerHTML = html;
stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`;
}
// Gather all substitute → real pairs from identity for reveal
function gatherSmartSubstitutePairs(id) {
const pairs = [];
for (const e of (id.emails || [])) {
if (e.substitute && e.real) pairs.push(e);
}
if (id.catchAllEmail) {
pairs.push({ substitute: id.catchAllEmail, real: '[catch-all]' });
}
for (const n of (id.names || [])) {
if (n.substitute && n.real) pairs.push(n);
}
for (const u of (id.usernames || [])) {
if (u.substitute && u.real) pairs.push(u);
}
for (const h of (id.hostnames || [])) {
if (h.substitute && h.real) pairs.push(h);
}
for (const p of (id.phones || [])) {
if (p.substitute && p.real) pairs.push(p);
}
return pairs;
}
async function copyRevealedText() {
const output = $('#revealOutput');
const text = output.textContent;
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const btn = $('#btnCopyRevealed');
btn.textContent = 'Copied!';
btn.style.background = '#059669';
setTimeout(() => {
btn.textContent = 'Copy to Clipboard';
btn.style.background = '';
}, 1500);
} catch (e) {
// Fallback
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
}
// --- Identity Status ---
function updateIdentityStatus() {
const el = $('#identityStatus');
const missing = [];
if (!(identity.names || []).some(n => n.type === 'first')) missing.push('first name');
if (!(identity.names || []).some(n => n.type === 'last')) missing.push('last name');
if ((identity.emails || []).length === 0 && !identity.catchAllEmail) missing.push('email');
if ((identity.usernames || []).length === 0) missing.push('username');
if (missing.length > 0) {
el.textContent = `Identity missing: ${missing.join(', ')}. Go to the Identity tab to set up.`;
el.classList.add('visible');
} else {
el.classList.remove('visible');
}
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// --- Status Dot ---
function updateStatusDot() {
const dot = $('#statusDot');
dot.classList.toggle('disabled', !settings.enabled);
}
// --- Util ---
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}