From e78b67b6a55c6718420bdb38fd3f13062fcad7a2 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Sun, 23 Aug 2026 15:27:30 -0700 Subject: [PATCH] Implements multi-account support in the lib. --- Tasks.md | 12 +- nextcloud_client/src/credentials.rs | 231 +++++++++++++++++++++------- 2 files changed, 183 insertions(+), 60 deletions(-) diff --git a/Tasks.md b/Tasks.md index 9a4b56b..1122da5 100644 --- a/Tasks.md +++ b/Tasks.md @@ -75,7 +75,7 @@ This document defines the complete project roadmap and task tracking system for | [NUT-004](#nut-004) | Implement OCS Share Link Generation | Fixed | Feature | | [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature | | [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | -| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Triage | Feature | +| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature | | [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature | | [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature | | [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature | @@ -213,19 +213,19 @@ Implement secure credential storage using OS keychain when available, falling ba - NUT-002 - + ### Implement Multi-Account Support (Backend) **ID:** NUT-007 -**Status:** Triage +**Status:** Fixed **Type:** Feature **Description:** Support multiple Nextcloud accounts in the backend credential system. **Requirements:** -- [ ] Add account list structure -- [ ] Add default account selection -- [ ] Add account switching API +- [x] Add account list structure +- [x] Add default account selection +- [x] Add account switching API **Dependencies:** - NUT-006 diff --git a/nextcloud_client/src/credentials.rs b/nextcloud_client/src/credentials.rs index 4626590..36eaf0a 100644 --- a/nextcloud_client/src/credentials.rs +++ b/nextcloud_client/src/credentials.rs @@ -2,8 +2,10 @@ use keyring::Entry; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +use url::Url; -use crate::config::AccountCredentials; +use crate::client::NextcloudClient; +use crate::config::{AccountCredentials, ClientConfig}; use crate::error::{NextcloudError, Result}; /// Service name identifier for OS Keychain / Keyring storage. @@ -12,6 +14,13 @@ pub const KEYRING_SERVICE_NAME: &str = "me.majinnaibu.nut"; /// Metadata record of a configured Nextcloud account. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StoredAccount { + /// Unique identifier for this account (e.g. `"tom@cloud.example.com"`). + pub id: String, + + /// Optional user-friendly label/alias (e.g. `"Personal"`, `"Work"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Username for this account on Nextcloud. pub username: String, @@ -27,8 +36,43 @@ pub struct StoredAccount { pub fallback_password: Option, } -/// Manages account credentials using system keychain (macOS Keychain, Windows Credential Manager, -/// Linux Secret Service) with fallback file persistence. +impl StoredAccount { + /// Create a new `StoredAccount` with a generated canonical ID (`username@host`). + pub fn new(username: impl Into, server_url: impl Into) -> Self { + let username = username.into(); + let server_url = server_url.into(); + + let host = Url::parse(&server_url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_else(|| "unknown-host".to_string()); + + let id = format!("{username}@{host}"); + + Self { + id, + label: None, + username, + server_url, + is_default: false, + fallback_password: None, + } + } + + /// Set a friendly label for the account. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Keyring account key identifier for secure storage. + pub fn keyring_key(&self) -> &str { + &self.id + } +} + +/// Manages multiple Nextcloud account credentials using system keychain (macOS Keychain, +/// Windows Credential Manager, Linux Secret Service) with fallback file persistence. #[derive(Debug, Clone, Default)] pub struct CredentialStore; @@ -73,26 +117,45 @@ impl CredentialStore { Ok(()) } - /// Retrieve full credentials (including secret app password) for a specific username. - pub fn get_credentials(username: &str) -> Result> { + /// Find an account by ID, label, or username. + pub fn find_account(query: &str) -> Result> { let accounts = Self::list_accounts()?; - let account = match accounts.into_iter().find(|a| a.username == username) { + let found = accounts.into_iter().find(|a| { + a.id == query + || a.label.as_deref() == Some(query) + || a.username == query + }); + Ok(found) + } + + /// Retrieve full credentials (including secret app password) for an account matching `query`. + pub fn get_credentials(query: &str) -> Result> { + let account = match Self::find_account(query)? { Some(a) => a, None => return Ok(None), }; - // Attempt retrieval from OS Keyring first - if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, username) { + let keyring_key = account.keyring_key(); + + // 1. Attempt retrieval from OS Keyring + if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) { if let Ok(password) = entry.get_password() { - return Ok(Some(( - account, - AccountCredentials::new(username, password), - ))); + let username = account.username.clone(); + return Ok(Some((account, AccountCredentials::new(username, password)))); } } - // Check fallback password if keyring didn't contain it + // Also try fallback by username alone if migrated + if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) { + if let Ok(password) = entry.get_password() { + let username = account.username.clone(); + return Ok(Some((account, AccountCredentials::new(username, password)))); + } + } + + // 2. Check fallback password in account record if let Some(ref pass) = account.fallback_password { + let username = account.username.clone(); return Ok(Some(( account.clone(), AccountCredentials::new(username, pass), @@ -102,31 +165,56 @@ impl CredentialStore { Ok(None) } - /// Retrieve the default/active account credentials (if any account is configured). + /// Retrieve the default/active account credentials. pub fn get_default_credentials() -> Result> { let accounts = Self::list_accounts()?; - let default_username = accounts + let default_id = accounts .iter() .find(|a| a.is_default) - .map(|a| a.username.clone()) - .or_else(|| accounts.first().map(|a| a.username.clone())); + .map(|a| a.id.clone()) + .or_else(|| accounts.first().map(|a| a.id.clone())); - match default_username { - Some(user) => Self::get_credentials(&user), + match default_id { + Some(id) => Self::get_credentials(&id), None => Ok(None), } } + /// Set an account as the default active account. + pub fn set_default_account(query: &str) -> Result<()> { + let mut accounts = Self::list_accounts()?; + let mut target_index = None; + + for (i, acc) in accounts.iter_mut().enumerate() { + if acc.id == query || acc.label.as_deref() == Some(query) || acc.username == query { + acc.is_default = true; + target_index = Some(i); + } else { + acc.is_default = false; + } + } + + if target_index.is_some() { + Self::save_accounts(&accounts)?; + Ok(()) + } else { + Err(NextcloudError::Other(format!( + "Account '{query}' not found" + ))) + } + } + /// Save or update an account with server URL and app password. pub fn save_account( server_url: &str, username: &str, app_password: &str, set_as_default: bool, - ) -> Result<()> { + ) -> Result { let mut accounts = Self::list_accounts()?; + let mut new_account = StoredAccount::new(username, server_url); - // If setting as default, clear default on other accounts + // If setting as default, clear default on existing accounts if set_as_default { for acc in &mut accounts { acc.is_default = false; @@ -135,52 +223,81 @@ impl CredentialStore { let is_first = accounts.is_empty(); let make_default = set_as_default || is_first; + new_account.is_default = make_default; // Try storing password in OS Keyring let mut fallback_password = None; - let keyring_result = Entry::new(KEYRING_SERVICE_NAME, username) + let keyring_result = Entry::new(KEYRING_SERVICE_NAME, new_account.keyring_key()) .and_then(|entry| entry.set_password(app_password)); if keyring_result.is_err() { - // Keyring unavailable (e.g. headless environment), store in fallback fallback_password = Some(app_password.to_string()); } + new_account.fallback_password = fallback_password; - // Update existing or append new account record - if let Some(existing) = accounts.iter_mut().find(|a| a.username == username) { + // Update existing or append new + if let Some(existing) = accounts.iter_mut().find(|a| a.id == new_account.id) { existing.server_url = server_url.to_string(); - existing.fallback_password = fallback_password; + existing.fallback_password = new_account.fallback_password.clone(); if set_as_default { existing.is_default = true; } + new_account = existing.clone(); } else { - accounts.push(StoredAccount { - username: username.to_string(), - server_url: server_url.to_string(), - is_default: make_default, - fallback_password, - }); + accounts.push(new_account.clone()); } - Self::save_accounts(&accounts) + Self::save_accounts(&accounts)?; + Ok(new_account) } /// Delete an account from both OS Keyring and local account registry. - pub fn delete_account(username: &str) -> Result<()> { - // Delete from OS Keyring - if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, username) { - let _ = entry.delete_credential(); - } - + pub fn delete_account(query: &str) -> Result<()> { let mut accounts = Self::list_accounts()?; - accounts.retain(|a| a.username != username); + let target = accounts.iter().find(|a| { + a.id == query || a.label.as_deref() == Some(query) || a.username == query + }).cloned(); - // Ensure at least one account is marked default if accounts remain - if !accounts.is_empty() && !accounts.iter().any(|a| a.is_default) { - accounts[0].is_default = true; + if let Some(acc) = target { + // Delete from OS Keyring + if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, acc.keyring_key()) { + let _ = entry.delete_credential(); + } + + accounts.retain(|a| a.id != acc.id); + + // Ensure at least one account is marked default if accounts remain + if !accounts.is_empty() && !accounts.iter().any(|a| a.is_default) { + accounts[0].is_default = true; + } + + Self::save_accounts(&accounts)?; + Ok(()) + } else { + Err(NextcloudError::Other(format!( + "Account '{query}' not found" + ))) } + } - Self::save_accounts(&accounts) + /// Create an initialized `NextcloudClient` for an account matching `query`. + pub fn create_client_for_account(query: &str) -> Result { + let (account, creds) = Self::get_credentials(query)?.ok_or_else(|| { + NextcloudError::Other(format!("No credentials found for account '{query}'")) + })?; + + let config = ClientConfig::new(&account.server_url, Some(creds))?; + NextcloudClient::new(config) + } + + /// Create an initialized `NextcloudClient` for the default account. + pub fn create_client_for_default() -> Result { + let (account, creds) = Self::get_default_credentials()?.ok_or_else(|| { + NextcloudError::Other("No default account configured. Please run 'nut login' or configure an account.".into()) + })?; + + let config = ClientConfig::new(&account.server_url, Some(creds))?; + NextcloudClient::new(config) } } @@ -188,20 +305,26 @@ impl CredentialStore { mod tests { use super::*; + #[test] + fn test_stored_account_generation() { + let account = StoredAccount::new("tom", "https://cloud.example.com") + .with_label("Personal Cloud"); + + assert_eq!(account.id, "tom@cloud.example.com"); + assert_eq!(account.label.as_deref(), Some("Personal Cloud")); + assert_eq!(account.username, "tom"); + assert_eq!(account.server_url, "https://cloud.example.com"); + assert_eq!(account.keyring_key(), "tom@cloud.example.com"); + } + #[test] fn test_stored_account_serialization() { - let account = StoredAccount { - username: "tom".to_string(), - server_url: "https://cloud.example.com".to_string(), - is_default: true, - fallback_password: None, - }; + let account = StoredAccount::new("tom", "https://cloud.example.com") + .with_label("Personal"); let json = serde_json::to_string(&account).unwrap(); - assert!(json.contains("\"username\":\"tom\"")); - assert!(json.contains("\"is_default\":true")); - // fallback_password shouldn't serialize when None - assert!(!json.contains("fallback_password")); + assert!(json.contains("\"id\":\"tom@cloud.example.com\"")); + assert!(json.contains("\"label\":\"Personal\"")); let deserialized: StoredAccount = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, account);