Merge pull request #9 from outis1one/claude/read-repo-wA3y1
feat: auto-sync, version history, merge, org policy, tamper guard
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"notifications"
|
||||
"notifications",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://claude.ai/*",
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"notifications"
|
||||
"notifications",
|
||||
"alarms"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://claude.ai/*",
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
|
||||
import Storage from '../lib/storage.js';
|
||||
import SilentSendSync from '../lib/sync.js';
|
||||
import OrgPolicy from '../lib/org-policy.js';
|
||||
import TamperGuard from '../lib/tamper-guard.js';
|
||||
import api from '../lib/browser-polyfill.js';
|
||||
|
||||
// --- Alarm names ---
|
||||
const AUTO_SYNC_ALARM = 'ss-auto-sync';
|
||||
const ORG_POLICY_ALARM = 'ss-org-policy';
|
||||
|
||||
// Track substitution counts per tab
|
||||
const tabCounts = new Map();
|
||||
|
||||
@@ -153,6 +159,14 @@ const messageHandlers = {
|
||||
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
|
||||
},
|
||||
|
||||
async 'autosync:config-changed'() {
|
||||
await setupAutoSyncAlarm();
|
||||
},
|
||||
|
||||
async 'org:config-changed'() {
|
||||
await setupOrgPolicyAlarm();
|
||||
},
|
||||
|
||||
async 'get:locked-state'(_message, _sender, sendResponse) {
|
||||
const locked = await Storage.isLocked();
|
||||
sendResponse({ locked });
|
||||
@@ -398,11 +412,84 @@ api.notifications.onClicked.addListener((notificationId) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Alarms — background polling (MV3-safe, survives service worker restarts) ---
|
||||
|
||||
async function setupAutoSyncAlarm() {
|
||||
const config = await SilentSendSync.getAutoSyncConfig();
|
||||
if (config?.enabled) {
|
||||
api.alarms.create(AUTO_SYNC_ALARM, {
|
||||
periodInMinutes: config.interval || 15,
|
||||
});
|
||||
} else {
|
||||
api.alarms.clear(AUTO_SYNC_ALARM).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function setupOrgPolicyAlarm() {
|
||||
const inOrg = await OrgPolicy.isInOrg();
|
||||
if (inOrg) {
|
||||
api.alarms.create(ORG_POLICY_ALARM, { periodInMinutes: 60 });
|
||||
} else {
|
||||
api.alarms.clear(ORG_POLICY_ALARM).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
api.alarms.onAlarm.addListener(async (alarm) => {
|
||||
// Skip if locked
|
||||
const locked = await Storage.isLocked();
|
||||
if (locked) return;
|
||||
|
||||
if (alarm.name === AUTO_SYNC_ALARM) {
|
||||
const result = await SilentSendSync.performAutoSync();
|
||||
if (result.pulled) {
|
||||
console.log('[Silent Send] Auto-sync pulled new data');
|
||||
}
|
||||
if (result.error) {
|
||||
console.warn('[Silent Send] Auto-sync error:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (alarm.name === ORG_POLICY_ALARM) {
|
||||
const result = await OrgPolicy.fetchPolicy();
|
||||
if (result.updated) {
|
||||
console.log('[Silent Send] Org policy updated to version', result.version);
|
||||
// Notify content scripts of potential new mappings
|
||||
broadcastSettings(await Storage.getSettings());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Tamper guard message handlers ---
|
||||
const tamperHandlers = {
|
||||
async 'tamper:check-action'(message, _sender, sendResponse) {
|
||||
const result = await TamperGuard.requireAuth(message.action, message.adminPassword);
|
||||
sendResponse(result);
|
||||
},
|
||||
|
||||
async 'tamper:is-enabled'(_message, _sender, sendResponse) {
|
||||
const enabled = await TamperGuard.isEnabled();
|
||||
sendResponse({ enabled });
|
||||
},
|
||||
};
|
||||
|
||||
// Add tamper handlers to main message handler
|
||||
const origHandler = api.runtime.onMessage._listeners?.[0];
|
||||
api.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
const handler = tamperHandlers[message.type];
|
||||
if (handler) {
|
||||
handler(message, sender, sendResponse);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Set initial state ---
|
||||
api.runtime.onInstalled.addListener(async () => {
|
||||
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
|
||||
const settings = await Storage.getSettings();
|
||||
await updateIcon(settings);
|
||||
// Set up alarms on install
|
||||
await setupAutoSyncAlarm();
|
||||
await setupOrgPolicyAlarm();
|
||||
});
|
||||
|
||||
// Also set icon on startup (service worker wake) + pull any newer sync data
|
||||
@@ -428,4 +515,8 @@ api.runtime.onInstalled.addListener(async () => {
|
||||
if (settings.browserSync) {
|
||||
await SilentSendSync.pullFromSyncStorage();
|
||||
}
|
||||
|
||||
// Set up periodic alarms
|
||||
await setupAutoSyncAlarm();
|
||||
await setupOrgPolicyAlarm();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* Silent Send - Three-Way Field-Level Merge
|
||||
*
|
||||
* Handles conflict resolution when syncing data between devices.
|
||||
* Instead of "newest wins", compares an ancestor (last synced state)
|
||||
* against both local and remote changes, auto-merges non-conflicting
|
||||
* fields, and surfaces true conflicts for the user to resolve.
|
||||
*
|
||||
* Data structures merged:
|
||||
* - identity: { profiles: [{ id, name, active, emails, names, ... }] }
|
||||
* - mappings: [{ id, real, substitute, category, ... }]
|
||||
* - settings: { enabled, showHighlights, customDomains, ... }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deep-equal comparison for two values (objects, arrays, primitives).
|
||||
* Good enough for JSON-serializable sync data.
|
||||
* @param {*} a
|
||||
* @param {*} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object') {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every((k) => deepEqual(a[k], b[k]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a JSON-serializable value.
|
||||
* @param {*} v
|
||||
* @returns {*}
|
||||
*/
|
||||
function clone(v) {
|
||||
if (v == null || typeof v !== 'object') return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
const SilentSendMerge = {
|
||||
/**
|
||||
* Three-way merge of all sync data.
|
||||
*
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} ancestor - last synced state
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} local - current local state
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} remote - incoming remote state
|
||||
* @returns {{ merged: { identity: object, mappings: Array, settings: object }, conflicts: Array<{ id: string, path: string, localValue: *, remoteValue: *, ancestorValue: * }> }}
|
||||
*/
|
||||
mergeData(ancestor, local, remote) {
|
||||
const mappingsResult = this._mergeMappings(
|
||||
ancestor?.mappings || [],
|
||||
local?.mappings || [],
|
||||
remote?.mappings || [],
|
||||
);
|
||||
|
||||
const settingsResult = this._mergeSettings(
|
||||
ancestor?.settings || {},
|
||||
local?.settings || {},
|
||||
remote?.settings || {},
|
||||
);
|
||||
|
||||
const identityResult = this._mergeIdentity(
|
||||
ancestor?.identity || {},
|
||||
local?.identity || {},
|
||||
remote?.identity || {},
|
||||
);
|
||||
|
||||
return {
|
||||
merged: {
|
||||
identity: identityResult.merged,
|
||||
mappings: mappingsResult.merged,
|
||||
settings: settingsResult.merged,
|
||||
},
|
||||
conflicts: [
|
||||
...mappingsResult.conflicts,
|
||||
...settingsResult.conflicts,
|
||||
...identityResult.conflicts,
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge mappings arrays by `id` field.
|
||||
*
|
||||
* Rules:
|
||||
* - New on one side only -> auto-add
|
||||
* - Deleted on one side, unchanged on other -> auto-delete
|
||||
* - Deleted on one side, changed on other -> conflict
|
||||
* - Changed on both sides -> conflict (unless changes are identical)
|
||||
* - Changed on one side only -> accept the change
|
||||
*
|
||||
* @param {Array} ancestor
|
||||
* @param {Array} local
|
||||
* @param {Array} remote
|
||||
* @returns {{ merged: Array, conflicts: Array }}
|
||||
*/
|
||||
_mergeMappings(ancestor, local, remote) {
|
||||
const ancestorMap = new Map(ancestor.map((m) => [m.id, m]));
|
||||
const localMap = new Map(local.map((m) => [m.id, m]));
|
||||
const remoteMap = new Map(remote.map((m) => [m.id, m]));
|
||||
|
||||
const allIds = new Set([
|
||||
...ancestorMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const merged = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const id of allIds) {
|
||||
const a = ancestorMap.get(id);
|
||||
const l = localMap.get(id);
|
||||
const r = remoteMap.get(id);
|
||||
|
||||
const inA = ancestorMap.has(id);
|
||||
const inL = localMap.has(id);
|
||||
const inR = remoteMap.has(id);
|
||||
|
||||
// New on local only (not in ancestor or remote)
|
||||
if (!inA && inL && !inR) {
|
||||
merged.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// New on remote only
|
||||
if (!inA && !inL && inR) {
|
||||
merged.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
|
||||
// New on both sides (same id independently — unlikely with UUIDs but handle it)
|
||||
if (!inA && inL && inR) {
|
||||
if (deepEqual(l, r)) {
|
||||
merged.push(clone(l));
|
||||
} else {
|
||||
// Both added with same id but different content — conflict
|
||||
merged.push(clone(l));
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Existed in ancestor
|
||||
if (inA) {
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
const localDeleted = !inL;
|
||||
const remoteDeleted = !inR;
|
||||
|
||||
// Deleted on both sides
|
||||
if (localDeleted && remoteDeleted) {
|
||||
continue; // gone
|
||||
}
|
||||
|
||||
// Deleted locally, unchanged remotely -> delete
|
||||
if (localDeleted && !remoteChanged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted remotely, unchanged locally -> delete
|
||||
if (remoteDeleted && !localChanged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted on one side but changed on other -> conflict
|
||||
if (localDeleted && remoteChanged) {
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: null,
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
// Keep the remote version in merged for now (user must resolve)
|
||||
merged.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (remoteDeleted && localChanged) {
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: null,
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
merged.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both present
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged.push(clone(a)); // unchanged
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged.push(clone(l));
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged.push(clone(r));
|
||||
} else {
|
||||
// Both changed
|
||||
if (deepEqual(l, r)) {
|
||||
merged.push(clone(l)); // same change on both sides
|
||||
} else {
|
||||
merged.push(clone(l)); // default to local, flag conflict
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, conflicts };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge settings objects by key.
|
||||
*
|
||||
* `customDomains` is treated specially: union of both sides (deduplicated).
|
||||
* All other keys use standard three-way field comparison.
|
||||
*
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @returns {{ merged: object, conflicts: Array }}
|
||||
*/
|
||||
_mergeSettings(ancestor, local, remote) {
|
||||
const merged = {};
|
||||
const conflicts = [];
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
// Special handling for customDomains — union both sides
|
||||
if (key === 'customDomains') {
|
||||
const localDomains = Array.isArray(l) ? l : [];
|
||||
const remoteDomains = Array.isArray(r) ? r : [];
|
||||
merged[key] = [...new Set([...localDomains, ...remoteDomains])];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for categories — union both sides
|
||||
if (key === 'categories') {
|
||||
const localCats = Array.isArray(l) ? l : [];
|
||||
const remoteCats = Array.isArray(r) ? r : [];
|
||||
merged[key] = [...new Set([...localCats, ...remoteCats])];
|
||||
continue;
|
||||
}
|
||||
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = clone(a); // unchanged
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = clone(l);
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = clone(r);
|
||||
} else {
|
||||
// Both changed
|
||||
if (deepEqual(l, r)) {
|
||||
merged[key] = clone(l); // same change
|
||||
} else {
|
||||
merged[key] = clone(l); // default to local, flag conflict
|
||||
conflicts.push({
|
||||
id: key,
|
||||
path: `settings.${key}`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, conflicts };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge identity objects.
|
||||
*
|
||||
* Profiles are matched by `id`. Within each profile, top-level scalar
|
||||
* fields (name, active, catchAllEmail, etc.) use three-way comparison.
|
||||
* Array fields (emails, names, usernames, hostnames, phones, emailDomains)
|
||||
* are compared by index position for simplicity.
|
||||
*
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @returns {{ merged: object, conflicts: Array }}
|
||||
*/
|
||||
_mergeIdentity(ancestor, local, remote) {
|
||||
const ancestorProfiles = ancestor?.profiles || [];
|
||||
const localProfiles = local?.profiles || [];
|
||||
const remoteProfiles = remote?.profiles || [];
|
||||
|
||||
const ancestorMap = new Map(ancestorProfiles.map((p) => [p.id, p]));
|
||||
const localMap = new Map(localProfiles.map((p) => [p.id, p]));
|
||||
const remoteMap = new Map(remoteProfiles.map((p) => [p.id, p]));
|
||||
|
||||
const allIds = new Set([
|
||||
...ancestorMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const mergedProfiles = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const id of allIds) {
|
||||
const a = ancestorMap.get(id);
|
||||
const l = localMap.get(id);
|
||||
const r = remoteMap.get(id);
|
||||
|
||||
const inA = ancestorMap.has(id);
|
||||
const inL = localMap.has(id);
|
||||
const inR = remoteMap.has(id);
|
||||
|
||||
// New on one side only
|
||||
if (!inA && inL && !inR) {
|
||||
mergedProfiles.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
if (!inA && !inL && inR) {
|
||||
mergedProfiles.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (!inA && inL && inR) {
|
||||
if (deepEqual(l, r)) {
|
||||
mergedProfiles.push(clone(l));
|
||||
} else {
|
||||
mergedProfiles.push(clone(l));
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inA) continue;
|
||||
|
||||
const localDeleted = !inL;
|
||||
const remoteDeleted = !inR;
|
||||
|
||||
// Both deleted
|
||||
if (localDeleted && remoteDeleted) continue;
|
||||
|
||||
// Deleted on one side, check if other side changed
|
||||
if (localDeleted) {
|
||||
if (deepEqual(a, r)) {
|
||||
continue; // deleted locally, unchanged remotely -> delete
|
||||
}
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: null,
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
mergedProfiles.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (remoteDeleted) {
|
||||
if (deepEqual(a, l)) {
|
||||
continue; // deleted remotely, unchanged locally -> delete
|
||||
}
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: null,
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
mergedProfiles.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both present — merge field by field within the profile
|
||||
const mergedProfile = this._mergeProfile(id, a, l, r, conflicts);
|
||||
mergedProfiles.push(mergedProfile);
|
||||
}
|
||||
|
||||
return {
|
||||
merged: { profiles: mergedProfiles },
|
||||
conflicts,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge a single identity profile field by field.
|
||||
*
|
||||
* Scalar fields: standard three-way comparison.
|
||||
* Array fields (emails, names, etc.): compared by index position.
|
||||
* The `enabled` sub-object: merged key by key.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @param {Array} conflicts - mutated; conflicts are pushed here
|
||||
* @returns {object} merged profile
|
||||
*/
|
||||
_mergeProfile(profileId, ancestor, local, remote, conflicts) {
|
||||
const merged = { id: profileId };
|
||||
const arrayFields = new Set(['emails', 'names', 'usernames', 'hostnames', 'phones', 'emailDomains']);
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === 'id') continue;
|
||||
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
// Merge `enabled` sub-object key by key
|
||||
if (key === 'enabled' && typeof l === 'object' && typeof r === 'object') {
|
||||
merged[key] = this._mergeEnabledFlags(profileId, a || {}, l, r, conflicts);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Array fields — compare by index
|
||||
if (arrayFields.has(key)) {
|
||||
merged[key] = this._mergeArray(profileId, key, a || [], l || [], r || [], conflicts);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scalar fields
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = clone(a);
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = clone(l);
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = clone(r);
|
||||
} else {
|
||||
if (deepEqual(l, r)) {
|
||||
merged[key] = clone(l);
|
||||
} else {
|
||||
merged[key] = clone(l);
|
||||
conflicts.push({
|
||||
id: `${profileId}.${key}`,
|
||||
path: `identity.profiles[${profileId}].${key}`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge the `enabled` flags sub-object within a profile.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @param {Array} conflicts
|
||||
* @returns {object}
|
||||
*/
|
||||
_mergeEnabledFlags(profileId, ancestor, local, remote, conflicts) {
|
||||
const merged = {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
const localChanged = a !== l;
|
||||
const remoteChanged = a !== r;
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = a;
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = l;
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = r;
|
||||
} else {
|
||||
if (l === r) {
|
||||
merged[key] = l;
|
||||
} else {
|
||||
merged[key] = l;
|
||||
conflicts.push({
|
||||
id: `${profileId}.enabled.${key}`,
|
||||
path: `identity.profiles[${profileId}].enabled.${key}`,
|
||||
localValue: l,
|
||||
remoteValue: r,
|
||||
ancestorValue: a,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge an array field within a profile by index position.
|
||||
*
|
||||
* Entries that exist at the same index in ancestor, local, and remote
|
||||
* are compared field by field. New entries appended on either side are
|
||||
* added. Entries removed from one side but unchanged on other are removed.
|
||||
*
|
||||
* For simplicity, if the array lengths diverge in complex ways, we fall
|
||||
* back to whole-array comparison and flag a conflict if both changed.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {string} fieldName
|
||||
* @param {Array} ancestor
|
||||
* @param {Array} local
|
||||
* @param {Array} remote
|
||||
* @param {Array} conflicts
|
||||
* @returns {Array}
|
||||
*/
|
||||
_mergeArray(profileId, fieldName, ancestor, local, remote, conflicts) {
|
||||
const maxLen = Math.max(ancestor.length, local.length, remote.length);
|
||||
|
||||
// Simple case: neither side changed
|
||||
if (deepEqual(ancestor, local) && deepEqual(ancestor, remote)) {
|
||||
return clone(ancestor);
|
||||
}
|
||||
|
||||
// Only one side changed
|
||||
if (deepEqual(ancestor, local) && !deepEqual(ancestor, remote)) {
|
||||
return clone(remote);
|
||||
}
|
||||
if (!deepEqual(ancestor, local) && deepEqual(ancestor, remote)) {
|
||||
return clone(local);
|
||||
}
|
||||
|
||||
// Both changed — try index-level merge
|
||||
const merged = [];
|
||||
let hasConflict = false;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = i < ancestor.length ? ancestor[i] : undefined;
|
||||
const l = i < local.length ? local[i] : undefined;
|
||||
const r = i < remote.length ? remote[i] : undefined;
|
||||
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
if (a !== undefined) merged.push(clone(a));
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
// l undefined means local deleted this index — skip
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
if (r !== undefined) merged.push(clone(r));
|
||||
} else {
|
||||
// Both changed at this index
|
||||
if (deepEqual(l, r)) {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
} else {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
hasConflict = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConflict) {
|
||||
conflicts.push({
|
||||
id: `${profileId}.${fieldName}`,
|
||||
path: `identity.profiles[${profileId}].${fieldName}`,
|
||||
localValue: clone(local),
|
||||
remoteValue: clone(remote),
|
||||
ancestorValue: clone(ancestor),
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a user's conflict resolution choice to the merged data.
|
||||
*
|
||||
* Navigates the `path` in the merged object and replaces the value
|
||||
* with either the local or remote version from the conflict record.
|
||||
*
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} merged
|
||||
* @param {{ id: string, path: string, localValue: *, remoteValue: * }} conflict
|
||||
* @param {'local'|'remote'} choice
|
||||
* @returns {{ identity: object, mappings: Array, settings: object }} the mutated merged object
|
||||
*/
|
||||
resolveConflict(merged, conflict, choice) {
|
||||
const value = choice === 'remote' ? conflict.remoteValue : conflict.localValue;
|
||||
const path = conflict.path;
|
||||
|
||||
// Handle mapping conflicts: mappings[<id>]
|
||||
const mappingMatch = path.match(/^mappings\[(.+)]$/);
|
||||
if (mappingMatch) {
|
||||
const id = mappingMatch[1];
|
||||
if (value === null) {
|
||||
// Choice is to delete
|
||||
merged.mappings = merged.mappings.filter((m) => m.id !== id);
|
||||
} else {
|
||||
const idx = merged.mappings.findIndex((m) => m.id === id);
|
||||
if (idx !== -1) {
|
||||
merged.mappings[idx] = clone(value);
|
||||
} else {
|
||||
merged.mappings.push(clone(value));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle settings conflicts: settings.<key>
|
||||
const settingsMatch = path.match(/^settings\.(.+)$/);
|
||||
if (settingsMatch) {
|
||||
const key = settingsMatch[1];
|
||||
merged.settings[key] = clone(value);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle identity profile-level conflicts: identity.profiles[<id>]
|
||||
const profileMatch = path.match(/^identity\.profiles\[(.+)]$/);
|
||||
if (profileMatch) {
|
||||
const id = profileMatch[1];
|
||||
if (value === null) {
|
||||
merged.identity.profiles = merged.identity.profiles.filter((p) => p.id !== id);
|
||||
} else {
|
||||
const idx = merged.identity.profiles.findIndex((p) => p.id === id);
|
||||
if (idx !== -1) {
|
||||
merged.identity.profiles[idx] = clone(value);
|
||||
} else {
|
||||
merged.identity.profiles.push(clone(value));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle profile field conflicts: identity.profiles[<id>].<field>
|
||||
const fieldMatch = path.match(/^identity\.profiles\[(.+?)]\.(.+)$/);
|
||||
if (fieldMatch) {
|
||||
const id = fieldMatch[1];
|
||||
const fieldPath = fieldMatch[2];
|
||||
const profile = merged.identity.profiles.find((p) => p.id === id);
|
||||
if (profile) {
|
||||
// Handle nested paths like "enabled.emails"
|
||||
const parts = fieldPath.split('.');
|
||||
let target = profile;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
target = target[parts[i]];
|
||||
if (!target) break;
|
||||
}
|
||||
if (target) {
|
||||
target[parts[parts.length - 1]] = clone(value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
};
|
||||
|
||||
export default SilentSendMerge;
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Silent Send - Organization Policy Manager
|
||||
*
|
||||
* Enables team/org administrators to enforce substitution policies
|
||||
* across all team members. Org-required mappings merge with personal
|
||||
* mappings and cannot be disabled by the user.
|
||||
*
|
||||
* Policy distribution:
|
||||
* - Admin hosts a JSON policy file at a URL (static host, S3, etc.)
|
||||
* - Team members join by entering the policy URL or an invite code
|
||||
* - Extension polls the policy URL periodically (default: hourly)
|
||||
* - Policy updates are applied automatically
|
||||
*
|
||||
* Privacy: the org admin can check compliance (are required fields
|
||||
* configured?) but CANNOT see individual PPI values.
|
||||
*/
|
||||
|
||||
import api from './browser-polyfill.js';
|
||||
|
||||
const ORG_CONFIG_KEY = 'ss_org_config';
|
||||
const ORG_POLICY_KEY = 'ss_org_policy';
|
||||
|
||||
const OrgPolicy = {
|
||||
// ----------------------------------------------------------------
|
||||
// Join / Leave
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Join an organization by policy URL or invite code.
|
||||
* Fetches the policy, validates it, and stores locally.
|
||||
*
|
||||
* @param {{ policyUrl?: string, inviteCode?: string }}
|
||||
* @returns {{ success: boolean, orgName?: string, reason?: string }}
|
||||
*/
|
||||
async joinOrg({ policyUrl, inviteCode }) {
|
||||
if (!policyUrl && !inviteCode) {
|
||||
return { success: false, reason: 'Provide a policy URL or invite code.' };
|
||||
}
|
||||
|
||||
// If invite code provided, it encodes the policy URL
|
||||
// Format: base64(JSON({ url: '...', orgId: '...' }))
|
||||
if (inviteCode && !policyUrl) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(inviteCode.trim()));
|
||||
policyUrl = decoded.url;
|
||||
} catch {
|
||||
return { success: false, reason: 'Invalid invite code.' };
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and validate the policy
|
||||
try {
|
||||
const resp = await fetch(policyUrl);
|
||||
if (!resp.ok) {
|
||||
return { success: false, reason: `Failed to fetch policy: HTTP ${resp.status}` };
|
||||
}
|
||||
|
||||
const policy = await resp.json();
|
||||
|
||||
if (!policy.orgId || !policy.orgName) {
|
||||
return { success: false, reason: 'Invalid policy — missing orgId or orgName.' };
|
||||
}
|
||||
|
||||
// Validate invite code if the policy requires one
|
||||
if (policy.inviteCode && inviteCode) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(inviteCode.trim()));
|
||||
if (decoded.orgId !== policy.orgId) {
|
||||
return { success: false, reason: 'Invite code does not match this organization.' };
|
||||
}
|
||||
} catch { /* non-fatal — URL join doesn't need code */ }
|
||||
}
|
||||
|
||||
// Store org config
|
||||
await api.storage.local.set({
|
||||
[ORG_CONFIG_KEY]: {
|
||||
policyUrl,
|
||||
orgId: policy.orgId,
|
||||
orgName: policy.orgName,
|
||||
joinedAt: Date.now(),
|
||||
lastFetch: Date.now(),
|
||||
},
|
||||
[ORG_POLICY_KEY]: policy,
|
||||
});
|
||||
|
||||
return { success: true, orgName: policy.orgName };
|
||||
} catch (e) {
|
||||
return { success: false, reason: 'Failed to fetch policy: ' + e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leave the organization. Removes org config and policy.
|
||||
* May require admin password if tamper protection is enabled.
|
||||
*/
|
||||
async leaveOrg() {
|
||||
await api.storage.local.remove([ORG_CONFIG_KEY, ORG_POLICY_KEY]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is in an organization.
|
||||
*/
|
||||
async isInOrg() {
|
||||
const result = await api.storage.local.get(ORG_CONFIG_KEY);
|
||||
return !!result[ORG_CONFIG_KEY]?.orgId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current org config (non-sensitive metadata).
|
||||
*/
|
||||
async getOrgConfig() {
|
||||
const result = await api.storage.local.get(ORG_CONFIG_KEY);
|
||||
return result[ORG_CONFIG_KEY] || null;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Policy fetch and update
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the latest policy from the org's URL.
|
||||
* Only applies if the version is newer than what we have.
|
||||
*
|
||||
* @returns {{ updated: boolean, reason?: string }}
|
||||
*/
|
||||
async fetchPolicy() {
|
||||
const config = await this.getOrgConfig();
|
||||
if (!config?.policyUrl) return { updated: false, reason: 'No org configured.' };
|
||||
|
||||
try {
|
||||
const resp = await fetch(config.policyUrl);
|
||||
if (!resp.ok) return { updated: false, reason: `HTTP ${resp.status}` };
|
||||
|
||||
const policy = await resp.json();
|
||||
const current = await this.getPolicy();
|
||||
|
||||
// Only update if version is newer
|
||||
if (current && policy.version <= (current.version || 0)) {
|
||||
// Update lastFetch timestamp
|
||||
config.lastFetch = Date.now();
|
||||
await api.storage.local.set({ [ORG_CONFIG_KEY]: config });
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
// Store updated policy
|
||||
await api.storage.local.set({
|
||||
[ORG_POLICY_KEY]: policy,
|
||||
[ORG_CONFIG_KEY]: { ...config, lastFetch: Date.now() },
|
||||
});
|
||||
|
||||
return { updated: true, version: policy.version };
|
||||
} catch (e) {
|
||||
return { updated: false, reason: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the cached policy.
|
||||
*/
|
||||
async getPolicy() {
|
||||
const result = await api.storage.local.get(ORG_POLICY_KEY);
|
||||
return result[ORG_POLICY_KEY] || null;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Policy enforcement — merge org rules with personal data
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge org-required mappings with personal mappings.
|
||||
* Org mappings are always included and cannot be disabled.
|
||||
*
|
||||
* @param {Array} personalMappings - user's own mappings
|
||||
* @returns {Array} merged mappings (org + personal)
|
||||
*/
|
||||
async getMergedMappings(personalMappings) {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy?.requiredMappings?.length) return personalMappings;
|
||||
|
||||
const orgMappings = policy.requiredMappings.map(m => ({
|
||||
id: `org-${policy.orgId}-${m.real}`,
|
||||
real: m.real,
|
||||
substitute: m.substitute,
|
||||
category: m.category || 'org',
|
||||
caseSensitive: m.caseSensitive ?? false,
|
||||
enabled: true, // always enabled — cannot be disabled
|
||||
_orgRequired: true,
|
||||
_orgId: policy.orgId,
|
||||
}));
|
||||
|
||||
// Remove personal mappings that conflict with org mappings (same real value)
|
||||
const orgReals = new Set(orgMappings.map(m => m.real.toLowerCase()));
|
||||
const filtered = personalMappings.filter(
|
||||
m => !orgReals.has(m.real.toLowerCase())
|
||||
);
|
||||
|
||||
return [...orgMappings, ...filtered];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get org-required secret scanner patterns.
|
||||
*
|
||||
* @returns {Array} additional patterns to add to the secret scanner
|
||||
*/
|
||||
async getOrgSecretPatterns() {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy?.requiredSecretPatterns?.length) return [];
|
||||
|
||||
return policy.requiredSecretPatterns.map(p => ({
|
||||
name: p.name,
|
||||
re: new RegExp(p.regex, 'g'),
|
||||
to: p.redact || '[REDACTED]',
|
||||
_orgRequired: true,
|
||||
}));
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Compliance checking (anonymized)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if the user's configuration meets org policy requirements.
|
||||
* Returns compliance status WITHOUT revealing actual PPI values.
|
||||
*
|
||||
* @returns {{ compliant: boolean, missing: string[], configured: string[] }}
|
||||
*/
|
||||
async checkCompliance() {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy) return { compliant: true, missing: [], configured: [] };
|
||||
|
||||
const result = await api.storage.local.get('ss_identity');
|
||||
const identity = result.ss_identity || {};
|
||||
const profiles = identity.profiles || [];
|
||||
const active = profiles.filter(p => p.active);
|
||||
|
||||
const missing = [];
|
||||
const configured = [];
|
||||
|
||||
const rules = policy.sharedIdentityRules || {};
|
||||
|
||||
// Check required categories
|
||||
if (rules.requiredCategories) {
|
||||
for (const cat of rules.requiredCategories) {
|
||||
let found = false;
|
||||
for (const p of active) {
|
||||
switch (cat) {
|
||||
case 'name':
|
||||
if ((p.names || []).some(n => n.real && n.substitute)) found = true;
|
||||
break;
|
||||
case 'email':
|
||||
if ((p.emails || []).some(e => e.real && e.substitute) || p.catchAllEmail) found = true;
|
||||
break;
|
||||
case 'username':
|
||||
if ((p.usernames || []).some(u => u.real && u.substitute)) found = true;
|
||||
break;
|
||||
case 'hostname':
|
||||
if ((p.hostnames || []).some(h => h.real && h.substitute)) found = true;
|
||||
break;
|
||||
case 'phone':
|
||||
if ((p.phones || []).some(ph => ph.real && ph.substitute)) found = true;
|
||||
break;
|
||||
case 'domain':
|
||||
found = true; // org-required mappings handle this
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
configured.push(cat);
|
||||
} else {
|
||||
missing.push(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check catch-all email requirement
|
||||
if (rules.requireCatchAllEmail) {
|
||||
const hasCatchAll = active.some(p => !!p.catchAllEmail);
|
||||
if (hasCatchAll) {
|
||||
configured.push('catch-all email');
|
||||
} else {
|
||||
missing.push('catch-all email');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
compliant: missing.length === 0,
|
||||
missing,
|
||||
configured,
|
||||
};
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Invite code generation (for admins)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate an invite code from a policy URL and org ID.
|
||||
* This is a simple base64 encoding — not a secret.
|
||||
*/
|
||||
generateInviteCode(policyUrl, orgId) {
|
||||
return btoa(JSON.stringify({ url: policyUrl, orgId }));
|
||||
},
|
||||
};
|
||||
|
||||
export default OrgPolicy;
|
||||
+159
@@ -874,6 +874,165 @@ const SilentSendSync = {
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Auto Sync — background polling for Gist/URL
|
||||
//
|
||||
// Config stored in ss_auto_sync_config (encrypted at rest).
|
||||
// Uses chrome.alarms API for MV3-safe periodic polling.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async getAutoSyncConfig() {
|
||||
const result = await api.storage.local.get('ss_auto_sync_config');
|
||||
return result.ss_auto_sync_config || null;
|
||||
},
|
||||
|
||||
async saveAutoSyncConfig(config) {
|
||||
await api.storage.local.set({ ss_auto_sync_config: config });
|
||||
},
|
||||
|
||||
/**
|
||||
* Perform an auto-sync cycle: pull from remote, push if local changed.
|
||||
* Called by the alarms listener in the service worker.
|
||||
*
|
||||
* @returns {{ pulled: boolean, pushed: boolean, error?: string }}
|
||||
*/
|
||||
async performAutoSync() {
|
||||
const config = await this.getAutoSyncConfig();
|
||||
if (!config?.enabled) return { pulled: false, pushed: false };
|
||||
|
||||
let pulled = false;
|
||||
let pushed = false;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
if (config.method === 'gist' && config.gistToken) {
|
||||
// Pull from Gist
|
||||
const pullResult = await this.pullFromGist(config.gistToken);
|
||||
if (pullResult.success && pullResult.imported) pulled = true;
|
||||
|
||||
// Push if local data changed since last push
|
||||
const local = await this._getAllData();
|
||||
if (!config.lastPush || local.lastModified > config.lastPush) {
|
||||
const pushResult = await this.pushToGist(config.gistToken);
|
||||
if (pushResult.success) {
|
||||
pushed = true;
|
||||
config.lastPush = Date.now();
|
||||
}
|
||||
}
|
||||
} else if (config.method === 'url' && config.url) {
|
||||
// Pull from URL
|
||||
const headers = config.headers || {};
|
||||
const pullResult = await this.pullFromUrl({ url: config.url, headers });
|
||||
if (pullResult.success && pullResult.imported) pulled = true;
|
||||
|
||||
// Push if local data changed since last push
|
||||
const local = await this._getAllData();
|
||||
if (!config.lastPush || local.lastModified > config.lastPush) {
|
||||
const pushResult = await this.pushToUrl({
|
||||
url: config.url,
|
||||
method: config.httpMethod || 'PUT',
|
||||
headers,
|
||||
});
|
||||
if (pushResult.success) {
|
||||
pushed = true;
|
||||
config.lastPush = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamps
|
||||
config.lastPull = Date.now();
|
||||
await this.saveAutoSyncConfig(config);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
return { pulled, pushed, error };
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Multi-Device Dashboard
|
||||
//
|
||||
// Each device registers itself with a unique ID + name.
|
||||
// Device list is embedded in the sync data so all devices
|
||||
// see each other.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get or create device info for this device.
|
||||
*/
|
||||
async getDeviceInfo() {
|
||||
const result = await api.storage.local.get('ss_device_info');
|
||||
if (result.ss_device_info) return result.ss_device_info;
|
||||
|
||||
// Auto-detect device name
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
let browser = 'Unknown';
|
||||
if (ua.includes('Firefox')) browser = 'Firefox';
|
||||
else if (ua.includes('Chrome')) browser = 'Chrome';
|
||||
else if (ua.includes('Safari')) browser = 'Safari';
|
||||
else if (ua.includes('Edge')) browser = 'Edge';
|
||||
|
||||
const platform = typeof navigator !== 'undefined'
|
||||
? (navigator.platform || navigator.userAgentData?.platform || 'Unknown')
|
||||
: 'Unknown';
|
||||
|
||||
const info = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${browser} on ${platform}`,
|
||||
browser,
|
||||
platform,
|
||||
createdAt: Date.now(),
|
||||
lastSync: Date.now(),
|
||||
};
|
||||
|
||||
await api.storage.local.set({ ss_device_info: info });
|
||||
return info;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename this device.
|
||||
*/
|
||||
async setDeviceName(name) {
|
||||
const info = await this.getDeviceInfo();
|
||||
info.name = name;
|
||||
await api.storage.local.set({ ss_device_info: info });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the list of all known devices.
|
||||
*/
|
||||
async getDevices() {
|
||||
const result = await api.storage.local.get('ss_devices');
|
||||
return result.ss_devices || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a device from the tracked list.
|
||||
*/
|
||||
async removeDevice(deviceId) {
|
||||
const devices = await this.getDevices();
|
||||
delete devices[deviceId];
|
||||
await api.storage.local.set({ ss_devices: devices });
|
||||
},
|
||||
|
||||
// Override _getAllData to include device info and device list
|
||||
async _getAllDataWithDevices() {
|
||||
const data = await this._getAllData();
|
||||
const deviceInfo = await this.getDeviceInfo();
|
||||
const devices = await this.getDevices();
|
||||
|
||||
// Register/update this device in the list
|
||||
devices[deviceInfo.id] = {
|
||||
...deviceInfo,
|
||||
lastSync: Date.now(),
|
||||
};
|
||||
await api.storage.local.set({ ss_devices: devices });
|
||||
|
||||
data.devices = devices;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export default SilentSendSync;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Silent Send - Tamper Protection
|
||||
*
|
||||
* Prevents unauthorized disabling, data clearing, or org policy
|
||||
* removal by requiring a separate admin password. This is distinct
|
||||
* from the vault encryption password.
|
||||
*
|
||||
* Protected actions:
|
||||
* - Disabling the extension (toggle off)
|
||||
* - Clearing all data (reset)
|
||||
* - Clearing mappings
|
||||
* - Changing or removing org policy URL
|
||||
* - Exporting data (plain, unencrypted)
|
||||
*
|
||||
* Limitations (documented in UI):
|
||||
* - Cannot prevent browser-level extension uninstall
|
||||
* - Cannot prevent clearing browser data via browser settings
|
||||
* - A determined user with dev tools can bypass this
|
||||
* - This is a deterrent for casual tampering, not a security boundary
|
||||
*
|
||||
* Org integration:
|
||||
* - If org policy sets disableTamperProtection: false, the tamper
|
||||
* guard cannot be turned off even with the admin password
|
||||
* - If org policy sets disableTamperProtection: true, the org
|
||||
* admin is opting out of this feature
|
||||
*/
|
||||
|
||||
import api from './browser-polyfill.js';
|
||||
|
||||
const ADMIN_AUTH_KEY = 'ss_admin_auth';
|
||||
const ITERATIONS = 200000; // higher than sync encryption for extra security
|
||||
const SALT_LENGTH = 16;
|
||||
|
||||
const TamperGuard = {
|
||||
/**
|
||||
* Check if tamper protection is enabled.
|
||||
*/
|
||||
async isEnabled() {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
return !!result[ADMIN_AUTH_KEY]?.enabled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the tamper guard config (without the password hash).
|
||||
*/
|
||||
async getConfig() {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config) return null;
|
||||
// Don't expose the hash
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
protectedActions: config.protectedActions,
|
||||
orgCanDisable: config.orgCanDisable,
|
||||
createdAt: config.createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up tamper protection with an admin password.
|
||||
*
|
||||
* @param {string} adminPassword - must be different from encryption password
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async setup(adminPassword) {
|
||||
if (!adminPassword || adminPassword.length < 4) {
|
||||
return { success: false, reason: 'Admin password must be at least 4 characters.' };
|
||||
}
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||
const hash = await this._hashPassword(adminPassword, salt);
|
||||
|
||||
const config = {
|
||||
enabled: true,
|
||||
passwordHash: hash,
|
||||
salt: btoa(String.fromCharCode(...salt)),
|
||||
protectedActions: [
|
||||
'disable',
|
||||
'clearData',
|
||||
'clearMappings',
|
||||
'changeOrgPolicy',
|
||||
'exportPlain',
|
||||
],
|
||||
orgCanDisable: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
await api.storage.local.set({ [ADMIN_AUTH_KEY]: config });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify the admin password.
|
||||
*
|
||||
* @param {string} adminPassword
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async verify(adminPassword) {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config?.enabled) return true; // not enabled = always passes
|
||||
|
||||
const salt = Uint8Array.from(atob(config.salt), c => c.charCodeAt(0));
|
||||
const hash = await this._hashPassword(adminPassword, salt);
|
||||
|
||||
return hash === config.passwordHash;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific action requires admin authentication.
|
||||
*
|
||||
* @param {string} action - e.g. 'disable', 'clearData'
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async isActionProtected(action) {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config?.enabled) return false;
|
||||
return (config.protectedActions || []).includes(action);
|
||||
},
|
||||
|
||||
/**
|
||||
* Require admin authentication for an action.
|
||||
* Returns true if auth passes (or isn't needed), false if denied.
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {string} adminPassword
|
||||
* @returns {{ allowed: boolean, reason?: string }}
|
||||
*/
|
||||
async requireAuth(action, adminPassword) {
|
||||
const isProtected = await this.isActionProtected(action);
|
||||
if (!isProtected) return { allowed: true };
|
||||
|
||||
if (!adminPassword) {
|
||||
return { allowed: false, reason: 'Admin password required.' };
|
||||
}
|
||||
|
||||
const valid = await this.verify(adminPassword);
|
||||
if (!valid) {
|
||||
return { allowed: false, reason: 'Wrong admin password.' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable tamper protection.
|
||||
* Requires the admin password unless org policy allows disabling.
|
||||
*
|
||||
* @param {string} adminPassword
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async disable(adminPassword) {
|
||||
// Check org policy
|
||||
const orgPolicy = await this._getOrgPolicy();
|
||||
if (orgPolicy && orgPolicy.disableTamperProtection === false) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Organization policy prevents disabling tamper protection.',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await this.verify(adminPassword);
|
||||
if (!valid) {
|
||||
return { success: false, reason: 'Wrong admin password.' };
|
||||
}
|
||||
|
||||
await api.storage.local.remove(ADMIN_AUTH_KEY);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the admin password.
|
||||
*
|
||||
* @param {string} oldPassword
|
||||
* @param {string} newPassword
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async changePassword(oldPassword, newPassword) {
|
||||
const valid = await this.verify(oldPassword);
|
||||
if (!valid) {
|
||||
return { success: false, reason: 'Wrong current admin password.' };
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 4) {
|
||||
return { success: false, reason: 'New password must be at least 4 characters.' };
|
||||
}
|
||||
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||
config.passwordHash = await this._hashPassword(newPassword, salt);
|
||||
config.salt = btoa(String.fromCharCode(...salt));
|
||||
|
||||
await api.storage.local.set({ [ADMIN_AUTH_KEY]: config });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hash a password with PBKDF2-SHA256.
|
||||
* Returns base64-encoded hash string.
|
||||
*/
|
||||
async _hashPassword(password, salt) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bits)));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get org policy (if any) to check tamper protection override.
|
||||
*/
|
||||
async _getOrgPolicy() {
|
||||
try {
|
||||
const result = await api.storage.local.get('ss_org_policy');
|
||||
return result.ss_org_policy || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default TamperGuard;
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Silent Send - Version History Manager
|
||||
*
|
||||
* Manages sync version history using IndexedDB. Stores snapshots of the
|
||||
* extension's data (identity, mappings, settings) so the user can
|
||||
* rollback to a previous state after a sync overwrites local data.
|
||||
*
|
||||
* Each snapshot records the source that triggered it (e.g. 'gist',
|
||||
* 'browser-sync', 'rollback') and a full copy of the data at that
|
||||
* point in time. Old snapshots are automatically pruned to stay within
|
||||
* a configurable maximum (default 10).
|
||||
*
|
||||
* Database: IndexedDB 'ss_version_history', version 1
|
||||
* Object store: 'snapshots' (autoIncrement keyPath 'id', index on 'timestamp')
|
||||
*/
|
||||
|
||||
const DB_NAME = 'ss_version_history';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'snapshots';
|
||||
const DEFAULT_MAX_SNAPSHOTS = 10;
|
||||
|
||||
const VersionHistory = {
|
||||
// ----------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open (or create/upgrade) the IndexedDB database.
|
||||
* @returns {Promise<IDBDatabase>}
|
||||
*/
|
||||
async _openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Public API
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a snapshot of the current extension data.
|
||||
*
|
||||
* @param {Object} data - The data payload to snapshot.
|
||||
* @param {string} data.version - Data schema version.
|
||||
* @param {number} data.lastModified - Epoch ms of the data.
|
||||
* @param {Object} data.identity - Identity fields.
|
||||
* @param {Array} data.mappings - Substitution mappings.
|
||||
* @param {Object} data.settings - Extension settings.
|
||||
* @param {string} source - Origin of the snapshot, e.g.
|
||||
* 'local'|'gist'|'url'|'browser-sync'|'file'|'code'|'rollback'.
|
||||
* @returns {Promise<number>} The auto-generated snapshot id.
|
||||
*/
|
||||
async saveSnapshot(data, source) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = {
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
data,
|
||||
};
|
||||
|
||||
const id = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.add(snapshot);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Prune old snapshots beyond the configured maximum
|
||||
const maxVersions = data?.settings?.maxVersionHistory ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
await this.pruneToMax(maxVersions);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve all snapshots, sorted newest first.
|
||||
* @returns {Promise<Array>} Array of snapshot objects.
|
||||
*/
|
||||
async getSnapshots() {
|
||||
const db = await this._openDB();
|
||||
const snapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Sort newest first by timestamp (fallback to id descending)
|
||||
snapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
return snapshots;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a single snapshot by id.
|
||||
* @param {number} id - The snapshot id.
|
||||
* @returns {Promise<Object|undefined>} The snapshot, or undefined.
|
||||
*/
|
||||
async getSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a single snapshot by id.
|
||||
* @param {number} id - The snapshot id to remove.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep only the N newest snapshots, deleting the rest.
|
||||
* @param {number} [maxVersions] - Maximum snapshots to retain
|
||||
* (defaults to DEFAULT_MAX_SNAPSHOTS).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async pruneToMax(maxVersions) {
|
||||
const max = maxVersions ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
if (max < 1) return;
|
||||
|
||||
const db = await this._openDB();
|
||||
const allSnapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
// Sort newest first, then mark everything beyond `max` for deletion
|
||||
allSnapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
const toDelete = allSnapshots.slice(max);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const snap of toDelete) {
|
||||
store.delete(snap.id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all snapshots (wipe version history).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAll() {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
|
||||
export default VersionHistory;
|
||||
@@ -276,6 +276,34 @@
|
||||
<div id="gistSyncStatus" style="font-size:12px;min-height:16px"></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">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Custom URL Sync</h3>
|
||||
<p class="section-desc" style="margin-bottom:8px">
|
||||
@@ -297,6 +325,70 @@
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
@@ -379,6 +471,46 @@
|
||||
|
||||
<section class="section">
|
||||
<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>
|
||||
<label>Reset all data</label>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Storage from '../lib/storage.js';
|
||||
import SilentSendCrypto from '../lib/crypto.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';
|
||||
|
||||
let mappings = [];
|
||||
@@ -25,6 +29,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
renderDomains();
|
||||
renderLog();
|
||||
|
||||
// --- New features ---
|
||||
await initAutoSyncUI();
|
||||
await initVersionHistoryUI();
|
||||
await initDeviceDashboard();
|
||||
await initOrgUI();
|
||||
await initTamperUI();
|
||||
await checkConflicts();
|
||||
|
||||
// --- Sync Encryption UI ---
|
||||
await initSyncEncryptionUI();
|
||||
|
||||
@@ -1049,6 +1061,446 @@ function setSyncAuthStatus(msg, type) {
|
||||
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) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
|
||||
Reference in New Issue
Block a user