diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index e213574e..e8f26394 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -96,6 +96,7 @@ pub fn import_from_auth_json_contents( ) -> Result { let auth: AuthDotJson = serde_json::from_str(&content).context("Failed to parse auth.json contents")?; + let account_name = account_name.trim().to_string(); // Determine auth mode and create account if let Some(api_key) = auth.openai_api_key { @@ -142,3 +143,51 @@ pub fn has_active_login() -> Result { None => Ok(false), } } + +#[cfg(test)] +mod tests { + use super::import_from_auth_json_contents; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use serde_json::json; + + fn auth_json(payload: serde_json::Value, account_id: &str) -> String { + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + serde_json::json!({ + "tokens": { + "id_token": format!("header.{payload}.signature"), + "access_token": "access", + "refresh_token": "refresh", + "account_id": account_id + } + }) + .to_string() + } + + #[test] + fn import_blank_name_uses_email() { + let account = import_from_auth_json_contents( + &auth_json(json!({"email": "imported@example.com"}), "acct-import"), + "".into(), + ) + .unwrap(); + assert_eq!(account.name, "imported@example.com"); + } + + #[test] + fn import_explicit_name_is_trimmed() { + let account = import_from_auth_json_contents( + &auth_json(json!({"email": "imported@example.com"}), "acct-import"), + " Imported Account ".into(), + ) + .unwrap(); + assert_eq!(account.name, "Imported Account"); + } + + #[test] + fn import_without_email_uses_account_id_fallback() { + let account = + import_from_auth_json_contents(&auth_json(json!({}), "acct-87654321"), "".into()) + .unwrap(); + assert_eq!(account.name, "ChatGPT account (87654321)"); + } +} diff --git a/src-tauri/src/commands/oauth.rs b/src-tauri/src/commands/oauth.rs index f4918147..a6e6517c 100644 --- a/src-tauri/src/commands/oauth.rs +++ b/src-tauri/src/commands/oauth.rs @@ -29,7 +29,7 @@ pub async fn start_login(account_name: String) -> Result previous.cancelled.store(true, Ordering::Relaxed); } - let (info, rx, cancelled) = start_oauth_login(account_name) + let (info, rx, cancelled) = start_oauth_login(account_name.trim().to_string()) .await .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index de1abfd2..c23d96d3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -95,11 +95,37 @@ pub struct StoredAccount { } impl StoredAccount { + fn resolved_name( + name: String, + email: Option<&String>, + account_id: Option<&String>, + kind: &str, + ) -> String { + if !name.trim().is_empty() { + return name; + } + if let Some(email) = email.filter(|email| !email.trim().is_empty()) { + return email.clone(); + } + if let Some(account_id) = account_id.filter(|id| !id.trim().is_empty()) { + let suffix: String = account_id + .chars() + .rev() + .take(8) + .collect::() + .chars() + .rev() + .collect(); + return format!("{kind} account ({suffix})"); + } + format!("{kind} account") + } + /// Create a new account with API key authentication pub fn new_api_key(name: String, api_key: String) -> Self { Self { id: Uuid::new_v4().to_string(), - name, + name: Self::resolved_name(name, None, None, "API key"), email: None, plan_type: None, subscription_expires_at: None, @@ -121,6 +147,7 @@ impl StoredAccount { refresh_token: String, account_id: Option, ) -> Self { + let name = Self::resolved_name(name, email.as_ref(), account_id.as_ref(), "ChatGPT"); Self { id: Uuid::new_v4().to_string(), name, @@ -140,6 +167,71 @@ impl StoredAccount { } } +#[cfg(test)] +mod account_name_tests { + use super::StoredAccount; + + #[test] + fn blank_name_defaults_to_email() { + let account = StoredAccount::new_chatgpt( + "".into(), + Some("user@example.com".into()), + None, + None, + "id".into(), + "access".into(), + "refresh".into(), + Some("acct-12345678".into()), + ); + assert_eq!(account.name, "user@example.com"); + } + + #[test] + fn explicit_name_is_preserved() { + let account = StoredAccount::new_chatgpt( + " My Account ".into(), + Some("user@example.com".into()), + None, + None, + "id".into(), + "access".into(), + "refresh".into(), + None, + ); + assert_eq!(account.name, " My Account "); + } + + #[test] + fn missing_email_uses_account_id_suffix() { + let account = StoredAccount::new_chatgpt( + "".into(), + None, + None, + None, + "id".into(), + "access".into(), + "refresh".into(), + Some("acct-12345678".into()), + ); + assert_eq!(account.name, "ChatGPT account (12345678)"); + } + + #[test] + fn missing_email_and_account_id_use_generic_fallback() { + let account = StoredAccount::new_chatgpt( + "".into(), + None, + None, + None, + "id".into(), + "access".into(), + "refresh".into(), + None, + ); + assert_eq!(account.name, "ChatGPT account"); + } +} + /// Authentication mode #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/src/components/AddAccountModal.tsx b/src/components/AddAccountModal.tsx index 9b795dec..3d868ca9 100644 --- a/src/components/AddAccountModal.tsx +++ b/src/components/AddAccountModal.tsx @@ -55,11 +55,6 @@ export function AddAccountModal({ }; const handleOAuthLogin = async () => { - if (!name.trim()) { - setError("Please enter an account name"); - return; - } - try { setLoading(true); setError(null); @@ -88,10 +83,6 @@ export function AddAccountModal({ }; const handleImportFile = async () => { - if (!name.trim()) { - setError("Please enter an account name"); - return; - } if (!fileSource) { setError("Please select an auth.json file"); return; @@ -152,16 +143,16 @@ export function AddAccountModal({ {/* Content */}
- {/* Account Name (always shown) */} + {/* Account name is optional; the backend derives one when blank. */}
setName(e.target.value)} - placeholder="e.g., Work Account" + placeholder="Leave blank to use email" className="w-full px-4 py-2.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:border-gray-400 dark:focus:border-gray-500 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500 transition-colors" />