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
+1 -1
View File
@@ -72,7 +72,7 @@ impl MainTui {
// Global shortcuts
match key.code {
KeyCode::Char('q') => {
self.state.should_exit = true;
self.state.exit = Some(crate::models::TuiExit::Quit);
return;
}
KeyCode::Char('?') => {
+110 -9
View File
@@ -7,7 +7,7 @@ mod search;
mod settings;
use crate::api::SessionApi;
use crate::models::AppState;
use crate::models::{AppState, TuiExit};
use crate::persist::{
snapshot::{Snapshot, restore_messages, restore_searches},
state::{PersistedMessage, StateStore},
@@ -182,7 +182,7 @@ impl MainTui {
self.saved_snapshot = snapshot;
}
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<TuiExit> {
use ratatui::crossterm::{event::DisableMouseCapture, execute};
// Run the event loop, then restore the terminal unconditionally: if the
@@ -196,7 +196,7 @@ impl MainTui {
ratatui::restore();
soulseek_rs::utils::logger::disable_buffering();
result
result.map(|()| self.state.exit.unwrap_or(TuiExit::Quit))
}
/// What the window holds, for a test to read back after driving it.
@@ -219,7 +219,7 @@ impl MainTui {
}
fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.state.should_exit {
while self.state.exit.is_none() {
terminal.draw(|frame| self.render(frame))?;
self.poll_session();
@@ -239,7 +239,7 @@ impl MainTui {
}
_ => {}
}
if self.state.should_exit || !poll(Duration::ZERO)? {
if self.state.exit.is_some() || !poll(Duration::ZERO)? {
break;
}
}
@@ -257,7 +257,7 @@ pub fn launch_main_tui(
search_timeout: Duration,
store: Option<StateStore>,
config_path: Option<std::path::PathBuf>,
) -> Result<()> {
) -> Result<TuiExit> {
let tui =
MainTui::new(client, download_dir, search_timeout, store, config_path);
tui.run(terminal)
@@ -295,6 +295,8 @@ mod tests {
download_dir_set: std::sync::Mutex<Option<String>>,
/// What `set_shared_directories` was last asked for, if anything.
shares_set: std::sync::Mutex<Option<Vec<String>>>,
/// What `change_password` was last asked for, if anything.
password_set: std::sync::Mutex<Option<String>>,
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
download_removed: std::sync::Mutex<Vec<(String, String)>>,
@@ -550,7 +552,12 @@ mod tests {
None
}
fn shared_counts(&self) -> (u32, u32) {
(0, 0)
(3, 12)
}
fn change_password(&self, password: &str) -> soulseek_rs::Result<()> {
*self.password_set.lock().expect("not poisoned") =
Some(password.to_string());
Ok(())
}
fn shared_directories(&self) -> Vec<String> {
Vec::new()
@@ -1276,6 +1283,100 @@ mod tests {
assert_eq!(tui.download_dir, downloads_dir);
}
/// The account block answers "who is this session, and what is it
/// offering" without a trip to another screen.
#[test]
fn the_settings_popup_names_the_account_and_its_shares() {
let mut tui = with_session(TalkativeSession::default());
press(&mut tui, KeyCode::Char('o'));
let screen = screen_sized(&mut tui, 100, 30);
assert!(screen.contains("tester"), "{screen}");
assert!(screen.contains("12 files in 3 folders"), "{screen}");
}
/// Typed twice the same way, the new password reaches the session — and
/// attached to a daemon it is the daemon that stores it.
#[test]
fn a_password_typed_twice_reaches_the_session() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session.clone());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Enter); // the change-password row is first
for _ in 0..2 {
for c in "hunter2".chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
}
assert_eq!(
*session.password_set.lock().expect("not poisoned"),
Some("hunter2".to_string())
);
let settings = tui.state.settings.as_ref().expect("still open");
assert_eq!(settings.status.as_deref(), Some("Password changed"));
}
/// A mistyped repeat is caught here rather than on the server, where the
/// account would be left with a password nobody knows.
#[test]
fn a_password_typed_two_different_ways_is_not_sent() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session.clone());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Enter);
for typed in ["hunter2", "hunter3"] {
for c in typed.chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
}
assert_eq!(*session.password_set.lock().expect("not poisoned"), None);
}
/// Logging out ends the window, but as a log-out: the caller brings the
/// login screen back rather than returning to the shell.
#[test]
fn logging_out_asks_first_and_then_closes_the_window() {
let mut tui = with_session(TalkativeSession::default());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Down); // change password -> log out
press(&mut tui, KeyCode::Enter);
assert!(tui.state.exit.is_none(), "the confirm comes first");
press(&mut tui, KeyCode::Char('y'));
assert_eq!(tui.state.exit, Some(TuiExit::Logout));
}
/// Attached, the login is the daemon's: there is no logout row to land
/// on, so the row below the password is the download folder.
#[test]
fn an_attached_window_offers_no_logout() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session);
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Down);
press(&mut tui, KeyCode::Enter);
let settings = tui.state.settings.as_ref().expect("still open");
assert_eq!(
settings.mode,
crate::models::SettingsMode::EditingDownloadDir
);
assert!(tui.state.exit.is_none());
}
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
(0..n)
.map(|i| crate::models::FileDisplayData {
@@ -1448,7 +1549,7 @@ mod tests {
ctrl(&mut tui, 'b');
assert!(!tui.state.command_bar_active, "ctrl-b is not b");
ctrl(&mut tui, 'q');
assert!(!tui.state.should_exit, "ctrl-q is not q");
assert!(tui.state.exit.is_none(), "ctrl-q is not q");
}
#[test]
@@ -1682,7 +1783,7 @@ mod tests {
// Other keys are swallowed while it is open.
press(&mut tui, KeyCode::Char('q'));
assert!(!tui.state.should_exit, "q closes the list, not the app");
assert!(tui.state.exit.is_none(), "q closes the list, not the app");
assert!(!tui.state.show_help);
press(&mut tui, KeyCode::Char('?'));
+125 -29
View File
@@ -6,9 +6,9 @@ use crate::ui::panes::{
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
accent_style, body_style, dimmed_style, info_style, pack_shortcuts,
pane_block, plain_title, primary_style, render_download_stats,
warning_style,
GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask,
pack_shortcuts, pane_block, plain_title, primary_style,
render_download_stats, warning_style,
};
use ratatui::{
Frame,
@@ -57,6 +57,14 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[
("0 / $", "its start / its end"),
],
),
(
"Settings popup",
&[
("Enter", "change the password, log out, edit the folder"),
("a / d", "add / remove a shared folder"),
("r", "re-index the shares"),
],
),
];
const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
@@ -129,6 +137,48 @@ fn filter_keys(
keys
}
/// What the bar offers in the settings popup: the keys that always apply,
/// plus what Enter does on the row the selection rests on.
fn settings_shortcuts(
settings: &crate::models::SettingsState,
) -> Vec<(&'static str, &'static str)> {
use crate::models::{SettingsMode, SettingsRow};
match settings.mode {
SettingsMode::ConfirmingLogout => {
vec![("y", "log out"), ("n/Esc", "stay")]
}
SettingsMode::NewPassword => {
vec![
("Type", "password"),
("Enter", "repeat it"),
("Esc", "cancel"),
]
}
SettingsMode::RepeatPassword(_) => {
vec![
("Type", "password"),
("Enter", "change it"),
("Esc", "cancel"),
]
}
SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => {
vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")]
}
SettingsMode::Navigate => vec![
("↑↓", "move"),
match settings.selected_row() {
SettingsRow::ChangePassword => ("Enter", "change password"),
SettingsRow::Logout => ("Enter", "log out"),
SettingsRow::DownloadDir => ("Enter", "edit folder"),
SettingsRow::Share(_) => ("d", "remove share"),
},
("a", "add share"),
("r", "re-index"),
("Esc", "close"),
],
}
}
impl MainTui {
/// Draw the whole window.
pub fn render(&mut self, frame: &mut Frame) {
@@ -450,44 +500,101 @@ impl MainTui {
}
fn render_settings_popup(&self, frame: &mut Frame) {
use crate::models::SettingsMode;
use crate::models::{SettingsMode, SettingsRow};
let Some(settings) = self.state.settings.as_ref() else {
return;
};
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
let selected = settings.selected_row();
let mut lines: Vec<Line> = Vec::new();
let marker = |selected: bool| if selected { "> " } else { " " };
let entry = |selected: bool, label: &str, value: String| {
let entry = |on: bool, label: &str, value: String| {
Line::from(vec![
Span::styled(marker(selected).to_string(), accent_style()),
Span::styled(if on { "> " } else { " " }, accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
])
};
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
let typing = |label: &str, value: String| {
Line::from(vec![
Span::styled("> ", accent_style()),
Span::styled("Download folder: ", dimmed_style()),
Span::styled(settings.input.clone(), primary_style()),
Span::styled("▏", accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
Span::styled(GLYPH_CURSOR, accent_style()),
])
};
let section = |name: &str| Line::styled(name.to_string(), info_style());
// One column of labels, padded here rather than by hand in each
// string.
let field = |label: &str, value: String| {
entry(false, &format!(" {label:<14}"), value)
};
let account = &settings.account;
lines.push(section("Account"));
lines.push(field("Signed in as", account.username.clone()));
lines.push(field("Sharing", account.shares.clone()));
lines.push(field(
"Reachable on",
account.listen_port.map_or_else(
|| "not accepting connections".to_string(),
|port| format!("port {port}"),
),
));
if let Some(daemon) = &account.daemon {
lines.push(field("Daemon", daemon.clone()));
}
lines.push(Line::from(""));
lines.push(match settings.mode {
SettingsMode::NewPassword => {
typing("New password: ", mask(&settings.input))
}
SettingsMode::RepeatPassword(_) => {
typing("Repeat it: ", mask(&settings.input))
}
_ => entry(
selected == SettingsRow::ChangePassword,
"Change password…",
String::new(),
),
});
if settings.mode == SettingsMode::ConfirmingLogout {
lines.push(entry(
true,
&format!("Log out of {}? ", account.username),
"y / n".to_string(),
));
} else if settings.can_log_out() {
lines.push(entry(
selected == SettingsRow::Logout,
"Log out…",
String::new(),
));
}
lines.push(Line::from(""));
lines.push(section("Folders"));
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
typing("Download folder: ", settings.input.clone())
} else {
entry(
settings.selected == 0,
selected == SettingsRow::DownloadDir,
"Download folder: ",
settings.download_dir.clone(),
)
});
lines.push(Line::from(""));
lines.push(Line::styled(
format!("Shared folders ({}):", settings.share_dirs.len()),
dimmed_style(),
));
for (i, dir) in settings.share_dirs.iter().enumerate() {
lines.push(entry(settings.selected == i + 1, "", dir.clone()));
lines.push(entry(
selected == SettingsRow::Share(i),
"",
dir.clone(),
));
}
if settings.share_dirs.is_empty() {
lines.push(Line::styled(
@@ -496,11 +603,7 @@ impl MainTui {
));
}
if settings.mode == SettingsMode::AddingShare {
lines.push(Line::from(vec![
Span::styled(" Add share: ", dimmed_style()),
Span::styled(settings.input.clone(), primary_style()),
Span::styled("▏", accent_style()),
]));
lines.push(typing(" Add share: ", settings.input.clone()));
}
if let Some(status) = &settings.status {
lines.push(Line::from(""));
@@ -599,15 +702,8 @@ impl MainTui {
fn shortcuts(&self) -> Vec<(&'static str, &'static str)> {
if self.state.show_help {
vec![("?/Esc", "close")]
} else if self.state.settings.is_some() {
vec![
("↑↓", "move"),
("Enter/e", "edit download dir"),
("a", "add share"),
("d", "remove share"),
("r", "re-index"),
("Esc", "close"),
]
} else if let Some(settings) = self.state.settings.as_ref() {
settings_shortcuts(settings)
} else if self.state.show_messages {
if self.state.chat_composing {
vec![
+46 -9
View File
@@ -3,12 +3,21 @@
//! socket when the session is a daemon's).
use super::MainTui;
use crate::models::{SettingsAction, SettingsState};
use crate::models::{AccountInfo, SettingsAction, SettingsState};
use crate::persist::secret::KeyringStore;
use ratatui::crossterm::event::KeyEvent;
impl MainTui {
pub(super) fn open_settings(&mut self) {
// Read once here, not while drawing: see `AccountInfo`.
let account = AccountInfo {
username: self.client.username(),
shares: self.share_counts(),
listen_port: self.client.listen_port(),
daemon: self.client.daemon_endpoint(),
};
self.state.settings = Some(SettingsState::new(
account,
self.download_dir.clone(),
self.client.shared_directories(),
));
@@ -23,9 +32,28 @@ impl MainTui {
SettingsAction::Close => self.state.settings = None,
SettingsAction::Apply => self.apply_settings(),
SettingsAction::Reindex => self.reindex_shares(),
SettingsAction::ChangePassword(password) => {
self.change_password(&password);
}
SettingsAction::Logout => {
self.state.exit = Some(crate::models::TuiExit::Logout);
}
}
}
fn change_password(&mut self, password: &str) {
let status = match crate::persist::secret::change_password(
self.client.as_ref(),
&KeyringStore,
password,
) {
Ok(None) => "Password changed".into(),
Ok(Some(e)) => format!("Password changed, but not stored: {e}"),
Err(e) => format!("Could not change it: {e}"),
};
self.set_settings_status(status);
}
/// Persist the edited settings and apply them live.
///
/// Attached to a daemon, both folders name paths on the *daemon's*
@@ -71,9 +99,10 @@ impl MainTui {
let mut status = if !refused.is_empty() {
format!("Could not apply {}", refused.join("; "))
} else if dropped > 0 {
self.refresh_share_counts();
format!("Applied ({dropped} invalid path(s) ignored)")
} else {
format!("Applied · sharing {}", self.share_counts())
format!("Applied · sharing {}", self.refresh_share_counts())
};
if dir_applied {
self.download_dir.clone_from(&download_dir);
@@ -101,6 +130,16 @@ impl MainTui {
self.set_settings_status(status);
}
/// Bring the account block's share count back in step, and hand the same
/// count to the caller: counting is a round trip worth making once.
fn refresh_share_counts(&mut self) -> String {
let counts = self.share_counts();
if let Some(settings) = self.state.settings.as_mut() {
settings.account.shares.clone_from(&counts);
}
counts
}
fn set_settings_status(&mut self, status: String) {
if let Some(settings) = self.state.settings.as_mut() {
settings.status = Some(status);
@@ -111,13 +150,11 @@ impl MainTui {
fn reindex_shares(&mut self) {
let dirs = self.client.shared_directories();
let result = self.client.set_shared_directories(dirs);
let counts = self.share_counts();
if let Some(settings) = self.state.settings.as_mut() {
settings.status = Some(match result {
Ok(()) => format!("Re-indexed · sharing {counts}"),
Err(e) => format!("Re-index failed: {e}"),
});
}
let counts = self.refresh_share_counts();
self.set_settings_status(match result {
Ok(()) => format!("Re-indexed · sharing {counts}"),
Err(e) => format!("Re-index failed: {e}"),
});
}
fn share_counts(&self) -> String {