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

@ -2034,6 +2034,118 @@ fn hex_to_postid(hex_str: &str) -> Result<itsgoin_core::types::PostId, String> {
// --- App setup ---
// --- Identity management IPC commands ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IdentityInfoDto {
node_id: String,
display_name: String,
created_at: u64,
last_used_at: u64,
is_active: bool,
}
#[tauri::command]
async fn list_identities(state: State<'_, AppIdentity>) -> Result<Vec<IdentityInfoDto>, String> {
let mgr = state.lock().await;
let active_id = mgr.active_id();
let identities = mgr.list_identities().map_err(|e| e.to_string())?;
Ok(identities.into_iter().map(|i| IdentityInfoDto {
is_active: active_id.map_or(false, |a| a == i.node_id),
node_id: i.node_id_hex,
display_name: i.display_name,
created_at: i.created_at,
last_used_at: i.last_used_at,
}).collect())
}
#[tauri::command]
async fn create_identity(state: State<'_, AppIdentity>, name: String) -> Result<String, String> {
let mgr = state.lock().await;
let node_id = mgr.create_identity(&name).map_err(|e| e.to_string())?;
Ok(hex::encode(node_id))
}
#[tauri::command]
async fn switch_identity(
node_state: State<'_, AppNode>,
id_state: State<'_, AppIdentity>,
node_id_hex: String,
) -> Result<String, String> {
let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;
let mut mgr = id_state.lock().await;
let new_node = mgr.switch_identity(&nid).await.map_err(|e| e.to_string())?;
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_pull_cycle(300);
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_anchor_register_cycle(600);
new_node.start_upnp_renewal_cycle();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
new_node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = new_node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&new_node), 300, cache_max_bytes);
// Hot-swap the active node
{
let mut current = node_state.write().await;
*current = new_node;
}
Ok(format!("Switched to {}", &node_id_hex[..12]))
}
#[tauri::command]
async fn delete_identity(state: State<'_, AppIdentity>, node_id_hex: String) -> Result<String, String> {
let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;
let mgr = state.lock().await;
mgr.delete_identity(&nid).map_err(|e| e.to_string())?;
Ok("Identity deleted".to_string())
}
#[tauri::command]
async fn import_identity_key(state: State<'_, AppIdentity>, key_hex: String, name: String) -> Result<String, String> {
let mgr = state.lock().await;
let node_id = mgr.import_identity_from_key(&key_hex, &name).map_err(|e| e.to_string())?;
Ok(hex::encode(node_id))
}
#[tauri::command]
async fn get_active_identity(state: State<'_, AppIdentity>) -> Result<Option<IdentityInfoDto>, String> {
let mgr = state.lock().await;
let active_id = mgr.active_id();
if active_id.is_none() {
return Ok(None);
}
let identities = mgr.list_identities().map_err(|e| e.to_string())?;
Ok(identities.into_iter().find(|i| Some(i.node_id) == active_id).map(|i| IdentityInfoDto {
is_active: true,
node_id: i.node_id_hex,
display_name: i.display_name,
created_at: i.created_at,
last_used_at: i.last_used_at,
}))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt()
@ -2216,6 +2328,12 @@ pub fn run() {
get_badge_counts,
get_last_read_message,
generate_share_link,
list_identities,
create_identity,
switch_identity,
delete_identity,
import_identity_key,
get_active_identity,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

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>