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:
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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">🔒 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>
|
||||||
|
|||||||
@@ -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}">×</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">✓ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user