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:
parent
75ce965b63
commit
18a40756d8
3 changed files with 250 additions and 1 deletions
|
|
@ -2034,6 +2034,118 @@ fn hex_to_postid(hex_str: &str) -> Result<itsgoin_core::types::PostId, String> {
|
||||||
|
|
||||||
// --- App setup ---
|
// --- 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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
|
|
@ -2216,6 +2328,12 @@ pub fn run() {
|
||||||
get_badge_counts,
|
get_badge_counts,
|
||||||
get_last_read_message,
|
get_last_read_message,
|
||||||
generate_share_link,
|
generate_share_link,
|
||||||
|
list_identities,
|
||||||
|
create_identity,
|
||||||
|
switch_identity,
|
||||||
|
delete_identity,
|
||||||
|
import_identity_key,
|
||||||
|
get_active_identity,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|
|
||||||
117
frontend/app.js
117
frontend/app.js
|
|
@ -2857,7 +2857,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||||
loadMessages(true); loadDmRecipientOptions();
|
loadMessages(true); loadDmRecipientOptions();
|
||||||
clearNotifications('msg-');
|
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 () => {
|
$('#notifications-btn').addEventListener('click', async () => {
|
||||||
// Load current settings
|
// Load current settings
|
||||||
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on';
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,22 @@
|
||||||
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></div>
|
<div id="circle-profiles-body" class="hidden"><div id="circle-profiles-list"></div></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">
|
<div class="section-card">
|
||||||
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
|
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue