Identity IPC commands + frontend identity management UI

New Tauri commands: list_identities, create_identity, switch_identity,
delete_identity, import_identity_key, get_active_identity.

Settings UI: Identities section with list (active indicator, switch/delete
buttons), Create New Identity lightbox, Import Identity Key lightbox.
Export/Import buttons as placeholders for Phase 2/3.

Identity switch does hot-swap: tears down old Node, starts new one with
all background tasks, swaps AppNode RwLock. Frontend reloads after switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-31 19:08:21 -04:00
parent 75ce965b63
commit 18a40756d8
3 changed files with 250 additions and 1 deletions

View file

@ -2857,7 +2857,7 @@ document.querySelectorAll('.tab').forEach(tab => {
loadMessages(true); loadDmRecipientOptions();
clearNotifications('msg-');
}
if (target === 'settings') { loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
if (target === 'settings') { loadIdentities(); loadRedundancy(); loadPublicVisible(); loadCacheSizeSetting(); }
});
});
});
@ -3199,6 +3199,121 @@ document.querySelectorAll('.text-size-opt').forEach(btn => {
});
});
// --- Identity management ---
async function loadIdentities() {
const list = $('#identities-list');
if (!list) return;
try {
const identities = await invoke('list_identities');
if (identities.length === 0) {
list.innerHTML = '<p class="empty-hint">No identities</p>';
return;
}
list.innerHTML = identities.map(id => {
const active = id.isActive ? ' <span style="color:#7fdbca;font-size:0.7rem">(active)</span>' : '';
const switchBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm switch-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem">Switch</button>`;
const deleteBtn = id.isActive ? '' : `<button class="btn btn-ghost btn-sm delete-id-btn" data-id="${id.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222">
<div>
<span style="font-weight:600">${escapeHtml(id.displayName)}</span>${active}
<div style="font-size:0.6rem;color:#666">${id.nodeId.substring(0, 16)}...</div>
</div>
<div style="display:flex;gap:0.3rem">${switchBtn}${deleteBtn}</div>
</div>`;
}).join('');
// Wire switch buttons
list.querySelectorAll('.switch-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Switching...';
try {
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
toast('Identity switched — reloading...');
setTimeout(() => location.reload(), 1000);
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
});
});
// Wire delete buttons
list.querySelectorAll('.delete-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this identity? This cannot be undone.')) return;
try {
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
toast('Identity deleted');
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
});
} catch (e) {
list.innerHTML = `<p class="empty-hint">Error: ${e}</p>`;
}
}
$('#create-identity-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#new-id-create').addEventListener('click', async () => {
const name = overlay.querySelector('#new-id-name').value.trim();
if (!name) { toast('Name is required'); return; }
try {
const nodeId = await invoke('create_identity', { name });
toast(`Identity created: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#new-id-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
$('#import-identity-btn').addEventListener('click', () => {
const overlay = document.createElement('div');
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
</div>
</div>`;
document.body.appendChild(overlay);
overlay.querySelector('#import-id-go').addEventListener('click', async () => {
const keyHex = overlay.querySelector('#import-id-key').value.trim();
const name = overlay.querySelector('#import-id-name').value.trim() || 'Imported';
if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; }
try {
const nodeId = await invoke('import_identity_key', { keyHex, name });
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
overlay.querySelector('#import-id-cancel').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
});
// Export/Import buttons (placeholder — will be implemented in Phase 2/3)
$('#export-btn').addEventListener('click', () => { toast('Export coming soon'); });
$('#import-btn').addEventListener('click', () => { toast('Import coming soon'); });
$('#notifications-btn').addEventListener('click', async () => {
// Load current settings
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';

View file

@ -182,6 +182,22 @@
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.5rem">Identities</h3>
<div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Identity</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Key</button>
</div>
</div>
<div class="section-card" style="text-align:center">
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>
<button id="import-btn" class="btn btn-ghost btn-sm">Import</button>
</div>
</div>
<div class="section-card">
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
</div>