feat: full options UI for all new features + managed browser docs

Options UI additions:
- Auto Sync panel: enable/disable, method (Gist/URL), interval selector
- Version History: snapshot list with timestamps/source, restore buttons,
  max snapshots setting, clear history
- Connected Devices: device table with name/browser/last-sync, rename
  this device, remove other devices
- Organization: join by invite code or policy URL, compliance status,
  required mappings count, leave button (tamper-guarded)
- Tamper Protection: enable with admin password, change password,
  disable (requires admin password), org policy can prevent disabling
- Admin auth dialog: reusable <dialog> for any protected action
- Conflict Resolution: shows local vs remote values side-by-side with
  Keep Local / Keep Remote buttons

Service worker:
- Added autosync:config-changed and org:config-changed message handlers

README:
- Added managed browser deployment instructions for Chrome and Firefox

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
Claude
2026-03-26 19:45:44 +00:00
parent 70c7b759c6
commit ea86bcfce7
4 changed files with 601 additions and 0 deletions
+9
View File
@@ -322,6 +322,15 @@ All sync channels (browser sync, GitHub Gist, folder sync, custom URL, sync code
3. Full configuration (including TOTP secret) is restored from the encrypted payload 3. Full configuration (including TOTP secret) is restored from the encrypted payload
4. WebAuthn credential is registered locally for future re-verification 4. WebAuthn credential is registered locally for future re-verification
### Managed browser deployments
For organizations that want to prevent extension removal:
- **Chrome / Chromium / Edge:** Use the `ExtensionInstallForcelist` group policy. See [Chrome Enterprise policies](https://chromeenterprise.google/policies/#ExtensionInstallForcelist).
- **Firefox:** Use the `ExtensionSettings` policy in `policies.json` or via Group Policy. See [Firefox Enterprise policies](https://mozilla.github.io/policy-templates/#extensionsettings).
These are standard browser management features — Silent Send does not attempt to prevent its own removal.
### Smart reveal ### Smart reveal
Reveal mode only replaces values that were **actually substituted** in outbound messages during the current session. If the AI uses a word that happens to match one of your substitute values (e.g., the AI says "the user should..." and "user" is a configured substitute), it won't be falsely revealed as your real username. Reveal mode only replaces values that were **actually substituted** in outbound messages during the current session. If the AI uses a word that happens to match one of your substitute values (e.g., the AI says "the user should..." and "user" is a configured substitute), it won't be falsely revealed as your real username.
+8
View File
@@ -159,6 +159,14 @@ const messageHandlers = {
api.action.setBadgeBackgroundColor({ color: '#6b7280' }); api.action.setBadgeBackgroundColor({ color: '#6b7280' });
}, },
async 'autosync:config-changed'() {
await setupAutoSyncAlarm();
},
async 'org:config-changed'() {
await setupOrgPolicyAlarm();
},
async 'get:locked-state'(_message, _sender, sendResponse) { async 'get:locked-state'(_message, _sender, sendResponse) {
const locked = await Storage.isLocked(); const locked = await Storage.isLocked();
sendResponse({ locked }); sendResponse({ locked });
+132
View File
@@ -276,6 +276,34 @@
<div id="gistSyncStatus" style="font-size:12px;min-height:16px"></div> <div id="gistSyncStatus" style="font-size:12px;min-height:16px"></div>
</div> </div>
<!-- Auto Sync -->
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Auto Sync — background polling</h3>
<p class="section-desc" style="margin-bottom:8px">
Automatically push and pull settings on an interval. Uses the Gist or URL method configured above.
</p>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
<label class="toggle" title="Enable auto sync">
<input type="checkbox" id="autoSyncEnabled">
<span class="toggle-slider"></span>
</label>
<select id="autoSyncMethod" style="font-size:12px;padding:3px 6px;border:1px solid #d1d5db;border-radius:4px">
<option value="gist">GitHub Gist</option>
<option value="url">Custom URL</option>
</select>
<div style="display:flex;align-items:center;gap:4px">
<label style="font-size:12px;white-space:nowrap">Every</label>
<select id="autoSyncInterval" style="font-size:12px;padding:3px 6px;border:1px solid #d1d5db;border-radius:4px">
<option value="5">5 min</option>
<option value="15" selected>15 min</option>
<option value="30">30 min</option>
<option value="60">1 hour</option>
</select>
</div>
</div>
<div id="autoSyncStatus" style="font-size:12px;min-height:16px"></div>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb"> <div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Custom URL Sync</h3> <h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Custom URL Sync</h3>
<p class="section-desc" style="margin-bottom:8px"> <p class="section-desc" style="margin-bottom:8px">
@@ -297,6 +325,70 @@
</div> </div>
</section> </section>
<!-- Sync Conflicts -->
<section class="section" id="conflictSection" style="display:none">
<h2 style="color:#b45309">Sync Conflicts</h2>
<p class="section-desc">Both this device and another device changed the same data. Choose which version to keep for each conflict.</p>
<div id="conflictList"></div>
</section>
<!-- Connected Devices -->
<section class="section">
<h2>Connected Devices</h2>
<p class="section-desc">Devices that sync with this extension. The device list is shared via sync data.</p>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<label style="font-size:12px;white-space:nowrap">This device:</label>
<input type="text" id="deviceName" placeholder="Device name" style="flex:1;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-sm" id="btnRenameDevice">Rename</button>
</div>
<div id="deviceList" style="font-size:13px"></div>
</section>
<!-- Version History -->
<section class="section">
<h2>Version History</h2>
<p class="section-desc">Snapshots of your data are saved automatically on each sync. Restore a previous version if a sync overwrote something important.</p>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
<label style="font-size:12px;white-space:nowrap">Max snapshots</label>
<input type="number" id="maxVersionHistory" class="input-small" min="3" max="50" value="10">
<button class="btn btn-sm btn-danger" id="btnClearVersionHistory">Clear History</button>
</div>
<div id="versionHistoryList" style="font-size:13px"></div>
</section>
<!-- Organization -->
<section class="section">
<h2>Organization</h2>
<p class="section-desc">Join an organization to receive required substitution rules and secret scanner patterns from your admin. Org rules merge with your personal rules and cannot be disabled.</p>
<div id="orgNotJoined">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="text" id="orgInviteCode" placeholder="Invite code" style="flex:1;min-width:140px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px;font-family:monospace">
<button class="btn btn-primary" id="btnJoinOrgCode">Join</button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<input type="url" id="orgPolicyUrl" placeholder="Policy URL (https://…/policy.json)" style="flex:1;min-width:200px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn" id="btnJoinOrgUrl">Join by URL</button>
</div>
</div>
<div id="orgJoined" style="display:none">
<div style="padding:10px;background:#f0fdf4;border:1px solid #86efac;border-radius:6px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
<div>
<strong id="orgNameDisplay" style="font-size:13px"></strong>
<span id="orgPolicyVersion" style="font-size:11px;color:#6b7280;margin-left:6px"></span>
</div>
<button class="btn btn-sm btn-danger" id="btnLeaveOrg">Leave</button>
</div>
<div id="orgComplianceStatus" style="font-size:12px;margin-top:6px"></div>
<div id="orgRequiredMappings" style="font-size:11px;color:#6b7280;margin-top:4px"></div>
</div>
</div>
<div id="orgStatus" style="font-size:12px;min-height:16px"></div>
</section>
<section class="section"> <section class="section">
<h2>Transfer Data</h2> <h2>Transfer Data</h2>
<p class="section-desc">Export all your identities, mappings, and settings to a file. Encrypted exports require a password to decrypt.</p> <p class="section-desc">Export all your identities, mappings, and settings to a file. Encrypted exports require a password to decrypt.</p>
@@ -379,6 +471,46 @@
<section class="section"> <section class="section">
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<!-- Tamper Protection -->
<div style="margin-bottom:16px;padding:12px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px">
<h3 style="font-size:13px;font-weight:600;margin:0 0 6px">Tamper Protection</h3>
<p class="section-desc" style="margin-bottom:8px">
Require a separate admin password to disable the extension, clear data, or change org settings.
This is a deterrent — it cannot prevent browser-level uninstall or dev tools access.
</p>
<div id="tamperNotEnabled">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">
<input type="password" id="tamperAdminPassword" placeholder="Admin password" autocomplete="new-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<input type="password" id="tamperAdminPasswordConfirm" placeholder="Confirm" autocomplete="new-password"
style="flex:1;min-width:120px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-primary btn-sm" id="btnEnableTamper">Enable</button>
</div>
</div>
<div id="tamperEnabled" style="display:none">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="font-size:12px;color:#dc2626;font-weight:500">&#128274; Active</span>
<button class="btn btn-sm" id="btnChangeTamperPassword">Change Password</button>
<button class="btn btn-sm btn-danger" id="btnDisableTamper">Disable</button>
</div>
</div>
<div id="tamperStatus" style="font-size:12px;margin-top:4px;min-height:14px"></div>
</div>
<!-- Admin auth dialog (reusable prompt for protected actions) -->
<dialog id="adminAuthDialog" style="border:1px solid #d1d5db;border-radius:8px;padding:20px;max-width:300px">
<h3 style="margin:0 0 8px;font-size:14px">Admin Password Required</h3>
<p id="adminAuthReason" style="font-size:12px;color:#6b7280;margin:0 0 12px"></p>
<input type="password" id="adminAuthInput" placeholder="Admin password" autocomplete="current-password"
style="width:100%;box-sizing:border-box;font-size:13px;padding:8px;border:1px solid #d1d5db;border-radius:6px;margin-bottom:8px">
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn btn-sm" id="btnAdminAuthCancel">Cancel</button>
<button class="btn btn-primary btn-sm" id="btnAdminAuthSubmit">Confirm</button>
</div>
<div id="adminAuthStatus" style="font-size:11px;margin-top:4px;min-height:14px;color:#dc2626"></div>
</dialog>
<div class="setting-row"> <div class="setting-row">
<div> <div>
<label>Reset all data</label> <label>Reset all data</label>
+452
View File
@@ -1,6 +1,10 @@
import Storage from '../lib/storage.js'; import Storage from '../lib/storage.js';
import SilentSendCrypto from '../lib/crypto.js'; import SilentSendCrypto from '../lib/crypto.js';
import SilentSendSync from '../lib/sync.js'; import SilentSendSync from '../lib/sync.js';
import VersionHistory from '../lib/version-history.js';
import SilentSendMerge from '../lib/merge.js';
import OrgPolicy from '../lib/org-policy.js';
import TamperGuard from '../lib/tamper-guard.js';
import api from '../lib/browser-polyfill.js'; import api from '../lib/browser-polyfill.js';
let mappings = []; let mappings = [];
@@ -25,6 +29,14 @@ document.addEventListener('DOMContentLoaded', async () => {
renderDomains(); renderDomains();
renderLog(); renderLog();
// --- New features ---
await initAutoSyncUI();
await initVersionHistoryUI();
await initDeviceDashboard();
await initOrgUI();
await initTamperUI();
await checkConflicts();
// --- Sync Encryption UI --- // --- Sync Encryption UI ---
await initSyncEncryptionUI(); await initSyncEncryptionUI();
@@ -1049,6 +1061,446 @@ function setSyncAuthStatus(msg, type) {
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
} }
// ----------------------------------------------------------------
// Auto Sync UI
// ----------------------------------------------------------------
async function initAutoSyncUI() {
const config = await SilentSendSync.getAutoSyncConfig();
if (config) {
$('#autoSyncEnabled').checked = config.enabled || false;
$('#autoSyncMethod').value = config.method || 'gist';
$('#autoSyncInterval').value = String(config.interval || 15);
if (config.lastPull) {
setAutoSyncStatus(`Last pull: ${new Date(config.lastPull).toLocaleString()}`, 'ok');
}
}
const saveAutoSync = async () => {
const config = (await SilentSendSync.getAutoSyncConfig()) || {};
config.enabled = $('#autoSyncEnabled').checked;
config.method = $('#autoSyncMethod').value;
config.interval = parseInt($('#autoSyncInterval').value, 10) || 15;
// Inherit token/URL from existing fields
if (config.method === 'gist') {
const token = $('#gistToken').value.trim();
if (token) config.gistToken = token;
} else {
config.url = $('#customSyncUrl').value.trim();
config.headers = parseHeadersField($('#customSyncHeaders').value);
}
await SilentSendSync.saveAutoSyncConfig(config);
// Tell service worker to reconfigure alarm
api.runtime.sendMessage({ type: 'autosync:config-changed' }).catch(() => {});
setAutoSyncStatus(config.enabled ? 'Auto sync enabled.' : 'Auto sync disabled.', config.enabled ? 'ok' : 'neutral');
};
$('#autoSyncEnabled').addEventListener('change', saveAutoSync);
$('#autoSyncMethod').addEventListener('change', saveAutoSync);
$('#autoSyncInterval').addEventListener('change', saveAutoSync);
}
function setAutoSyncStatus(msg, type) {
const el = $('#autoSyncStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Version History UI
// ----------------------------------------------------------------
async function initVersionHistoryUI() {
$('#maxVersionHistory').value = settings.maxVersionHistory || 10;
$('#maxVersionHistory').addEventListener('change', async (e) => {
await Storage.saveSettings({ maxVersionHistory: parseInt(e.target.value, 10) || 10 });
});
$('#btnClearVersionHistory').addEventListener('click', async () => {
if (!confirm('Clear all version history snapshots?')) return;
await VersionHistory.clearAll();
renderVersionHistory();
});
await renderVersionHistory();
}
async function renderVersionHistory() {
const list = $('#versionHistoryList');
const snapshots = await VersionHistory.getSnapshots();
if (snapshots.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No snapshots yet. Snapshots are created on each sync.</div>';
return;
}
list.innerHTML = snapshots.map(s => {
const time = new Date(s.timestamp).toLocaleString();
const mappingCount = (s.data?.mappings || []).length;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
<div>
<span style="font-size:12px;font-weight:500">${time}</span>
<span style="font-size:11px;color:#6b7280;margin-left:8px">via ${escapeHtml(s.source || 'unknown')}</span>
<span style="font-size:11px;color:#9ca3af;margin-left:8px">${mappingCount} mappings</span>
</div>
<button class="btn btn-sm btn-restore-snapshot" data-id="${s.id}">Restore</button>
</div>`;
}).join('');
list.querySelectorAll('.btn-restore-snapshot').forEach(btn => {
btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id, 10);
if (!confirm('Restore this snapshot? Current data will be overwritten.')) return;
const snapshot = await VersionHistory.getSnapshot(id);
if (snapshot?.data) {
await SilentSendSync._applyData(snapshot.data, 'rollback');
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
alert('Restored. Reload open AI tabs for changes to take effect.');
}
});
});
}
// ----------------------------------------------------------------
// Connected Devices UI
// ----------------------------------------------------------------
async function initDeviceDashboard() {
const deviceInfo = await SilentSendSync.getDeviceInfo();
$('#deviceName').value = deviceInfo.name;
$('#btnRenameDevice').addEventListener('click', async () => {
const name = $('#deviceName').value.trim();
if (!name) return;
await SilentSendSync.setDeviceName(name);
renderDevices();
});
await renderDevices();
}
async function renderDevices() {
const list = $('#deviceList');
const devices = await SilentSendSync.getDevices();
const currentDevice = await SilentSendSync.getDeviceInfo();
const entries = Object.values(devices);
if (entries.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No devices synced yet. Push or pull to register this device.</div>';
return;
}
entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0));
list.innerHTML = `<table style="width:100%;font-size:12px;border-collapse:collapse">
<thead><tr style="text-align:left;border-bottom:1px solid #e5e7eb">
<th style="padding:6px">Device</th>
<th style="padding:6px">Browser</th>
<th style="padding:6px">Last Sync</th>
<th style="padding:6px"></th>
</tr></thead>
<tbody>${entries.map(d => {
const isCurrent = d.id === currentDevice.id;
const lastSync = d.lastSync ? new Date(d.lastSync).toLocaleString() : 'Never';
return `<tr style="border-bottom:1px solid #f3f4f6">
<td style="padding:6px">${escapeHtml(d.name || 'Unknown')} ${isCurrent ? '<span style="color:#10b981;font-size:10px">(this)</span>' : ''}</td>
<td style="padding:6px">${escapeHtml(d.browser || '?')}</td>
<td style="padding:6px">${lastSync}</td>
<td style="padding:6px">${!isCurrent ? `<button class="btn btn-sm btn-danger btn-remove-device" data-id="${d.id}">&times;</button>` : ''}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
list.querySelectorAll('.btn-remove-device').forEach(btn => {
btn.addEventListener('click', async () => {
await SilentSendSync.removeDevice(btn.dataset.id);
renderDevices();
});
});
}
// ----------------------------------------------------------------
// Organization UI
// ----------------------------------------------------------------
async function initOrgUI() {
const inOrg = await OrgPolicy.isInOrg();
if (inOrg) {
await showOrgJoined();
} else {
showOrgNotJoined();
}
$('#btnJoinOrgCode').addEventListener('click', async () => {
const code = $('#orgInviteCode').value.trim();
if (!code) { setOrgStatus('Enter an invite code.', 'warn'); return; }
setOrgStatus('Joining...', 'neutral');
const result = await OrgPolicy.joinOrg({ inviteCode: code });
if (result.success) {
setOrgStatus(`Joined ${result.orgName}.`, 'ok');
await showOrgJoined();
} else {
setOrgStatus('Failed: ' + result.reason, 'error');
}
});
$('#btnJoinOrgUrl').addEventListener('click', async () => {
const url = $('#orgPolicyUrl').value.trim();
if (!url) { setOrgStatus('Enter a policy URL.', 'warn'); return; }
setOrgStatus('Joining...', 'neutral');
const result = await OrgPolicy.joinOrg({ policyUrl: url });
if (result.success) {
setOrgStatus(`Joined ${result.orgName}.`, 'ok');
await showOrgJoined();
} else {
setOrgStatus('Failed: ' + result.reason, 'error');
}
});
$('#btnLeaveOrg').addEventListener('click', async () => {
// Check tamper protection
if (await TamperGuard.isActionProtected('changeOrgPolicy')) {
const pw = await promptAdminPassword('Leave organization');
if (!pw) return;
const auth = await TamperGuard.verify(pw);
if (!auth) { setOrgStatus('Wrong admin password.', 'error'); return; }
}
if (!confirm('Leave this organization? Org-required mappings will be removed.')) return;
await OrgPolicy.leaveOrg();
showOrgNotJoined();
setOrgStatus('Left organization.', 'neutral');
});
}
async function showOrgJoined() {
$('#orgNotJoined').style.display = 'none';
$('#orgJoined').style.display = 'block';
const config = await OrgPolicy.getOrgConfig();
const policy = await OrgPolicy.getPolicy();
if (config) {
$('#orgNameDisplay').textContent = config.orgName;
$('#orgPolicyVersion').textContent = `v${policy?.version || '?'}`;
}
const compliance = await OrgPolicy.checkCompliance();
const statusEl = $('#orgComplianceStatus');
if (compliance.compliant) {
statusEl.innerHTML = '<span style="color:#10b981">&#10003; Compliant — all required fields configured</span>';
} else {
statusEl.innerHTML = `<span style="color:#b45309">Missing: ${compliance.missing.join(', ')}</span>`;
}
const reqMappings = policy?.requiredMappings || [];
const reqEl = $('#orgRequiredMappings');
if (reqMappings.length > 0) {
reqEl.textContent = `${reqMappings.length} required mapping(s) enforced by org policy`;
} else {
reqEl.textContent = '';
}
}
function showOrgNotJoined() {
$('#orgNotJoined').style.display = 'block';
$('#orgJoined').style.display = 'none';
}
function setOrgStatus(msg, type) {
const el = $('#orgStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
// ----------------------------------------------------------------
// Tamper Protection UI
// ----------------------------------------------------------------
async function initTamperUI() {
const enabled = await TamperGuard.isEnabled();
if (enabled) {
$('#tamperNotEnabled').style.display = 'none';
$('#tamperEnabled').style.display = 'block';
}
$('#btnEnableTamper').addEventListener('click', async () => {
const pw = $('#tamperAdminPassword').value;
const confirm = $('#tamperAdminPasswordConfirm').value;
if (!pw) { setTamperStatus('Enter a password.', 'warn'); return; }
if (pw !== confirm) { setTamperStatus('Passwords do not match.', 'error'); return; }
const result = await TamperGuard.setup(pw);
if (result.success) {
$('#tamperNotEnabled').style.display = 'none';
$('#tamperEnabled').style.display = 'block';
$('#tamperAdminPassword').value = '';
$('#tamperAdminPasswordConfirm').value = '';
setTamperStatus('Tamper protection enabled.', 'ok');
} else {
setTamperStatus(result.reason, 'error');
}
});
$('#btnDisableTamper').addEventListener('click', async () => {
const pw = await promptAdminPassword('Disable tamper protection');
if (!pw) return;
const result = await TamperGuard.disable(pw);
if (result.success) {
$('#tamperNotEnabled').style.display = 'block';
$('#tamperEnabled').style.display = 'none';
setTamperStatus('Tamper protection disabled.', 'neutral');
} else {
setTamperStatus(result.reason, 'error');
}
});
$('#btnChangeTamperPassword').addEventListener('click', async () => {
const oldPw = await promptAdminPassword('Change admin password');
if (!oldPw) return;
const newPw = window.prompt('Enter new admin password:');
if (!newPw) return;
const confirmPw = window.prompt('Confirm new admin password:');
if (newPw !== confirmPw) { setTamperStatus('Passwords do not match.', 'error'); return; }
const result = await TamperGuard.changePassword(oldPw, newPw);
if (result.success) {
setTamperStatus('Admin password changed.', 'ok');
} else {
setTamperStatus(result.reason, 'error');
}
});
}
function setTamperStatus(msg, type) {
const el = $('#tamperStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
/**
* Show the admin auth dialog and return the password, or null if cancelled.
*/
function promptAdminPassword(reason) {
return new Promise((resolve) => {
const dialog = $('#adminAuthDialog');
$('#adminAuthReason').textContent = reason;
$('#adminAuthInput').value = '';
$('#adminAuthStatus').textContent = '';
dialog.showModal();
const submit = () => {
const pw = $('#adminAuthInput').value;
if (!pw) {
$('#adminAuthStatus').textContent = 'Enter password.';
return;
}
dialog.close();
cleanup();
resolve(pw);
};
const cancel = () => {
dialog.close();
cleanup();
resolve(null);
};
const onKey = (e) => { if (e.key === 'Enter') submit(); };
const cleanup = () => {
$('#btnAdminAuthSubmit').removeEventListener('click', submit);
$('#btnAdminAuthCancel').removeEventListener('click', cancel);
$('#adminAuthInput').removeEventListener('keydown', onKey);
};
$('#btnAdminAuthSubmit').addEventListener('click', submit);
$('#btnAdminAuthCancel').addEventListener('click', cancel);
$('#adminAuthInput').addEventListener('keydown', onKey);
setTimeout(() => $('#adminAuthInput').focus(), 100);
});
}
// ----------------------------------------------------------------
// Conflict Resolution UI
// ----------------------------------------------------------------
async function checkConflicts() {
const result = await api.storage.local.get('ss_sync_conflicts');
const conflicts = result.ss_sync_conflicts || [];
const section = $('#conflictSection');
if (conflicts.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
renderConflicts(conflicts);
}
function renderConflicts(conflicts) {
const list = $('#conflictList');
list.innerHTML = conflicts.map(c => `
<div style="padding:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;margin-bottom:8px" data-conflict-id="${c.id}">
<div style="font-size:12px;font-weight:500;margin-bottom:6px">${escapeHtml(c.path)}</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
<div style="flex:1;min-width:120px">
<div style="font-size:10px;color:#6b7280;margin-bottom:2px">LOCAL (this device)</div>
<code style="font-size:11px;background:#f3f4f6;padding:4px 6px;border-radius:4px;display:block;word-break:break-all">${escapeHtml(JSON.stringify(c.localValue))}</code>
</div>
<div style="flex:1;min-width:120px">
<div style="font-size:10px;color:#6b7280;margin-bottom:2px">REMOTE (other device)</div>
<code style="font-size:11px;background:#f3f4f6;padding:4px 6px;border-radius:4px;display:block;word-break:break-all">${escapeHtml(JSON.stringify(c.remoteValue))}</code>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-primary btn-resolve" data-id="${c.id}" data-choice="local">Keep Local</button>
<button class="btn btn-sm btn-resolve" data-id="${c.id}" data-choice="remote">Keep Remote</button>
</div>
</div>
`).join('');
list.querySelectorAll('.btn-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
const conflictId = btn.dataset.id;
const choice = btn.dataset.choice;
const result = await api.storage.local.get('ss_sync_conflicts');
const conflicts = result.ss_sync_conflicts || [];
const conflict = conflicts.find(c => c.id === conflictId);
if (conflict) {
// Apply resolution
const local = await SilentSendSync._getAllData();
SilentSendMerge.resolveConflict(local, conflict, choice);
await SilentSendSync._applyData(local, 'conflict-resolution');
// Remove resolved conflict
const remaining = conflicts.filter(c => c.id !== conflictId);
await api.storage.local.set({ ss_sync_conflicts: remaining });
// Refresh
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
checkConflicts();
}
});
});
}
// ----------------------------------------------------------------
// Utility
// ----------------------------------------------------------------
function escapeHtml(str) { function escapeHtml(str) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = str; div.textContent = str;