Implements multi-account support in the lib.
This commit is contained in:
12
Tasks.md
12
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
|
||||
|
||||
|
||||
<a id="nut-007" class="task" data-status="triage" data-task-type="feature"></a>
|
||||
<a id="nut-007" class="task" data-status="done" data-task-type="feature"></a>
|
||||
### 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
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Username for this account on Nextcloud.
|
||||
pub username: String,
|
||||
|
||||
@@ -27,8 +36,43 @@ pub struct StoredAccount {
|
||||
pub fallback_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>, server_url: impl Into<String>) -> 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<String>) -> 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<Option<(StoredAccount, AccountCredentials)>> {
|
||||
/// Find an account by ID, label, or username.
|
||||
pub fn find_account(query: &str) -> Result<Option<StoredAccount>> {
|
||||
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<Option<(StoredAccount, AccountCredentials)>> {
|
||||
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<Option<(StoredAccount, AccountCredentials)>> {
|
||||
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<StoredAccount> {
|
||||
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<NextcloudClient> {
|
||||
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<NextcloudClient> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user