diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index a4d862a..d0ff7cf 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -2034,6 +2034,118 @@ fn hex_to_postid(hex_str: &str) -> Result { // --- 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, 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 { + 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 { + 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 { + 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 { + 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, 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") diff --git a/frontend/app.js b/frontend/app.js index 37005e3..a56c46c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -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 = '

No identities

'; + return; + } + list.innerHTML = identities.map(id => { + const active = id.isActive ? ' (active)' : ''; + const switchBtn = id.isActive ? '' : ``; + const deleteBtn = id.isActive ? '' : ``; + return `
+
+ ${escapeHtml(id.displayName)}${active} +
${id.nodeId.substring(0, 16)}...
+
+
${switchBtn}${deleteBtn}
+
`; + }).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 = `

Error: ${e}

`; + } +} + +$('#create-identity-btn').addEventListener('click', () => { + const overlay = document.createElement('div'); + overlay.className = 'image-lightbox'; + overlay.style.cursor = 'default'; + overlay.innerHTML = ` +
+

New Identity

+ +
+ + +
+
`; + 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 = ` +
+

Import Identity

+

Paste the 64-character hex key from an identity export.

+ + +
+ + +
+
`; + 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'; diff --git a/frontend/index.html b/frontend/index.html index 46ccff2..3a8e927 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -182,6 +182,22 @@ +
+

Identities

+
+
+ + +
+
+ +
+
+ + +
+
+