feat: masked passwords UI with vault-gated reveal

Passwords imported from password managers are now:
- Displayed as dots (••••••••) by default in both options and popup
- Separated into their own "Passwords" section in options page
- Only revealable by entering the vault encryption password
- Always masked in the popup mappings list (no reveal there)

Options page:
- New Passwords section with locked/unlocked states
- Reveal button validates against vault encryption password
- Hide button re-masks all password values
- Password mappings excluded from the general Mappings table
- Delete and enable/disable controls work while masked

Popup:
- Password-category mappings show dots for real value
- Password category added to mapping add form dropdown

Storage:
- Added 'password' to default categories list

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 20:11:05 +00:00
parent 7c968e1461
commit e4b44a73ea
5 changed files with 144 additions and 4 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ const DEFAULT_SETTINGS = {
autoAddDetected: true, autoAddDetected: true,
maxLogEntries: 200, maxLogEntries: 200,
customDomains: [], customDomains: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'], categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'password', 'general'],
browserSync: false, browserSync: false,
}; };
+42
View File
@@ -463,6 +463,7 @@
<option value="ssn">SSN</option> <option value="ssn">SSN</option>
<option value="dob">DOB</option> <option value="dob">DOB</option>
<option value="domain">Domain</option> <option value="domain">Domain</option>
<option value="password">Password</option>
<option value="general">General</option> <option value="general">General</option>
</select> </select>
<label class="checkbox-label"> <label class="checkbox-label">
@@ -473,6 +474,47 @@
</div> </div>
</section> </section>
<section class="section">
<h2>Passwords</h2>
<p class="section-desc">
Imported passwords are automatically redacted when sent to AI services.
Password values are hidden by default — enter your vault password to reveal them.
</p>
<div id="passwordsLocked" style="margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span style="font-size:12px;color:#6b7280">&#128274; Password values are hidden</span>
<input type="password" id="passwordRevealKey" placeholder="Vault password to reveal" autocomplete="current-password"
style="font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;width:180px">
<button class="btn btn-sm" id="btnRevealPasswords">Reveal</button>
</div>
<div id="passwordRevealStatus" style="font-size:11px;margin-top:4px;min-height:14px"></div>
</div>
<div id="passwordsUnlocked" style="display:none;margin-bottom:12px">
<div style="display:flex;gap:8px;align-items:center">
<span style="font-size:12px;color:#b45309">&#128275; Passwords visible</span>
<button class="btn btn-sm" id="btnHidePasswords">Hide</button>
</div>
</div>
<table class="mapping-table">
<thead>
<tr>
<th>Password</th>
<th>Redacted As</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody id="passwordTableBody">
</tbody>
</table>
<div id="noPasswordsMsg" style="text-align:center;color:#9ca3af;padding:16px;font-size:13px;display:none">
No passwords imported. Use Transfer Data → Import to add passwords from your password manager.
</div>
</section>
<section class="section"> <section class="section">
<h2>Custom Domains</h2> <h2>Custom Domains</h2>
<p class="section-desc">Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.</p> <p class="section-desc">Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.</p>
+99 -2
View File
@@ -10,6 +10,7 @@ import api from '../lib/browser-polyfill.js';
let mappings = []; let mappings = [];
let settings = {}; let settings = {};
let passwordsRevealed = false;
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@@ -27,6 +28,7 @@ document.addEventListener('DOMContentLoaded', async () => {
$('#browserSync').checked = settings.browserSync === true; $('#browserSync').checked = settings.browserSync === true;
renderMappings(); renderMappings();
renderPasswords();
renderDomains(); renderDomains();
renderLog(); renderLog();
@@ -326,6 +328,36 @@ document.addEventListener('DOMContentLoaded', async () => {
renderMappings(); renderMappings();
}); });
// Password reveal/hide
$('#btnRevealPasswords').addEventListener('click', async () => {
const pw = $('#passwordRevealKey').value;
if (!pw) {
setPasswordRevealStatus('Enter your vault password.', 'warn');
return;
}
const result = await SilentSendSync.authenticate(pw);
if (result.success) {
passwordsRevealed = true;
$('#passwordsLocked').style.display = 'none';
$('#passwordsUnlocked').style.display = 'block';
$('#passwordRevealKey').value = '';
renderPasswords();
} else {
setPasswordRevealStatus('Wrong password.', 'error');
}
});
$('#btnHidePasswords').addEventListener('click', () => {
passwordsRevealed = false;
$('#passwordsLocked').style.display = 'block';
$('#passwordsUnlocked').style.display = 'none';
renderPasswords();
});
$('#passwordRevealKey').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#btnRevealPasswords').click();
});
// Clear log // Clear log
$('#btnClearLog').addEventListener('click', async () => { $('#btnClearLog').addEventListener('click', async () => {
await Storage.clearLog(); await Storage.clearLog();
@@ -369,13 +401,15 @@ async function addMapping() {
function renderMappings() { function renderMappings() {
const tbody = $('#mappingTableBody'); const tbody = $('#mappingTableBody');
// Exclude password-category mappings — they have their own section
const nonPasswordMappings = mappings.filter(m => m.category !== 'password');
if (mappings.length === 0) { if (nonPasswordMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>';
return; return;
} }
tbody.innerHTML = mappings tbody.innerHTML = nonPasswordMappings
.map( .map(
(m) => ` (m) => `
<tr data-id="${m.id}"> <tr data-id="${m.id}">
@@ -415,6 +449,69 @@ function renderMappings() {
}); });
} }
// --- Passwords Section ---
function renderPasswords() {
const passwordMappings = mappings.filter(m => m.category === 'password');
const tbody = $('#passwordTableBody');
const noMsg = $('#noPasswordsMsg');
if (passwordMappings.length === 0) {
tbody.innerHTML = '';
noMsg.style.display = 'block';
return;
}
noMsg.style.display = 'none';
tbody.innerHTML = passwordMappings.map(m => {
const displayReal = passwordsRevealed
? escapeHtml(m.real)
: '&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;';
return `
<tr data-id="${m.id}">
<td class="real" style="font-family:monospace;font-size:12px">${displayReal}</td>
<td class="sub" style="font-size:12px">${escapeHtml(m.substitute)}</td>
<td>
<label class="toggle" style="width:32px;height:18px">
<input type="checkbox" class="toggle-pw-enabled" ${m.enabled ? 'checked' : ''}>
<span class="toggle-slider" style="border-radius:18px"></span>
</label>
</td>
<td><button class="btn btn-sm btn-danger btn-delete-pw">&times;</button></td>
</tr>`;
}).join('');
// Bind delete
tbody.querySelectorAll('.btn-delete-pw').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.closest('tr').dataset.id;
await Storage.deleteMapping(id);
mappings = mappings.filter(m => m.id !== id);
renderPasswords();
renderMappings();
});
});
// Bind toggle
tbody.querySelectorAll('.toggle-pw-enabled').forEach(cb => {
cb.addEventListener('change', async () => {
const id = cb.closest('tr').dataset.id;
await Storage.updateMapping(id, { enabled: cb.checked });
const m = mappings.find(m => m.id === id);
if (m) m.enabled = cb.checked;
});
});
}
function setPasswordRevealStatus(msg, type) {
const el = $('#passwordRevealStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
async function renderLog() { async function renderLog() {
const log = await Storage.getLog(); const log = await Storage.getLog();
$('#logCount').textContent = `${log.length} entries`; $('#logCount').textContent = `${log.length} entries`;
+1
View File
@@ -133,6 +133,7 @@
<option value="ssn">SSN</option> <option value="ssn">SSN</option>
<option value="dob">DOB</option> <option value="dob">DOB</option>
<option value="domain">Domain</option> <option value="domain">Domain</option>
<option value="password">Password</option>
<option value="general">General</option> <option value="general">General</option>
</select> </select>
<label class="checkbox-label"> <label class="checkbox-label">
+1 -1
View File
@@ -585,7 +585,7 @@ function renderMappings() {
(m) => ` (m) => `
<div class="mapping-item" data-id="${m.id}"> <div class="mapping-item" data-id="${m.id}">
<div class="mapping-values"> <div class="mapping-values">
<span class="mapping-real">${escapeHtml(m.real)}</span> <span class="mapping-real">${m.category === 'password' ? '&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;' : escapeHtml(m.real)}</span>
&rarr; &rarr;
<span class="mapping-sub">${escapeHtml(m.substitute)}</span> <span class="mapping-sub">${escapeHtml(m.substitute)}</span>
</div> </div>