feat(tui): account settings — stats, password change, and logout

The settings popup opens with an Account block: who the session is logged in
as, how much it shares, the port peers reach it on, and the daemon it borrows
when the window is attached to one. Two rows follow, both confirmed before
they act. A password is typed twice, because the server sends no verdict on a
change and a typo entered once would leave the account with a password nobody
knows. Logging out closes the window and comes back to the login screen with
the stored password deleted, so the next start asks rather than walking
straight back in. A window attached to a daemon offers no logout: the login is
the daemon's.

"Change it on the server, then store it where the login lives" is one function
over the session and the secret store, so the window, the command and the
daemon share the rule rather than each spelling out its own idea of which
process owns the password.

Headless the same two acts are `account password` and `account logout`, and
the daemon answers `account.password` so a routed run is not a dead end —
`whoami` already reported who is logged in and what they share.
This commit is contained in:
Michel de Graaf
2026-09-10 11:10:58 +02:00
parent 4ea1e9c47c
commit d6d870b39f
26 changed files with 1137 additions and 215 deletions
+70 -5
View File
@@ -11,13 +11,14 @@ use super::proto::{
DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto,
DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds,
MemberStats, Members, MessageParams, Messages, Method, OPENRPC,
PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError,
SayParams, SearchResultDto, SearchResults, SearchSummary, Searches,
Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads,
UserInfoDto, UserRef, UserResult, Watched,
PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto,
RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary,
Searches, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto,
Uploads, UserInfoDto, UserRef, UserResult, Watched,
};
use crate::api::SessionApi;
use crate::output::Exit;
use crate::persist::secret::SecretStore;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -27,6 +28,9 @@ use std::time::{Duration, Instant};
/// Everything a connection needs to answer a request.
pub struct Daemon {
pub session: Arc<dyn SessionApi>,
/// Where this host keeps the account's password. The daemon owns the
/// login, so a password it changes it also has to store.
pub secrets: Arc<dyn SecretStore>,
pub hub: Arc<Hub>,
pub browses: Arc<PendingBrowses>,
pub downloads: Arc<Mutex<Vec<PendingDownload>>>,
@@ -346,6 +350,27 @@ impl Daemon {
users: self.session.watched_users(),
}),
Method::AccountPassword => {
let params: PasswordParams = parse(params)?;
// The daemon logs in again from this host's store when it
// restarts, so a password it changed but never stored would
// lock it out of its own account.
let stored = crate::persist::secret::change_password(
self.session.as_ref(),
self.secrets.as_ref(),
&params.password,
)
.map_err(|e| {
RpcError::application(Exit::Connection, e.to_string())
})?;
if let Some(e) = stored {
soulseek_rs::warn!(
"Password changed, but not stored on this host: {e}"
);
}
ok(Ack::OK)
}
Method::SharesStatusOf => ok(self.shares()),
Method::SharesSet => {
let directories: DirectoriesParams = parse(params)?;
@@ -656,6 +681,32 @@ mod tests {
assert_eq!(stats["members"], json!([]));
}
/// The daemon owns the login, so a password it changes it also stores —
/// its next start logs in from that store, not from the client that
/// asked.
#[test]
fn a_password_change_reaches_the_session_and_this_hosts_store() {
let session = Arc::new(SilentSession::default());
let daemon = daemon_with("t", Arc::clone(&session));
daemon
.call(Method::AccountPassword, json!({ "password": "hunter2" }))
.expect("the password change is served");
assert_eq!(
*session.password.lock().expect("not poisoned"),
Some("hunter2".to_string())
);
assert_eq!(
daemon
.secrets
.get(&session.username())
.expect("the store answers")
.as_deref(),
Some("hunter2")
);
}
#[test]
fn a_watch_is_readable_back_and_survives_until_unwatched() {
// The three methods are only useful together: what `user.watch`
@@ -719,14 +770,23 @@ mod tests {
#[derive(Default)]
struct SilentSession {
watched: Mutex<Vec<String>>,
/// What `account.password` asked the session to set, if anything.
password: Mutex<Option<String>>,
}
/// The one room `SilentSession` claims to have joined.
const STUB_ROOM: &str = "jazz";
fn daemon_with_token(token: &str) -> Daemon {
daemon_with(token, Arc::new(SilentSession::default()))
}
/// A daemon over a session the caller can read back, with an in-memory
/// secret store: no test may touch the machine's keychain.
fn daemon_with(token: &str, session: Arc<SilentSession>) -> Daemon {
Daemon {
session: Arc::new(SilentSession::default()),
session,
secrets: Arc::new(crate::persist::secret::MemoryStore::default()),
hub: Arc::new(Hub::new()),
browses: Arc::new(PendingBrowses::default()),
downloads: Arc::new(Mutex::new(Vec::new())),
@@ -745,6 +805,11 @@ mod tests {
fn username(&self) -> String {
"tester".to_string()
}
fn change_password(&self, password: &str) -> soulseek_rs::Result<()> {
*self.password.lock().expect("not poisoned") =
Some(password.to_string());
Ok(())
}
fn listen_port(&self) -> Option<u16> {
None
}