feat: multiple identity profiles
Identity tab now supports multiple named profiles: - Dropdown selector to switch between profiles - "+" button to add a new profile (prompts for name) - Pencil button to rename - X button to delete (can't delete the last one) - Toggle to enable/disable each profile independently Default profile is "Personal". All active profiles are merged and substituted simultaneously — so "Personal" (your name) and "Work" (your work email, company domain) both get caught. Storage model: profiles are stored as an array under ss_identity. getIdentity() merges all active profiles into a single identity object for the substitution engine. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
+86
-5
@@ -67,14 +67,17 @@ const Storage = {
|
||||
await this.saveMappings(filtered);
|
||||
},
|
||||
|
||||
// --- Identity (Smart Patterns) ---
|
||||
// --- Identity Profiles (Smart Patterns) ---
|
||||
|
||||
async getIdentity() {
|
||||
const result = await api.storage.local.get(KEYS.IDENTITY);
|
||||
return result[KEYS.IDENTITY] || {
|
||||
_emptyProfile() {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: 'Personal',
|
||||
active: true,
|
||||
emails: [],
|
||||
names: [],
|
||||
usernames: [],
|
||||
hostnames: [],
|
||||
phones: [],
|
||||
catchAllEmail: '',
|
||||
emailDomains: [],
|
||||
@@ -82,8 +85,86 @@ const Storage = {
|
||||
};
|
||||
},
|
||||
|
||||
async getProfiles() {
|
||||
const result = await api.storage.local.get(KEYS.IDENTITY);
|
||||
const data = result[KEYS.IDENTITY];
|
||||
if (data?.profiles) return data.profiles;
|
||||
return [];
|
||||
},
|
||||
|
||||
async saveProfiles(profiles) {
|
||||
await api.storage.local.set({ [KEYS.IDENTITY]: { profiles } });
|
||||
},
|
||||
|
||||
async addProfile(name) {
|
||||
const profiles = await this.getProfiles();
|
||||
const profile = { ...this._emptyProfile(), name: name || `Profile ${profiles.length + 1}` };
|
||||
profiles.push(profile);
|
||||
await this.saveProfiles(profiles);
|
||||
return profile;
|
||||
},
|
||||
|
||||
async updateProfile(id, updates) {
|
||||
const profiles = await this.getProfiles();
|
||||
const idx = profiles.findIndex((p) => p.id === id);
|
||||
if (idx === -1) return null;
|
||||
profiles[idx] = { ...profiles[idx], ...updates };
|
||||
await this.saveProfiles(profiles);
|
||||
return profiles[idx];
|
||||
},
|
||||
|
||||
async deleteProfile(id) {
|
||||
const profiles = await this.getProfiles();
|
||||
const filtered = profiles.filter((p) => p.id !== id);
|
||||
await this.saveProfiles(filtered);
|
||||
},
|
||||
|
||||
// Merged identity: combines all active profiles into one identity object
|
||||
// for the substitution engine (which expects a single identity)
|
||||
async getIdentity() {
|
||||
const profiles = await this.getProfiles();
|
||||
const active = profiles.filter((p) => p.active);
|
||||
|
||||
if (active.length === 0) {
|
||||
return { emails: [], names: [], usernames: [], hostnames: [], phones: [],
|
||||
catchAllEmail: '', emailDomains: [], enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } };
|
||||
}
|
||||
|
||||
// Merge all active profiles
|
||||
const merged = {
|
||||
emails: [],
|
||||
names: [],
|
||||
usernames: [],
|
||||
hostnames: [],
|
||||
phones: [],
|
||||
catchAllEmail: '',
|
||||
emailDomains: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
|
||||
for (const p of active) {
|
||||
merged.emails.push(...(p.emails || []));
|
||||
merged.names.push(...(p.names || []));
|
||||
merged.usernames.push(...(p.usernames || []));
|
||||
merged.hostnames.push(...(p.hostnames || []));
|
||||
merged.phones.push(...(p.phones || []));
|
||||
if (p.catchAllEmail && !merged.catchAllEmail) merged.catchAllEmail = p.catchAllEmail;
|
||||
merged.emailDomains.push(...(p.emailDomains || []));
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
// Legacy compat: saveIdentity saves to first profile
|
||||
async saveIdentity(identity) {
|
||||
await api.storage.local.set({ [KEYS.IDENTITY]: identity });
|
||||
const profiles = await this.getProfiles();
|
||||
if (profiles.length === 0) {
|
||||
const profile = { ...this._emptyProfile(), ...identity };
|
||||
await this.saveProfiles([profile]);
|
||||
} else {
|
||||
profiles[0] = { ...profiles[0], ...identity };
|
||||
await this.saveProfiles(profiles);
|
||||
}
|
||||
},
|
||||
|
||||
// --- Activity Log ---
|
||||
|
||||
@@ -276,6 +276,28 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Profile bar */
|
||||
.profile-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.profile-bar .select {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-bar .btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Identity tab */
|
||||
.id-section {
|
||||
margin-bottom: 12px;
|
||||
|
||||
+12
-1
@@ -46,7 +46,18 @@
|
||||
|
||||
<!-- Identity Tab (Smart Patterns) -->
|
||||
<section class="tab-content active" id="tab-identity">
|
||||
<p class="help-text">Tell Silent Send who you are. It auto-catches all variations.</p>
|
||||
<!-- Profile selector -->
|
||||
<div class="profile-bar">
|
||||
<select id="profileSelect" class="select" style="flex:1"></select>
|
||||
<button class="btn-icon" id="btnAddProfile" title="Add profile">+</button>
|
||||
<button class="btn-icon" id="btnRenameProfile" title="Rename profile">✎</button>
|
||||
<button class="btn-icon" id="btnDeleteProfile" title="Delete profile">×</button>
|
||||
<label class="toggle" title="Enable/disable this profile" style="margin-left:4px">
|
||||
<input type="checkbox" id="profileActive" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="help-text">Each profile is a person/identity. All active profiles are protected simultaneously.</p>
|
||||
|
||||
<div class="id-section">
|
||||
<div class="id-label">Names</div>
|
||||
|
||||
+115
-38
@@ -6,7 +6,9 @@ import api from '../lib/browser-polyfill.js';
|
||||
|
||||
// --- State ---
|
||||
let mappings = [];
|
||||
let identity = {};
|
||||
let identity = {}; // merged identity (all active profiles)
|
||||
let profiles = []; // all profiles
|
||||
let currentProfileId = null; // currently selected profile for editing
|
||||
let settings = {};
|
||||
|
||||
// --- DOM refs ---
|
||||
@@ -16,12 +18,21 @@ const $$ = (sel) => document.querySelectorAll(sel);
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
mappings = await Storage.getMappings();
|
||||
profiles = await Storage.getProfiles();
|
||||
identity = await Storage.getIdentity();
|
||||
settings = await Storage.getSettings();
|
||||
|
||||
// Initialize profiles — create default if none exist
|
||||
if (profiles.length === 0) {
|
||||
const p = await Storage.addProfile('Personal');
|
||||
profiles = [p];
|
||||
}
|
||||
currentProfileId = profiles[0].id;
|
||||
|
||||
renderProfileSelector();
|
||||
loadIdentityForm();
|
||||
renderMappings();
|
||||
renderActivity();
|
||||
loadIdentityForm();
|
||||
updateStatusDot();
|
||||
checkFirstRun();
|
||||
|
||||
@@ -37,12 +48,20 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
if (tab.dataset.tab === 'activity') renderActivity();
|
||||
if (tab.dataset.tab === 'test') {
|
||||
// Reload identity from storage in case it was just saved
|
||||
// Reload merged identity from storage in case profiles were just saved
|
||||
Storage.getIdentity().then((id) => {
|
||||
identity = id;
|
||||
updateIdentityStatus();
|
||||
});
|
||||
}
|
||||
if (tab.dataset.tab === 'identity') {
|
||||
// Reload profiles
|
||||
Storage.getProfiles().then((p) => {
|
||||
profiles = p;
|
||||
renderProfileSelector();
|
||||
loadIdentityForm();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +89,57 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
$('#btnReveal').classList.toggle('active', settings.revealMode);
|
||||
|
||||
// Profile controls
|
||||
$('#profileSelect').addEventListener('change', (e) => {
|
||||
currentProfileId = e.target.value;
|
||||
loadIdentityForm();
|
||||
});
|
||||
|
||||
$('#btnAddProfile').addEventListener('click', async () => {
|
||||
const name = prompt('Profile name:', `Profile ${profiles.length + 1}`);
|
||||
if (!name) return;
|
||||
const p = await Storage.addProfile(name);
|
||||
profiles = await Storage.getProfiles();
|
||||
currentProfileId = p.id;
|
||||
renderProfileSelector();
|
||||
loadIdentityForm();
|
||||
checkFirstRun();
|
||||
});
|
||||
|
||||
$('#btnRenameProfile').addEventListener('click', async () => {
|
||||
const profile = profiles.find(p => p.id === currentProfileId);
|
||||
if (!profile) return;
|
||||
const name = prompt('Rename profile:', profile.name);
|
||||
if (!name) return;
|
||||
await Storage.updateProfile(currentProfileId, { name });
|
||||
profiles = await Storage.getProfiles();
|
||||
renderProfileSelector();
|
||||
});
|
||||
|
||||
$('#btnDeleteProfile').addEventListener('click', async () => {
|
||||
if (profiles.length <= 1) {
|
||||
alert('Cannot delete the last profile.');
|
||||
return;
|
||||
}
|
||||
const profile = profiles.find(p => p.id === currentProfileId);
|
||||
if (!confirm(`Delete profile "${profile?.name}"?`)) return;
|
||||
await Storage.deleteProfile(currentProfileId);
|
||||
profiles = await Storage.getProfiles();
|
||||
currentProfileId = profiles[0]?.id;
|
||||
renderProfileSelector();
|
||||
loadIdentityForm();
|
||||
identity = await Storage.getIdentity();
|
||||
checkFirstRun();
|
||||
});
|
||||
|
||||
$('#profileActive').addEventListener('change', async (e) => {
|
||||
await Storage.updateProfile(currentProfileId, { active: e.target.checked });
|
||||
profiles = await Storage.getProfiles();
|
||||
identity = await Storage.getIdentity();
|
||||
renderProfileSelector();
|
||||
checkFirstRun();
|
||||
});
|
||||
|
||||
// Save identity
|
||||
$('#btnSaveIdentity').addEventListener('click', saveIdentity);
|
||||
|
||||
@@ -108,40 +178,45 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Profiles ---
|
||||
function renderProfileSelector() {
|
||||
const select = $('#profileSelect');
|
||||
select.innerHTML = profiles.map(p =>
|
||||
`<option value="${p.id}" ${p.id === currentProfileId ? 'selected' : ''}>` +
|
||||
`${escapeHtml(p.name)}${p.active ? '' : ' (off)'}` +
|
||||
`</option>`
|
||||
).join('');
|
||||
|
||||
const profile = profiles.find(p => p.id === currentProfileId);
|
||||
$('#profileActive').checked = profile?.active ?? true;
|
||||
}
|
||||
|
||||
// --- Identity ---
|
||||
function loadIdentityForm() {
|
||||
const first = (identity.names || []).find(n => n.type === 'first');
|
||||
const last = (identity.names || []).find(n => n.type === 'last');
|
||||
const email = (identity.emails || [])[0];
|
||||
const user = (identity.usernames || [])[0];
|
||||
const phone = (identity.phones || [])[0];
|
||||
const profile = profiles.find(p => p.id === currentProfileId);
|
||||
if (!profile) return;
|
||||
|
||||
if (first) {
|
||||
$('#idFirstReal').value = first.real || '';
|
||||
$('#idFirstSub').value = first.substitute || '';
|
||||
}
|
||||
if (last) {
|
||||
$('#idLastReal').value = last.real || '';
|
||||
$('#idLastSub').value = last.substitute || '';
|
||||
}
|
||||
if (email) {
|
||||
$('#idEmailReal').value = email.real || '';
|
||||
$('#idEmailSub').value = email.substitute || '';
|
||||
}
|
||||
$('#idCatchAllEmail').value = identity.catchAllEmail || '';
|
||||
if (user) {
|
||||
$('#idUserReal').value = user.real || '';
|
||||
$('#idUserSub').value = user.substitute || '';
|
||||
}
|
||||
const host = (identity.hostnames || [])[0];
|
||||
if (host) {
|
||||
$('#idHostReal').value = host.real || '';
|
||||
$('#idHostSub').value = host.substitute || '';
|
||||
}
|
||||
if (phone) {
|
||||
$('#idPhoneReal').value = phone.real || '';
|
||||
$('#idPhoneSub').value = phone.substitute || '';
|
||||
}
|
||||
const first = (profile.names || []).find(n => n.type === 'first');
|
||||
const last = (profile.names || []).find(n => n.type === 'last');
|
||||
const email = (profile.emails || [])[0];
|
||||
const user = (profile.usernames || [])[0];
|
||||
const host = (profile.hostnames || [])[0];
|
||||
const phone = (profile.phones || [])[0];
|
||||
|
||||
$('#idFirstReal').value = first?.real || '';
|
||||
$('#idFirstSub').value = first?.substitute || '';
|
||||
$('#idLastReal').value = last?.real || '';
|
||||
$('#idLastSub').value = last?.substitute || '';
|
||||
$('#idEmailReal').value = email?.real || '';
|
||||
$('#idEmailSub').value = email?.substitute || '';
|
||||
$('#idCatchAllEmail').value = profile.catchAllEmail || '';
|
||||
$('#idUserReal').value = user?.real || '';
|
||||
$('#idUserSub').value = user?.substitute || '';
|
||||
$('#idHostReal').value = host?.real || '';
|
||||
$('#idHostSub').value = host?.substitute || '';
|
||||
$('#idPhoneReal').value = phone?.real || '';
|
||||
$('#idPhoneSub').value = phone?.substitute || '';
|
||||
$('#profileActive').checked = profile.active ?? true;
|
||||
}
|
||||
|
||||
async function saveIdentity() {
|
||||
@@ -185,18 +260,20 @@ async function saveIdentity() {
|
||||
phones.push({ real: phoneReal, substitute: phoneSub });
|
||||
}
|
||||
|
||||
identity = {
|
||||
const profileData = {
|
||||
names,
|
||||
emails,
|
||||
usernames,
|
||||
hostnames,
|
||||
phones,
|
||||
catchAllEmail: $('#idCatchAllEmail').value.trim(),
|
||||
emailDomains: identity.emailDomains || [],
|
||||
enabled: identity.enabled || { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
emailDomains: [],
|
||||
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
|
||||
};
|
||||
|
||||
await Storage.saveIdentity(identity);
|
||||
await Storage.updateProfile(currentProfileId, profileData);
|
||||
profiles = await Storage.getProfiles();
|
||||
identity = await Storage.getIdentity();
|
||||
checkFirstRun();
|
||||
|
||||
// Flash save button
|
||||
|
||||
Reference in New Issue
Block a user