From 848dae0e695cf2cfc55c866b8543f1dae8ff9667 Mon Sep 17 00:00:00 2001 From: parthashirolkar Date: Mon, 14 Sep 2026 14:13:22 +0530 Subject: [PATCH 1/6] feat: email alias settings UI, mask generation and Ctrl+E (#72 #73 #74) --- frontend/src-tauri/src/commands/alias.rs | 72 +++++- frontend/src-tauri/src/commands/credential.rs | 14 ++ frontend/src-tauri/src/commands/health.rs | 1 + frontend/src-tauri/src/commands/totp.rs | 1 + frontend/src-tauri/src/lib.rs | 3 + frontend/src-tauri/src/vault/alias.rs | 216 +++++++++++++++++- frontend/src-tauri/src/vault/entries.rs | 13 ++ frontend/src-tauri/src/vault/mod.rs | 24 ++ frontend/src-tauri/src/vault/search.rs | 1 + frontend/src-tauri/src/vault_health/audit.rs | 3 + frontend/src/api/client.ts | 35 ++- frontend/src/api/types.ts | 19 ++ frontend/src/components/AliasSettings.tsx | 184 +++++++++++++++ frontend/src/components/PaletteInput.tsx | 5 +- frontend/src/components/Settings.tsx | 3 + .../src/components/modes/AddCredential.tsx | 144 +++++++++++- frontend/src/test/AddCredential.test.tsx | 117 +++++++++- frontend/src/test/AliasSettings.test.tsx | 114 +++++++++ frontend/src/test/apiClient.test.ts | 58 +++++ frontend/src/utils/aliasProviders.ts | 8 + 20 files changed, 1009 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/AliasSettings.tsx create mode 100644 frontend/src/test/AliasSettings.test.tsx create mode 100644 frontend/src/utils/aliasProviders.ts diff --git a/frontend/src-tauri/src/commands/alias.rs b/frontend/src-tauri/src/commands/alias.rs index f026854..66e49fd 100644 --- a/frontend/src-tauri/src/commands/alias.rs +++ b/frontend/src-tauri/src/commands/alias.rs @@ -23,10 +23,17 @@ fn configured_provider( pub async fn save_alias_config( provider_id: String, api_token: String, + description: Option, state: State<'_, VaultState>, ) -> Result { state.lock(|storage, workspace| { - crate::vault::alias::save_config(workspace, storage, &provider_id, &api_token) + crate::vault::alias::save_config( + workspace, + storage, + &provider_id, + &api_token, + description.as_deref(), + ) })?; Ok(json!({ @@ -35,6 +42,52 @@ pub async fn save_alias_config( .to_string()) } +fn alias_configs_payload( + configs: Vec, + default_provider_id: Option, +) -> String { + json!({ + "status": "success", + "configs": configs, + "default_provider_id": default_provider_id, + }) + .to_string() +} + +#[tauri::command] +pub async fn list_alias_configs(state: State<'_, VaultState>) -> Result { + let (configs, default_provider_id) = state.lock(|_, workspace| { + let configs = crate::vault::alias::list_configs(workspace)?; + Ok((configs, workspace.default_provider_id.clone())) + })?; + + Ok(alias_configs_payload(configs, default_provider_id)) +} + +#[tauri::command] +pub async fn delete_alias_config( + provider_id: String, + state: State<'_, VaultState>, +) -> Result { + state.lock(|storage, workspace| { + crate::vault::alias::delete_config(workspace, storage, &provider_id) + })?; + + Ok(json!({ "status": "success" }).to_string()) +} + +#[tauri::command] +pub async fn set_default_alias_provider( + provider_id: String, + state: State<'_, VaultState>, +) -> Result { + state.lock(|storage, workspace| { + crate::vault::alias::set_default_config(workspace, storage, &provider_id) + })?; + + Ok(json!({ "status": "success" }).to_string()) +} + #[tauri::command] pub async fn generate_email_mask( provider_id: String, @@ -68,11 +121,28 @@ mod tests { workspace.alias_configs.push(AliasConfig { provider_id: "simplelogin".to_string(), api_token: "sl-token".to_string(), + description: None, }); workspace.start([3u8; 32]); workspace } + #[test] + fn alias_configs_payload_exposes_providers_without_tokens() { + let payload = alias_configs_payload( + vec![crate::vault::alias::AliasProviderInfo { + provider_id: "simplelogin".to_string(), + description: Some("Work".to_string()), + }], + Some("simplelogin".to_string()), + ); + + assert!(payload.contains("\"provider_id\":\"simplelogin\"")); + assert!(payload.contains("\"description\":\"Work\"")); + assert!(payload.contains("\"default_provider_id\":\"simplelogin\"")); + assert!(!payload.contains("api_token")); + } + #[test] fn configured_provider_rejects_expired_sessions() { let mut workspace = unlocked_workspace(); diff --git a/frontend/src-tauri/src/commands/credential.rs b/frontend/src-tauri/src/commands/credential.rs index 5baef5a..d94f906 100644 --- a/frontend/src-tauri/src/commands/credential.rs +++ b/frontend/src-tauri/src/commands/credential.rs @@ -16,6 +16,7 @@ fn serialize_full_entry(entry: &Entry) -> String { "url": entry.url, "icon_url": entry.icon_url, "has_totp": entry.totp_secret.is_some(), + "alias_provider_id": entry.alias_provider_id, } }) .to_string() @@ -98,6 +99,7 @@ pub async fn request_secret( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn add_entry( title: String, username: String, @@ -105,6 +107,7 @@ pub async fn add_entry( url: Option, icon_url: Option, totp_secret: Option, + alias_provider_id: Option, state: State<'_, VaultState>, ) -> Result { validate_entry_fields(&title, &username, &password, url.as_ref())?; @@ -118,6 +121,7 @@ pub async fn add_entry( url, icon_url, totp_secret: normalize_totp_secret(totp_secret)?.filter(|secret| !secret.is_empty()), + alias_provider_id, }; state.lock(|storage, workspace| crate::vault::entries::add(workspace, storage, entry))?; @@ -145,6 +149,7 @@ pub async fn update_entry( url: Option, icon_url: Option, totp_secret: Option, + alias_provider_id: Option, state: State<'_, VaultState>, ) -> Result { validate_entry_fields(&title, &username, &password, url.as_ref())?; @@ -157,6 +162,7 @@ pub async fn update_entry( url, icon_url, totp_secret: normalize_totp_secret(totp_secret)?, + alias_provider_id, }; state.lock(|storage, workspace| crate::vault::entries::update(workspace, storage, entry))?; @@ -188,9 +194,17 @@ mod tests { url: Some("https://example.com".to_string()), icon_url: None, totp_secret: Some("GEZDGNBVGY3TQOJQ".to_string()), + alias_provider_id: Some("simplelogin".to_string()), } } + #[test] + fn full_entry_payload_includes_alias_provider_id() { + let payload = serialize_full_entry(&entry_with_secret()); + + assert!(payload.contains("\"alias_provider_id\":\"simplelogin\"")); + } + #[test] fn full_entry_payload_never_exposes_totp_secret() { let payload = serialize_full_entry(&entry_with_secret()); diff --git a/frontend/src-tauri/src/commands/health.rs b/frontend/src-tauri/src/commands/health.rs index 8430255..e907e47 100644 --- a/frontend/src-tauri/src/commands/health.rs +++ b/frontend/src-tauri/src/commands/health.rs @@ -41,6 +41,7 @@ mod tests { url: None, icon_url: None, totp_secret: None, + alias_provider_id: None, }); workspace.start([5u8; 32]); workspace.session_start = diff --git a/frontend/src-tauri/src/commands/totp.rs b/frontend/src-tauri/src/commands/totp.rs index 10604d5..840e1d4 100644 --- a/frontend/src-tauri/src/commands/totp.rs +++ b/frontend/src-tauri/src/commands/totp.rs @@ -67,6 +67,7 @@ mod tests { url: None, icon_url: None, totp_secret: Some("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".to_string()), + alias_provider_id: None, }); workspace.start([7; 32]); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 7ff8a0b..4a01cd9 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -164,6 +164,9 @@ pub fn run() { commands::credential::delete_entry, commands::totp::get_totp_token, commands::alias::save_alias_config, + commands::alias::list_alias_configs, + commands::alias::delete_alias_config, + commands::alias::set_default_alias_provider, commands::alias::generate_email_mask, commands::generator::generate_password, commands::generator::analyze_password_strength, diff --git a/frontend/src-tauri/src/vault/alias.rs b/frontend/src-tauri/src/vault/alias.rs index 0654cc8..8232fd0 100644 --- a/frontend/src-tauri/src/vault/alias.rs +++ b/frontend/src-tauri/src/vault/alias.rs @@ -94,6 +94,7 @@ pub fn save_config( storage: &VaultStorage, provider_id: &str, api_token: &str, + description: Option<&str>, ) -> Result<(), String> { workspace.check_session()?; workspace.refresh(); @@ -102,21 +103,88 @@ pub fn save_config( return Err("Alias API token is required".to_string()); } + let description = description + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match workspace .alias_configs .iter_mut() .find(|config| config.provider_id == provider_id) { - Some(config) => config.api_token = api_token.to_string(), + Some(config) => { + config.api_token = api_token.to_string(); + config.description = description; + } None => workspace.alias_configs.push(AliasConfig { provider_id: provider_id.to_string(), api_token: api_token.to_string(), + description, }), } crate::vault::entries::persist(workspace, storage) } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct AliasProviderInfo { + pub provider_id: String, + pub description: Option, +} + +/// List configured providers for display. The API token is never included. +pub fn list_configs(workspace: &mut Workspace) -> Result, String> { + workspace.check_session()?; + workspace.refresh(); + Ok(workspace + .alias_configs + .iter() + .map(|config| AliasProviderInfo { + provider_id: config.provider_id.clone(), + description: config.description.clone(), + }) + .collect()) +} + +pub fn delete_config( + workspace: &mut Workspace, + storage: &VaultStorage, + provider_id: &str, +) -> Result<(), String> { + workspace.check_session()?; + workspace.refresh(); + let len_before = workspace.alias_configs.len(); + workspace + .alias_configs + .retain(|config| config.provider_id != provider_id); + if workspace.alias_configs.len() == len_before { + return Err(format!("Alias provider '{provider_id}' is not configured")); + } + if workspace.default_provider_id.as_deref() == Some(provider_id) { + workspace.default_provider_id = None; + } + crate::vault::entries::persist(workspace, storage) +} + +pub fn set_default_config( + workspace: &mut Workspace, + storage: &VaultStorage, + provider_id: &str, +) -> Result<(), String> { + workspace.check_session()?; + workspace.refresh(); + if !workspace + .alias_configs + .iter() + .any(|config| config.provider_id == provider_id) + { + return Err(format!("Alias provider '{provider_id}' is not configured")); + } + workspace.default_provider_id = Some(provider_id.to_string()); + crate::vault::entries::persist(workspace, storage) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Provider { SimpleLogin, @@ -222,6 +290,7 @@ mod tests { AliasConfig { provider_id: provider_id.to_string(), api_token: api_token.to_string(), + description: None, } } @@ -385,7 +454,7 @@ mod tests { let mut workspace = unlocked_workspace(); let key = [9u8; 32]; - save_config(&mut workspace, &storage, "simplelogin", "sl-token").unwrap(); + save_config(&mut workspace, &storage, "simplelogin", "sl-token", None).unwrap(); let vault = storage.read().unwrap(); let decrypted = crate::crypto::aead::decrypt(&key, &vault.data).unwrap(); @@ -395,24 +464,156 @@ mod tests { assert_eq!(data.alias_configs[0].api_token, "sl-token"); } + #[test] + fn saved_configs_store_the_description() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + let key = [9u8; 32]; + + save_config( + &mut workspace, + &storage, + "simplelogin", + "sl-token", + Some("Personal"), + ) + .unwrap(); + + let vault = storage.read().unwrap(); + let decrypted = crate::crypto::aead::decrypt(&key, &vault.data).unwrap(); + let data: crate::vault::VaultData = serde_json::from_str(&decrypted).unwrap(); + assert_eq!( + data.alias_configs[0].description.as_deref(), + Some("Personal") + ); + } + #[test] fn saving_the_same_provider_replaces_its_token() { let (storage, _dir) = storage_with_existing_vault(); let mut workspace = unlocked_workspace(); - save_config(&mut workspace, &storage, "duckduckgo", "old-token").unwrap(); - save_config(&mut workspace, &storage, "duckduckgo", "new-token").unwrap(); + save_config(&mut workspace, &storage, "duckduckgo", "old-token", None).unwrap(); + save_config(&mut workspace, &storage, "duckduckgo", "new-token", None).unwrap(); assert_eq!(workspace.alias_configs.len(), 1); assert_eq!(workspace.alias_configs[0].api_token, "new-token"); } + #[test] + fn listing_configs_preserves_display_fields_in_creation_order() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + + save_config( + &mut workspace, + &storage, + "simplelogin", + "sl-token", + Some("Work"), + ) + .unwrap(); + save_config(&mut workspace, &storage, "duckduckgo", "ddg-token", None).unwrap(); + + let configs = list_configs(&mut workspace).unwrap(); + + assert_eq!( + configs, + vec![ + AliasProviderInfo { + provider_id: "simplelogin".to_string(), + description: Some("Work".to_string()), + }, + AliasProviderInfo { + provider_id: "duckduckgo".to_string(), + description: None, + }, + ] + ); + } + + #[test] + fn listing_configs_rejects_locked_vaults() { + let mut workspace = crate::vault::workspace::Workspace::new(); + + let error = list_configs(&mut workspace).unwrap_err(); + + assert_eq!(error, "Vault is locked"); + } + + #[test] + fn deleting_a_config_removes_it_from_the_vault() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + let key = [9u8; 32]; + + save_config(&mut workspace, &storage, "simplelogin", "sl-token", None).unwrap(); + save_config(&mut workspace, &storage, "duckduckgo", "ddg-token", None).unwrap(); + + delete_config(&mut workspace, &storage, "simplelogin").unwrap(); + + let vault = storage.read().unwrap(); + let decrypted = crate::crypto::aead::decrypt(&key, &vault.data).unwrap(); + let data: crate::vault::VaultData = serde_json::from_str(&decrypted).unwrap(); + assert_eq!(data.alias_configs.len(), 1); + assert_eq!(data.alias_configs[0].provider_id, "duckduckgo"); + } + + #[test] + fn deleting_the_default_provider_clears_the_default() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + + save_config(&mut workspace, &storage, "simplelogin", "sl-token", None).unwrap(); + set_default_config(&mut workspace, &storage, "simplelogin").unwrap(); + + delete_config(&mut workspace, &storage, "simplelogin").unwrap(); + + assert_eq!(workspace.default_provider_id, None); + } + + #[test] + fn deleting_an_unconfigured_provider_is_rejected() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + + let error = delete_config(&mut workspace, &storage, "simplelogin").unwrap_err(); + + assert_eq!(error, "Alias provider 'simplelogin' is not configured"); + } + + #[test] + fn setting_a_default_provider_persists_it() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + let key = [9u8; 32]; + + save_config(&mut workspace, &storage, "duckduckgo", "ddg-token", None).unwrap(); + set_default_config(&mut workspace, &storage, "duckduckgo").unwrap(); + + let vault = storage.read().unwrap(); + let decrypted = crate::crypto::aead::decrypt(&key, &vault.data).unwrap(); + let data: crate::vault::VaultData = serde_json::from_str(&decrypted).unwrap(); + assert_eq!(data.default_provider_id.as_deref(), Some("duckduckgo")); + } + + #[test] + fn setting_a_default_provider_rejects_unconfigured_providers() { + let (storage, _dir) = storage_with_existing_vault(); + let mut workspace = unlocked_workspace(); + + let error = set_default_config(&mut workspace, &storage, "simplelogin").unwrap_err(); + + assert_eq!(error, "Alias provider 'simplelogin' is not configured"); + assert_eq!(workspace.default_provider_id, None); + } + #[test] fn unsupported_providers_are_rejected_when_saving() { let (storage, _dir) = storage_with_existing_vault(); let mut workspace = unlocked_workspace(); - let error = save_config(&mut workspace, &storage, "cloudflare", "token").unwrap_err(); + let error = save_config(&mut workspace, &storage, "cloudflare", "token", None).unwrap_err(); assert_eq!(error, "Unsupported alias provider 'cloudflare'"); assert!(workspace.alias_configs.is_empty()); @@ -423,7 +624,7 @@ mod tests { let (storage, _dir) = storage_with_existing_vault(); let mut workspace = unlocked_workspace(); - let error = save_config(&mut workspace, &storage, "simplelogin", " ").unwrap_err(); + let error = save_config(&mut workspace, &storage, "simplelogin", " ", None).unwrap_err(); assert_eq!(error, "Alias API token is required"); assert!(workspace.alias_configs.is_empty()); @@ -434,7 +635,8 @@ mod tests { let (storage, _dir) = storage_with_existing_vault(); let mut workspace = crate::vault::workspace::Workspace::new(); - let error = save_config(&mut workspace, &storage, "simplelogin", "sl-token").unwrap_err(); + let error = + save_config(&mut workspace, &storage, "simplelogin", "sl-token", None).unwrap_err(); assert_eq!(error, "Vault is locked"); } diff --git a/frontend/src-tauri/src/vault/entries.rs b/frontend/src-tauri/src/vault/entries.rs index 0ea4cbd..efb7c08 100644 --- a/frontend/src-tauri/src/vault/entries.rs +++ b/frontend/src-tauri/src/vault/entries.rs @@ -99,6 +99,7 @@ mod tests { url: None, icon_url: None, totp_secret: None, + alias_provider_id: None, }); workspace.start([7u8; 32]); workspace @@ -132,6 +133,7 @@ mod tests { url: None, icon_url: None, totp_secret: totp_secret.map(str::to_string), + alias_provider_id: None, } } @@ -161,6 +163,17 @@ mod tests { assert_eq!(workspace.credentials[0].totp_secret.as_deref(), Some("NEW")); } + #[test] + fn update_replaces_the_stored_alias_provider_id() { + let (storage, _dir) = test_storage(); + let mut workspace = unlocked_workspace(); + workspace.credentials[0].alias_provider_id = Some("simplelogin".to_string()); + + update(&mut workspace, &storage, replacement(None)).unwrap(); + + assert_eq!(workspace.credentials[0].alias_provider_id, None); + } + #[test] fn update_clears_the_stored_totp_secret_when_an_empty_value_is_provided() { let (storage, _dir) = test_storage(); diff --git a/frontend/src-tauri/src/vault/mod.rs b/frontend/src-tauri/src/vault/mod.rs index abf0c29..a75d9aa 100644 --- a/frontend/src-tauri/src/vault/mod.rs +++ b/frontend/src-tauri/src/vault/mod.rs @@ -24,6 +24,8 @@ pub struct Entry { pub icon_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub totp_secret: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias_provider_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -59,6 +61,8 @@ pub struct EncryptedVault { pub struct AliasConfig { pub provider_id: String, pub api_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -91,6 +95,7 @@ mod tests { alias_configs: vec![AliasConfig { provider_id: "simplelogin".to_string(), api_token: "sl-token".to_string(), + description: Some("Personal".to_string()), }], default_provider_id: Some("simplelogin".to_string()), }; @@ -124,6 +129,7 @@ mod tests { url: None, icon_url: None, totp_secret: Some("JBSWY3DPEHPK3PXP".to_string()), + alias_provider_id: None, }; entry.zeroize(); @@ -132,6 +138,24 @@ mod tests { assert!(entry.totp_secret.is_none()); } + #[test] + fn credential_alias_provider_id_round_trips_and_remains_optional() { + let json = r#"{"id":"1","title":"Example","username":"user","password":"secret","url":null,"icon_url":null,"alias_provider_id":"simplelogin"}"#; + let credential: Entry = serde_json::from_str(json).unwrap(); + + assert_eq!(credential.alias_provider_id.as_deref(), Some("simplelogin")); + assert_eq!( + serde_json::to_value(&credential).unwrap()["alias_provider_id"], + "simplelogin" + ); + + let legacy = r#"{"id":"2","title":"Legacy","username":"user","password":"secret","url":null,"icon_url":null}"#; + assert!(serde_json::from_str::(legacy) + .unwrap() + .alias_provider_id + .is_none()); + } + #[test] fn credential_totp_secret_round_trips_and_remains_optional() { let json = r#"{"id":"1","title":"Example","username":"user","password":"secret","url":null,"icon_url":null,"totp_secret":"JBSWY3DPEHPK3PXP"}"#; diff --git a/frontend/src-tauri/src/vault/search.rs b/frontend/src-tauri/src/vault/search.rs index 982ea66..78209df 100644 --- a/frontend/src-tauri/src/vault/search.rs +++ b/frontend/src-tauri/src/vault/search.rs @@ -51,6 +51,7 @@ mod tests { url: None, icon_url: None, totp_secret, + alias_provider_id: None, } } diff --git a/frontend/src-tauri/src/vault_health/audit.rs b/frontend/src-tauri/src/vault_health/audit.rs index 1ce7458..d40aa62 100644 --- a/frontend/src-tauri/src/vault_health/audit.rs +++ b/frontend/src-tauri/src/vault_health/audit.rs @@ -206,6 +206,7 @@ mod tests { url: None, icon_url: None, totp_secret: None, + alias_provider_id: None, } } @@ -282,6 +283,7 @@ mod tests { url: None, icon_url: None, totp_secret: None, + alias_provider_id: None, }]; let breached = check_breach_status(&entries, &checker).await; assert_eq!(breached.len(), 1); @@ -299,6 +301,7 @@ mod tests { url: None, icon_url: None, totp_secret: None, + alias_provider_id: None, }]; let breached = check_breach_status(&entries, &checker).await; assert_eq!(breached.len(), 0); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1996ec8..d87bfe3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -9,6 +9,8 @@ import { TotpTokenResponseSchema, VaultStatusResponseSchema, VaultHealthReportSchema, + AliasConfigsResponseSchema, + EmailMaskResponseSchema, type Credential, type CredentialPreview, type PasswordOptions, @@ -93,7 +95,7 @@ export const api = { async addEntry(entry: { title: string; username: string; password: string; - url?: string; iconUrl?: string; totpSecret?: string; + url?: string; iconUrl?: string; totpSecret?: string; aliasProviderId?: string; }): Promise { const result = await invoke('add_entry', entry) return AddEntryResponseSchema.parse(JSON.parse(result as string)).id @@ -102,6 +104,7 @@ export const api = { async updateEntry(entry: { id: string; title: string; username: string; password: string; url?: string; iconUrl?: string; totpSecret?: string; + aliasProviderId?: string; }): Promise { const result = await invoke('update_entry', entry) parse(result, ResponseSchema) @@ -112,6 +115,36 @@ export const api = { parse(result, ResponseSchema) }, + // Email aliases + async listAliasConfigs(): Promise<{ + configs: { provider_id: string; description?: string | null }[]; + default_provider_id: string | null; + }> { + const result = await invoke('list_alias_configs') + const parsed = AliasConfigsResponseSchema.parse(JSON.parse(result as string)) + return { configs: parsed.configs, default_provider_id: parsed.default_provider_id ?? null } + }, + + async saveAliasConfig(providerId: string, apiToken: string, description?: string): Promise { + const result = await invoke('save_alias_config', { providerId, apiToken, description }) + parse(result, ResponseSchema) + }, + + async deleteAliasConfig(providerId: string): Promise { + const result = await invoke('delete_alias_config', { providerId }) + parse(result, ResponseSchema) + }, + + async setDefaultAliasProvider(providerId: string): Promise { + const result = await invoke('set_default_alias_provider', { providerId }) + parse(result, ResponseSchema) + }, + + async generateEmailMask(providerId: string): Promise { + const result = await invoke('generate_email_mask', { providerId }) + return EmailMaskResponseSchema.parse(JSON.parse(result as string)).email + }, + // Password generator async generatePassword(options: PasswordOptions): Promise { const result = await invoke('generate_password', { options }) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3a8a342..923b86d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -10,6 +10,7 @@ export const CredentialSchema = z.object({ url: OptionalStringSchema, icon_url: OptionalStringSchema, has_totp: z.boolean().optional(), + alias_provider_id: OptionalStringSchema, }) export type Credential = z.infer @@ -68,6 +69,24 @@ export const TotpTokenResponseSchema = z.object({ remaining_seconds: z.number(), }) +export const AliasProviderSchema = z.object({ + provider_id: z.string(), + description: OptionalStringSchema, +}) +export type AliasProvider = z.infer + +export const AliasConfigsResponseSchema = z.object({ + status: z.literal('success'), + configs: z.array(AliasProviderSchema), + default_provider_id: OptionalStringSchema, +}) +export type AliasConfigsResponse = z.infer + +export const EmailMaskResponseSchema = z.object({ + status: z.literal('success'), + email: z.string(), +}) + export const PasswordOptionsSchema = z.object({ length: z.number().min(8).max(128), uppercase: z.boolean(), diff --git a/frontend/src/components/AliasSettings.tsx b/frontend/src/components/AliasSettings.tsx new file mode 100644 index 0000000..c8ef64a --- /dev/null +++ b/frontend/src/components/AliasSettings.tsx @@ -0,0 +1,184 @@ +import { useState, useEffect } from 'react' +import { api } from '../api/client' +import { type AliasProvider } from '../api/types' +import { providerLabel } from '../utils/aliasProviders' + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +function AliasSettings() { + const [configs, setConfigs] = useState([]) + const [defaultProviderId, setDefaultProviderId] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [providerId, setProviderId] = useState('simplelogin') + const [description, setDescription] = useState('') + const [apiToken, setApiToken] = useState('') + + useEffect(() => { + load() + }, []) + + const load = async () => { + try { + setLoading(true) + setError('') + const result = await api.listAliasConfigs() + setConfigs(result.configs) + setDefaultProviderId(result.default_provider_id) + } catch (err) { + setError(errorMessage(err)) + } finally { + setLoading(false) + } + } + + const handleSave = async () => { + if (!apiToken.trim()) { + setError('API access token is required') + return + } + try { + setSaving(true) + setError('') + await api.saveAliasConfig(providerId, apiToken.trim(), description.trim() || undefined) + setApiToken('') + setDescription('') + await load() + } catch (err) { + setError(errorMessage(err)) + } finally { + setSaving(false) + } + } + + const handleDelete = async (id: string) => { + try { + setError('') + await api.deleteAliasConfig(id) + await load() + } catch (err) { + setError(errorMessage(err)) + } + } + + const handleSetDefault = async (id: string) => { + try { + setError('') + await api.setDefaultAliasProvider(id) + await load() + } catch (err) { + setError(errorMessage(err)) + } + } + + const inputClass = + 'w-full bg-theme-bg text-theme-text border-2 border-theme-accent px-3 py-2 font-theme text-[13px] outline-none focus:bg-theme-surface' + + return ( +
+
+

+ Email Alias Integration +

+
+ +
+

+ Connect an alias provider to generate email masks for your credentials. +

+ + {loading ? ( +

Loading…

+ ) : ( +
    + {configs.length === 0 && ( +
  • + No alias providers configured. +
  • + )} + {configs.map((config) => ( +
  • +
    + {providerLabel(config.provider_id)} + {config.description && ( + {config.description} + )} + {defaultProviderId === config.provider_id && ( + + DEFAULT + + )} +
    +
    + {defaultProviderId !== config.provider_id && ( + + )} + +
    +
  • + ))} +
+ )} + +
+ + setDescription(e.target.value)} + placeholder="Description (optional)" + className={inputClass} + /> + setApiToken(e.target.value)} + placeholder="API access token" + className={inputClass} + /> + +
+ + {error && ( +
+ {error} +
+ )} +
+
+ ) +} + +export default AliasSettings diff --git a/frontend/src/components/PaletteInput.tsx b/frontend/src/components/PaletteInput.tsx index f6fa86a..28ecefa 100644 --- a/frontend/src/components/PaletteInput.tsx +++ b/frontend/src/components/PaletteInput.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import { LucideIcon, Eye, EyeOff, Copy } from 'lucide-react' import { useClipboardGuard } from '../hooks/useClipboardGuard' @@ -13,6 +13,7 @@ interface PaletteInputProps { hint?: string disabled?: boolean iconSpin?: boolean + action?: ReactNode } function PaletteInput({ @@ -26,6 +27,7 @@ function PaletteInput({ hint, disabled = false, iconSpin = false, + action, }: PaletteInputProps) { const inputRef = useRef(null) const [showPassword, setShowPassword] = useState(false) @@ -86,6 +88,7 @@ function PaletteInput({ )} + {action} {hint && ( {hint} diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 3ba2cd7..876cfd7 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -10,6 +10,7 @@ import { clearStoredKey } from '../utils/biometricKeys' import ConfirmationModal from './ConfirmationModal' +import AliasSettings from './AliasSettings' import { useTheme, THEMES } from '../hooks/useTheme' import { api } from '../api/client' import { Google } from './ui/svgs/google' @@ -419,6 +420,8 @@ function Settings() { + +
diff --git a/frontend/src/components/modes/AddCredential.tsx b/frontend/src/components/modes/AddCredential.tsx index ad1665c..50efca5 100644 --- a/frontend/src/components/modes/AddCredential.tsx +++ b/frontend/src/components/modes/AddCredential.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useCallback } from 'react' -import { Globe, User, Key, ShieldCheck } from 'lucide-react' +import { Globe, User, Key, ShieldCheck, Mail, ChevronDown } from 'lucide-react' import { api } from '../../api/client' import { fetchFavicon } from '../../utils/favicon' import { parseOtpAuthUri } from '../../utils/otpauth' +import { providerLabel } from '../../utils/aliasProviders' import PaletteInput from '../PaletteInput' -import { type PaletteMode, type CredentialPreview } from '../../api/types' +import { type PaletteMode, type CredentialPreview, type AliasProvider } from '../../api/types' interface AddCredentialProps { editEntry: CredentialPreview | null @@ -26,6 +27,13 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang const [error, setError] = useState('') const [loadedEdit, setLoadedEdit] = useState(false) const [totpTouched, setTotpTouched] = useState(false) + const [aliasConfigs, setAliasConfigs] = useState([]) + const [defaultProviderId, setDefaultProviderId] = useState(null) + const [aliasMenuOpen, setAliasMenuOpen] = useState(false) + const [generatingAlias, setGeneratingAlias] = useState(false) + const [aliasProviderId, setAliasProviderId] = useState(null) + const [aliasError, setAliasError] = useState('') + const [aliasSetupHint, setAliasSetupHint] = useState(false) useEffect(() => { if (isEditing && editEntry && !loadedEdit) { @@ -33,6 +41,16 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang } }, [isEditing, editEntry, loadedEdit]) + useEffect(() => { + api + .listAliasConfigs() + .then((result) => { + setAliasConfigs(result.configs) + setDefaultProviderId(result.default_provider_id) + }) + .catch((err) => console.error('Failed to load alias providers:', err)) + }, []) + useEffect(() => { if (generatedPassword) { setFormData((prev) => ({ ...prev, password: generatedPassword })) @@ -49,6 +67,7 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang url: fullEntry.url || '', totpSecret: '' }) + setAliasProviderId(fullEntry.alias_provider_id ?? null) setLoadedEdit(true) setTotpTouched(false) } catch (error) { @@ -67,6 +86,31 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang })) } + const generateAlias = useCallback(async (providerId: string) => { + try { + setGeneratingAlias(true) + setAliasError('') + setAliasSetupHint(false) + const email = await api.generateEmailMask(providerId) + setFormData((prev) => ({ ...prev, username: email })) + setAliasProviderId(providerId) + setAliasMenuOpen(false) + } catch (err) { + setAliasError(err instanceof Error ? err.message : String(err)) + } finally { + setGeneratingAlias(false) + } + }, []) + + const handleGenerateAliasShortcut = useCallback(() => { + if (!defaultProviderId) { + setAliasError('No default alias provider configured') + setAliasSetupHint(true) + return + } + generateAlias(defaultProviderId) + }, [defaultProviderId, generateAlias]) + const handleSave = useCallback(async () => { setError('') @@ -91,6 +135,8 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang } } + const aliasProvider = aliasProviderId || undefined + if (isEditing && editEntry) { await api.updateEntry({ id: editEntry.id, @@ -100,6 +146,7 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang url, iconUrl, totpSecret, + aliasProviderId: aliasProvider, }) } else { await api.addEntry({ @@ -109,22 +156,29 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang url, iconUrl, totpSecret, + aliasProviderId: aliasProvider, }) } setFormData({ title: '', username: '', password: '', url: '', totpSecret: '' }) setTotpTouched(false) + setAliasProviderId(null) + setAliasError('') + setAliasSetupHint(false) onCredentialsChanged() onModeChange('search') } catch (err) { console.error(`Error ${isEditing ? 'updating' : 'adding'} entry:`, err) setError(err instanceof Error ? err.message : String(err)) } - }, [formData, editEntry, isEditing, totpTouched, onModeChange, onCredentialsChanged]) + }, [formData, editEntry, isEditing, totpTouched, aliasProviderId, onModeChange, onCredentialsChanged]) useEffect(() => { function handleKey(e: KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') { + e.preventDefault() + handleGenerateAliasShortcut() + } else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleSave() } else if (e.key === 'Escape') { @@ -132,12 +186,16 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang setFormData({ title: '', username: '', password: '', url: '', totpSecret: '' }) setLoadedEdit(false) setTotpTouched(false) + setAliasProviderId(null) + setAliasError('') + setAliasSetupHint(false) + setAliasMenuOpen(false) onModeChange('search') } } window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) - }, [handleSave, onModeChange]) + }, [handleSave, handleGenerateAliasShortcut, onModeChange]) return ( <> @@ -148,12 +206,70 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang icon={Globe} autoFocus={true} /> - setFormData({ ...formData, username: val })} - placeholder={isEditing ? 'Edit username or email...' : 'Username or email...'} - icon={User} - /> +
+ setFormData({ ...formData, username: val })} + placeholder={isEditing ? 'Edit username or email...' : 'Username or email...'} + icon={User} + iconSpin={generatingAlias} + hint={aliasProviderId ? providerLabel(aliasProviderId) : undefined} + action={ + aliasConfigs.length > 0 ? ( +
+ + {aliasMenuOpen && ( +
+ {defaultProviderId && ( + + )} + {aliasConfigs.map((config) => ( + + ))} +
+ )} +
+ ) : undefined + } + /> + {aliasError && ( +
+ {aliasError} + {aliasSetupHint && ( + + )} +
+ )} +
setFormData({ ...formData, password: val })} @@ -178,7 +294,11 @@ function AddCredential({ editEntry, prefillTitle, generatedPassword, onModeChang {error &&
{error}
}
- Enter {isEditing ? 'Update' : 'Save'} Esc Cancel + Enter {isEditing ? 'Update' : 'Save'} Esc Cancel {aliasConfigs.length > 0 && ( + <> + Ctrl+E Generate mask + + )}
diff --git a/frontend/src/test/AddCredential.test.tsx b/frontend/src/test/AddCredential.test.tsx index 08404e8..f52af54 100644 --- a/frontend/src/test/AddCredential.test.tsx +++ b/frontend/src/test/AddCredential.test.tsx @@ -3,7 +3,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import AddCredential from '../components/modes/AddCredential' vi.mock('../api/client', () => ({ - api: { addEntry: vi.fn(), updateEntry: vi.fn(), getFullEntry: vi.fn() }, + api: { + addEntry: vi.fn(), + updateEntry: vi.fn(), + getFullEntry: vi.fn(), + listAliasConfigs: vi.fn(), + generateEmailMask: vi.fn(), + }, })) import { api } from '../api/client' @@ -11,22 +17,40 @@ import { api } from '../api/client' const addEntry = vi.mocked(api.addEntry) const updateEntry = vi.mocked(api.updateEntry) const getFullEntry = vi.mocked(api.getFullEntry) +const listAliasConfigs = vi.mocked(api.listAliasConfigs) +const generateEmailMask = vi.mocked(api.generateEmailMask) const TOTP_PLACEHOLDER = '2FA secret or otpauth:// link (optional)...' const EDIT_TOTP_PLACEHOLDER = 'Edit 2FA secret or otpauth:// link (blank keeps current)...' - -function renderForm() { +const SIMPLELOGIN = { provider_id: 'simplelogin', description: 'Personal' } +const DUCKDUCKGO = { provider_id: 'duckduckgo', description: null } + +function renderForm( + options: { + onModeChange?: (mode: string) => void + configs?: unknown[] + defaultProviderId?: string | null + } = {}, +) { + listAliasConfigs.mockResolvedValue({ + configs: (options.configs ?? []) as never, + default_provider_id: options.defaultProviderId ?? null, + }) return render( , ) } +beforeEach(() => { + listAliasConfigs.mockResolvedValue({ configs: [], default_provider_id: null }) +}) + function renderEditForm() { return render( { }) }) +describe('AddCredential email alias masks', () => { + beforeEach(() => { + vi.clearAllMocks() + addEntry.mockResolvedValue('new-id') + listAliasConfigs.mockResolvedValue({ + configs: [SIMPLELOGIN, DUCKDUCKGO], + default_provider_id: 'simplelogin', + }) + generateEmailMask.mockResolvedValue('mask@simplelogin.com') + }) + + const usernameInput = () => + screen.getByPlaceholderText('Username or email...') as HTMLInputElement + + it('generates a mask for the default provider and prefills the username', async () => { + renderForm({ configs: [SIMPLELOGIN, DUCKDUCKGO], defaultProviderId: 'simplelogin' }) + + fireEvent.click(await screen.findByRole('button', { name: 'Generate email mask' })) + fireEvent.click(await screen.findByRole('button', { name: 'Default (SimpleLogin)' })) + + await waitFor(() => expect(generateEmailMask).toHaveBeenCalledWith('simplelogin')) + await waitFor(() => expect(usernameInput().value).toBe('mask@simplelogin.com')) + }) + + it('generates a mask from a specific provider', async () => { + generateEmailMask.mockResolvedValue('abc@duck.com') + renderForm({ configs: [SIMPLELOGIN, DUCKDUCKGO], defaultProviderId: 'simplelogin' }) + + fireEvent.click(await screen.findByRole('button', { name: 'Generate email mask' })) + fireEvent.click(await screen.findByRole('button', { name: 'DuckDuckGo' })) + + await waitFor(() => expect(generateEmailMask).toHaveBeenCalledWith('duckduckgo')) + await waitFor(() => expect(usernameInput().value).toBe('abc@duck.com')) + }) + + it('saves the alias provider id with the credential', async () => { + renderForm({ configs: [SIMPLELOGIN], defaultProviderId: 'simplelogin' }) + + fireEvent.click(await screen.findByRole('button', { name: 'Generate email mask' })) + fireEvent.click(await screen.findByRole('button', { name: 'Default (SimpleLogin)' })) + await waitFor(() => expect(usernameInput().value).toBe('mask@simplelogin.com')) + + fireEvent.change(screen.getByPlaceholderText('Website title...'), { target: { value: 'Acme' } }) + fireEvent.change(screen.getByPlaceholderText('Password...'), { target: { value: 'hunter2' } }) + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => expect(addEntry).toHaveBeenCalledTimes(1)) + expect(addEntry).toHaveBeenCalledWith( + expect.objectContaining({ aliasProviderId: 'simplelogin' }), + ) + }) + + it('generates a mask with the default provider on Ctrl+E', async () => { + renderForm({ configs: [SIMPLELOGIN], defaultProviderId: 'simplelogin' }) + await screen.findByRole('button', { name: 'Generate email mask' }) + + fireEvent.keyDown(window, { key: 'e', ctrlKey: true }) + + await waitFor(() => expect(generateEmailMask).toHaveBeenCalledWith('simplelogin')) + await waitFor(() => expect(usernameInput().value).toBe('mask@simplelogin.com')) + }) + + it('guides to settings on Ctrl+E when no default provider exists', async () => { + const onModeChange = vi.fn() + renderForm({ configs: [SIMPLELOGIN], defaultProviderId: null, onModeChange }) + await screen.findByRole('button', { name: 'Generate email mask' }) + + fireEvent.keyDown(window, { key: 'e', ctrlKey: true }) + + expect(await screen.findByText('No default alias provider configured')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Open Settings' })) + expect(onModeChange).toHaveBeenCalledWith('settings') + }) + + it('shows generation errors below the username field', async () => { + generateEmailMask.mockRejectedValue(new Error('Invalid token')) + renderForm({ configs: [SIMPLELOGIN], defaultProviderId: 'simplelogin' }) + + fireEvent.click(await screen.findByRole('button', { name: 'Generate email mask' })) + fireEvent.click(await screen.findByRole('button', { name: 'Default (SimpleLogin)' })) + + expect(await screen.findByText('Invalid token')).toBeTruthy() + }) +}) + diff --git a/frontend/src/test/AliasSettings.test.tsx b/frontend/src/test/AliasSettings.test.tsx new file mode 100644 index 0000000..408e223 --- /dev/null +++ b/frontend/src/test/AliasSettings.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../api/client', () => ({ + api: { + listAliasConfigs: vi.fn(), + saveAliasConfig: vi.fn(), + deleteAliasConfig: vi.fn(), + setDefaultAliasProvider: vi.fn(), + }, +})) + +import { api } from '../api/client' +import AliasSettings from '../components/AliasSettings' + +const listAliasConfigs = vi.mocked(api.listAliasConfigs) +const saveAliasConfig = vi.mocked(api.saveAliasConfig) +const deleteAliasConfig = vi.mocked(api.deleteAliasConfig) +const setDefaultAliasProvider = vi.mocked(api.setDefaultAliasProvider) + +const SIMPLELOGIN = { + provider_id: 'simplelogin', + description: 'Personal aliases', +} +const DUCKDUCKGO = { + provider_id: 'duckduckgo', + description: null, +} + +beforeEach(() => { + vi.clearAllMocks() + listAliasConfigs.mockResolvedValue({ configs: [], default_provider_id: null }) + saveAliasConfig.mockResolvedValue() + deleteAliasConfig.mockResolvedValue() + setDefaultAliasProvider.mockResolvedValue() +}) + +async function renderWith( + configs: { provider_id: string; description?: string | null }[], + defaultProviderId: string | null = null, +) { + listAliasConfigs.mockResolvedValue({ configs, default_provider_id: defaultProviderId }) + render() + await waitFor(() => expect(listAliasConfigs).toHaveBeenCalled()) + return listAliasConfigs +} + +describe('AliasSettings', () => { + it('lists saved alias providers with their description', async () => { + await renderWith([SIMPLELOGIN, DUCKDUCKGO]) + + const list = within(screen.getByRole('list')) + expect(list.getByText('Personal aliases')).toBeTruthy() + expect(list.getByText('SimpleLogin')).toBeTruthy() + expect(list.getByText('DuckDuckGo')).toBeTruthy() + }) + + it('adds an integration through the form', async () => { + await renderWith([]) + + fireEvent.change(screen.getByLabelText('Provider type'), { + target: { value: 'duckduckgo' }, + }) + fireEvent.change(screen.getByPlaceholderText('Description (optional)'), { + target: { value: 'Work' }, + }) + fireEvent.change(screen.getByPlaceholderText('API access token'), { + target: { value: 'token-123' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save integration' })) + + await waitFor(() => + expect(saveAliasConfig).toHaveBeenCalledWith('duckduckgo', 'token-123', 'Work'), + ) + }) + + it('deletes a saved integration', async () => { + await renderWith([SIMPLELOGIN]) + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(deleteAliasConfig).toHaveBeenCalledWith('simplelogin')) + }) + + it('sets a provider as the default', async () => { + await renderWith([SIMPLELOGIN, DUCKDUCKGO]) + + const [makeDefault] = screen.getAllByRole('button', { name: 'Make default' }) + fireEvent.click(makeDefault) + + await waitFor(() => + expect(setDefaultAliasProvider).toHaveBeenCalledWith('simplelogin'), + ) + }) + + it('shows the default provider badge and hides its make-default action', async () => { + await renderWith([SIMPLELOGIN, DUCKDUCKGO], 'duckduckgo') + + expect(screen.getByText('DEFAULT')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Make default' })).toBeTruthy() + }) + + it('surfaces save errors', async () => { + await renderWith([]) + saveAliasConfig.mockRejectedValue(new Error('Invalid token')) + + fireEvent.change(screen.getByPlaceholderText('API access token'), { + target: { value: 'bad' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Save integration' })) + + await waitFor(() => expect(screen.getByText('Invalid token')).toBeTruthy()) + }) +}) diff --git a/frontend/src/test/apiClient.test.ts b/frontend/src/test/apiClient.test.ts index f1d7052..bdab509 100644 --- a/frontend/src/test/apiClient.test.ts +++ b/frontend/src/test/apiClient.test.ts @@ -131,6 +131,64 @@ describe('api client response parsing', () => { }) expect(invokeMock).toHaveBeenCalledWith('get_totp_token', { entryId: 'entry-1' }) }) + + test('addEntry forwards the alias provider id', async () => { + invokeMock.mockResolvedValue(JSON.stringify({ status: 'success', id: 'entry-1' })) + + await api.addEntry({ + title: 'Example', + username: 'mask@simplelogin.com', + password: 'secret', + aliasProviderId: 'simplelogin', + }) + + expect(invokeMock).toHaveBeenCalledWith( + 'add_entry', + expect.objectContaining({ aliasProviderId: 'simplelogin' }) + ) + }) + + test('listAliasConfigs exposes providers and the default without tokens', async () => { + invokeMock.mockResolvedValue( + JSON.stringify({ + status: 'success', + configs: [ + { provider_id: 'simplelogin', description: 'Personal' }, + { provider_id: 'duckduckgo' }, + ], + default_provider_id: 'simplelogin', + }) + ) + + await expect(api.listAliasConfigs()).resolves.toEqual({ + configs: [ + { provider_id: 'simplelogin', description: 'Personal' }, + { provider_id: 'duckduckgo' }, + ], + default_provider_id: 'simplelogin', + }) + }) + + test('generateEmailMask returns the generated address', async () => { + invokeMock.mockResolvedValue( + JSON.stringify({ status: 'success', email: 'abc123@simplelogin.com' }) + ) + + await expect(api.generateEmailMask('simplelogin')).resolves.toBe('abc123@simplelogin.com') + expect(invokeMock).toHaveBeenCalledWith('generate_email_mask', { providerId: 'simplelogin' }) + }) + + test('saveAliasConfig sends the provider, token and description', async () => { + invokeMock.mockResolvedValue(JSON.stringify({ status: 'success' })) + + await api.saveAliasConfig('duckduckgo', 'token-1', 'Work') + + expect(invokeMock).toHaveBeenCalledWith('save_alias_config', { + providerId: 'duckduckgo', + apiToken: 'token-1', + description: 'Work', + }) + }) }) diff --git a/frontend/src/utils/aliasProviders.ts b/frontend/src/utils/aliasProviders.ts new file mode 100644 index 0000000..cbbc6ea --- /dev/null +++ b/frontend/src/utils/aliasProviders.ts @@ -0,0 +1,8 @@ +const PROVIDER_LABELS: Record = { + simplelogin: 'SimpleLogin', + duckduckgo: 'DuckDuckGo', +} + +export function providerLabel(providerId: string): string { + return PROVIDER_LABELS[providerId] ?? providerId +} From 27c95193490585a2ef441430a1da87d89edd7c7b Mon Sep 17 00:00:00 2001 From: parthashirolkar Date: Mon, 14 Sep 2026 14:16:17 +0530 Subject: [PATCH 2/6] fix: move email alias settings into visible column --- frontend/src-tauri/Cargo.toml | 2 +- frontend/src/components/Settings.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 06651f1..c8729ea 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = "=2.6.3" +tauri-build = { version = "=2.6.3", features = [] } [dependencies] serde_json = "1.0" diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 876cfd7..4ecc085 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -420,12 +420,10 @@ function Settings() {
- -
-
+

Updates

@@ -466,6 +464,8 @@ function Settings() { )}
+ +
From e5f58d5e9c4b66a22854ada0986f119d3c053f03 Mon Sep 17 00:00:00 2001 From: parthashirolkar Date: Mon, 14 Sep 2026 14:16:57 +0530 Subject: [PATCH 3/6] chore: drop incidental Cargo.toml rewrite --- frontend/src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index c8729ea..06651f1 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { version = "=2.6.3", features = [] } +tauri-build = "=2.6.3" [dependencies] serde_json = "1.0" From 8e57702484cb7967711c3b58ee2bda12d4d68600 Mon Sep 17 00:00:00 2001 From: parthashirolkar Date: Mon, 14 Sep 2026 14:25:43 +0530 Subject: [PATCH 4/6] fix: compact email alias section so it fits the settings column --- frontend/src/components/AliasSettings.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/AliasSettings.tsx b/frontend/src/components/AliasSettings.tsx index c8ef64a..df4a424 100644 --- a/frontend/src/components/AliasSettings.tsx +++ b/frontend/src/components/AliasSettings.tsx @@ -79,8 +79,8 @@ function AliasSettings() { return (
-
-

+
+

Email Alias Integration

@@ -136,7 +136,7 @@ function AliasSettings() { )} -
+