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:
@@ -1,9 +1,9 @@
|
||||
//! State machine for the settings popup (download folder + share paths).
|
||||
//! State machine for the settings popup (account, download folder, shares).
|
||||
//! Pure — no client or terminal — so every transition is unit-testable.
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
/// Which row is highlighted: 0 = download dir, 1.. = share paths.
|
||||
/// What the popup is doing with the keyboard.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SettingsMode {
|
||||
/// Moving between rows.
|
||||
@@ -12,10 +12,39 @@ pub enum SettingsMode {
|
||||
EditingDownloadDir,
|
||||
/// Typing a new share path to add.
|
||||
AddingShare,
|
||||
/// Typing a new password; the keystrokes are masked.
|
||||
NewPassword,
|
||||
/// Typing it again, with the first entry held here. A password reaches the
|
||||
/// server unconfirmed, so a typo entered once would change the account to
|
||||
/// something nobody knows.
|
||||
RepeatPassword(String),
|
||||
/// Waiting for "log out?" to be answered.
|
||||
ConfirmingLogout,
|
||||
}
|
||||
|
||||
impl SettingsMode {
|
||||
/// Whether what is being typed must not be shown.
|
||||
const fn is_secret(&self) -> bool {
|
||||
matches!(self, Self::NewPassword | Self::RepeatPassword(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// One line the selection can rest on.
|
||||
///
|
||||
/// The rows are derived from the state rather than counted, because the logout
|
||||
/// row is absent when the session belongs to a daemon and index arithmetic
|
||||
/// would silently shift with it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SettingsRow {
|
||||
ChangePassword,
|
||||
Logout,
|
||||
DownloadDir,
|
||||
/// Share path at this index.
|
||||
Share(usize),
|
||||
}
|
||||
|
||||
/// What the TUI should do after a key was handled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SettingsAction {
|
||||
None,
|
||||
/// Settings changed: persist config and apply the new share set live.
|
||||
@@ -24,12 +53,33 @@ pub enum SettingsAction {
|
||||
Reindex,
|
||||
/// Close the popup.
|
||||
Close,
|
||||
/// Make this the account's password.
|
||||
ChangePassword(String),
|
||||
/// End the session and forget the stored password.
|
||||
Logout,
|
||||
}
|
||||
|
||||
/// Who the session is logged in as and what it offers the network.
|
||||
///
|
||||
/// Captured when the popup opens rather than read while drawing: attached to a
|
||||
/// daemon every one of these is a round trip, and the popup redraws each tick.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountInfo {
|
||||
pub username: String,
|
||||
/// Already formatted, e.g. "1234 files in 56 folders".
|
||||
pub shares: String,
|
||||
/// The port peers can reach us on, when the listener is up.
|
||||
pub listen_port: Option<u16>,
|
||||
/// The daemon this session is borrowed from. `Some` means the account is
|
||||
/// the daemon's, so this window cannot log it out.
|
||||
pub daemon: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SettingsState {
|
||||
pub account: AccountInfo,
|
||||
pub download_dir: String,
|
||||
pub share_dirs: Vec<String>,
|
||||
/// 0 = download dir row; 1 + i = share path i.
|
||||
/// Index into [`Self::rows`].
|
||||
pub selected: usize,
|
||||
pub mode: SettingsMode,
|
||||
/// Edit buffer while typing.
|
||||
@@ -40,8 +90,13 @@ pub struct SettingsState {
|
||||
|
||||
impl SettingsState {
|
||||
#[must_use]
|
||||
pub const fn new(download_dir: String, share_dirs: Vec<String>) -> Self {
|
||||
pub const fn new(
|
||||
account: AccountInfo,
|
||||
download_dir: String,
|
||||
share_dirs: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
account,
|
||||
download_dir,
|
||||
share_dirs,
|
||||
selected: 0,
|
||||
@@ -51,16 +106,42 @@ impl SettingsState {
|
||||
}
|
||||
}
|
||||
|
||||
const fn rows(&self) -> usize {
|
||||
1 + self.share_dirs.len()
|
||||
/// The daemon owns its own login; a window borrowing it can only detach.
|
||||
#[must_use]
|
||||
pub const fn can_log_out(&self) -> bool {
|
||||
self.account.daemon.is_none()
|
||||
}
|
||||
|
||||
/// Every row, in the order they are drawn.
|
||||
#[must_use]
|
||||
pub fn rows(&self) -> Vec<SettingsRow> {
|
||||
let mut rows = vec![SettingsRow::ChangePassword];
|
||||
if self.can_log_out() {
|
||||
rows.push(SettingsRow::Logout);
|
||||
}
|
||||
rows.push(SettingsRow::DownloadDir);
|
||||
rows.extend((0..self.share_dirs.len()).map(SettingsRow::Share));
|
||||
rows
|
||||
}
|
||||
|
||||
fn last_row(&self) -> usize {
|
||||
self.rows().len() - 1
|
||||
}
|
||||
|
||||
/// The row the selection rests on.
|
||||
#[must_use]
|
||||
pub fn selected_row(&self) -> SettingsRow {
|
||||
self.rows()[self.selected.min(self.last_row())]
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
match self.mode {
|
||||
SettingsMode::Navigate => self.handle_navigate(key),
|
||||
SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => {
|
||||
self.handle_typing(key)
|
||||
}
|
||||
SettingsMode::ConfirmingLogout => self.handle_logout_confirm(key),
|
||||
SettingsMode::EditingDownloadDir
|
||||
| SettingsMode::AddingShare
|
||||
| SettingsMode::NewPassword
|
||||
| SettingsMode::RepeatPassword(_) => self.handle_typing(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,22 +154,28 @@ impl SettingsState {
|
||||
SettingsAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.selected = (self.selected + 1).min(self.rows() - 1);
|
||||
self.selected = (self.selected + 1).min(self.last_row());
|
||||
SettingsAction::None
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => {
|
||||
self.mode = SettingsMode::EditingDownloadDir;
|
||||
self.input = self.download_dir.clone();
|
||||
SettingsAction::None
|
||||
KeyCode::Enter => self.activate(),
|
||||
// 'e' has always meant "edit this folder"; it must not reach a row
|
||||
// that logs the account out.
|
||||
KeyCode::Char('e')
|
||||
if self.selected_row() == SettingsRow::DownloadDir =>
|
||||
{
|
||||
self.activate()
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.mode = SettingsMode::AddingShare;
|
||||
self.input.clear();
|
||||
SettingsAction::None
|
||||
}
|
||||
KeyCode::Char('d') if self.selected > 0 => {
|
||||
self.share_dirs.remove(self.selected - 1);
|
||||
self.selected = self.selected.min(self.rows() - 1);
|
||||
KeyCode::Char('d') => {
|
||||
let SettingsRow::Share(index) = self.selected_row() else {
|
||||
return SettingsAction::None;
|
||||
};
|
||||
self.share_dirs.remove(index);
|
||||
self.selected = self.selected.min(self.last_row());
|
||||
SettingsAction::Apply
|
||||
}
|
||||
KeyCode::Char('r') => SettingsAction::Reindex,
|
||||
@@ -96,6 +183,31 @@ impl SettingsState {
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self) -> SettingsAction {
|
||||
match self.selected_row() {
|
||||
SettingsRow::ChangePassword => {
|
||||
self.mode = SettingsMode::NewPassword;
|
||||
// Defensive: a password buffer is never left holding anything.
|
||||
self.input.clear();
|
||||
}
|
||||
SettingsRow::Logout => self.mode = SettingsMode::ConfirmingLogout,
|
||||
SettingsRow::DownloadDir => {
|
||||
self.mode = SettingsMode::EditingDownloadDir;
|
||||
self.input.clone_from(&self.download_dir);
|
||||
}
|
||||
SettingsRow::Share(_) => {}
|
||||
}
|
||||
SettingsAction::None
|
||||
}
|
||||
|
||||
fn handle_logout_confirm(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
self.mode = SettingsMode::Navigate;
|
||||
if matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter) {
|
||||
return SettingsAction::Logout;
|
||||
}
|
||||
SettingsAction::None
|
||||
}
|
||||
|
||||
fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
@@ -103,25 +215,7 @@ impl SettingsState {
|
||||
self.input.clear();
|
||||
SettingsAction::None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let value = self.input.trim().to_string();
|
||||
let adding = self.mode == SettingsMode::AddingShare;
|
||||
self.mode = SettingsMode::Navigate;
|
||||
self.input.clear();
|
||||
if value.is_empty() {
|
||||
return SettingsAction::None;
|
||||
}
|
||||
if adding {
|
||||
if self.share_dirs.contains(&value) {
|
||||
return SettingsAction::None;
|
||||
}
|
||||
self.share_dirs.push(value);
|
||||
self.selected = self.rows() - 1;
|
||||
} else {
|
||||
self.download_dir = value;
|
||||
}
|
||||
SettingsAction::Apply
|
||||
}
|
||||
KeyCode::Enter => self.submit(),
|
||||
KeyCode::Backspace => {
|
||||
self.input.pop();
|
||||
SettingsAction::None
|
||||
@@ -133,6 +227,48 @@ impl SettingsState {
|
||||
_ => SettingsAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self) -> SettingsAction {
|
||||
if self.mode.is_secret() {
|
||||
return self.submit_password();
|
||||
}
|
||||
let value = self.input.trim().to_string();
|
||||
let adding = self.mode == SettingsMode::AddingShare;
|
||||
self.mode = SettingsMode::Navigate;
|
||||
self.input.clear();
|
||||
if value.is_empty() {
|
||||
return SettingsAction::None;
|
||||
}
|
||||
if adding {
|
||||
if self.share_dirs.contains(&value) {
|
||||
return SettingsAction::None;
|
||||
}
|
||||
self.share_dirs.push(value);
|
||||
self.selected = self.last_row();
|
||||
} else {
|
||||
self.download_dir = value;
|
||||
}
|
||||
SettingsAction::Apply
|
||||
}
|
||||
|
||||
/// The first entry is only remembered; the second is what commits.
|
||||
fn submit_password(&mut self) -> SettingsAction {
|
||||
let typed = std::mem::take(&mut self.input);
|
||||
let SettingsMode::RepeatPassword(first) =
|
||||
std::mem::replace(&mut self.mode, SettingsMode::Navigate)
|
||||
else {
|
||||
if !typed.is_empty() {
|
||||
self.mode = SettingsMode::RepeatPassword(typed);
|
||||
}
|
||||
return SettingsAction::None;
|
||||
};
|
||||
if typed != first {
|
||||
self.status =
|
||||
Some("The two passwords differ — nothing was changed".into());
|
||||
return SettingsAction::None;
|
||||
}
|
||||
SettingsAction::ChangePassword(typed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -150,8 +286,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn account() -> AccountInfo {
|
||||
AccountInfo {
|
||||
username: "alice".into(),
|
||||
shares: "12 files in 3 folders".into(),
|
||||
listen_port: Some(2234),
|
||||
daemon: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state() -> SettingsState {
|
||||
SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()])
|
||||
SettingsState::new(
|
||||
account(),
|
||||
"/dl".into(),
|
||||
vec!["/dl".into(), "/music".into()],
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the selection on a row by name, whatever index it has.
|
||||
fn select(state: &mut SettingsState, row: SettingsRow) {
|
||||
state.selected = state
|
||||
.rows()
|
||||
.iter()
|
||||
.position(|candidate| *candidate == row)
|
||||
.expect("the row is present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -172,6 +330,7 @@ mod tests {
|
||||
assert_eq!(action, SettingsAction::Apply);
|
||||
assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]);
|
||||
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||
assert_eq!(s.selected_row(), SettingsRow::Share(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -186,16 +345,16 @@ mod tests {
|
||||
#[test]
|
||||
fn deleting_the_selected_share_applies() {
|
||||
let mut s = state();
|
||||
s.handle_key(key(KeyCode::Down)); // select share 0
|
||||
s.handle_key(key(KeyCode::Down)); // select share 1 (/music)
|
||||
select(&mut s, SettingsRow::Share(1));
|
||||
let action = s.handle_key(key(KeyCode::Char('d')));
|
||||
assert_eq!(action, SettingsAction::Apply);
|
||||
assert_eq!(s.share_dirs, vec!["/dl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_on_the_download_dir_row_does_nothing() {
|
||||
fn delete_on_a_row_that_is_not_a_share_does_nothing() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::DownloadDir);
|
||||
assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None);
|
||||
assert_eq!(s.share_dirs.len(), 2);
|
||||
}
|
||||
@@ -203,6 +362,7 @@ mod tests {
|
||||
#[test]
|
||||
fn editing_the_download_dir_applies() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::DownloadDir);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
assert_eq!(s.mode, SettingsMode::EditingDownloadDir);
|
||||
assert_eq!(s.input, "/dl");
|
||||
@@ -217,6 +377,7 @@ mod tests {
|
||||
#[test]
|
||||
fn escape_cancels_an_edit_without_applying() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::DownloadDir);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
type_str(&mut s, "junk");
|
||||
assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None);
|
||||
@@ -238,10 +399,112 @@ mod tests {
|
||||
for _ in 0..10 {
|
||||
s.handle_key(key(KeyCode::Down));
|
||||
}
|
||||
assert_eq!(s.selected, 2);
|
||||
assert_eq!(s.selected_row(), SettingsRow::Share(1));
|
||||
for _ in 0..10 {
|
||||
s.handle_key(key(KeyCode::Up));
|
||||
}
|
||||
assert_eq!(s.selected, 0);
|
||||
assert_eq!(s.selected_row(), SettingsRow::ChangePassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_typed_twice_the_same_way_is_changed() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::ChangePassword);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
assert_eq!(s.mode, SettingsMode::NewPassword);
|
||||
type_str(&mut s, "hunter2");
|
||||
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||
assert!(matches!(s.mode, SettingsMode::RepeatPassword(_)));
|
||||
// The first entry is not left lying in the buffer the popup draws.
|
||||
assert!(s.input.is_empty());
|
||||
type_str(&mut s, "hunter2");
|
||||
assert_eq!(
|
||||
s.handle_key(key(KeyCode::Enter)),
|
||||
SettingsAction::ChangePassword("hunter2".into())
|
||||
);
|
||||
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mistyped_repeat_changes_nothing_and_says_so() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::ChangePassword);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
type_str(&mut s, "hunter2");
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
type_str(&mut s, "hunter3");
|
||||
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||
assert!(s.status.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_password_is_not_asked_for_twice() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::ChangePassword);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_during_the_repeat_forgets_the_first_entry() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::ChangePassword);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
type_str(&mut s, "hunter2");
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
s.handle_key(key(KeyCode::Esc));
|
||||
// Starting over asks for both entries again rather than matching the
|
||||
// abandoned one.
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
type_str(&mut s, "hunter2");
|
||||
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||
assert!(matches!(s.mode, SettingsMode::RepeatPassword(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_out_is_confirmed_first() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::Logout);
|
||||
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||
assert_eq!(s.mode, SettingsMode::ConfirmingLogout);
|
||||
assert_eq!(
|
||||
s.handle_key(key(KeyCode::Char('y'))),
|
||||
SettingsAction::Logout
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_but_yes_cancels_the_logout() {
|
||||
let mut s = state();
|
||||
select(&mut s, SettingsRow::Logout);
|
||||
s.handle_key(key(KeyCode::Enter));
|
||||
assert_eq!(s.handle_key(key(KeyCode::Char('n'))), SettingsAction::None);
|
||||
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemons_session_offers_no_logout_row() {
|
||||
let mut s = SettingsState::new(
|
||||
AccountInfo {
|
||||
daemon: Some("127.0.0.1:2245".into()),
|
||||
..account()
|
||||
},
|
||||
"/dl".into(),
|
||||
vec!["/music".into()],
|
||||
);
|
||||
assert_eq!(
|
||||
s.rows(),
|
||||
vec![
|
||||
SettingsRow::ChangePassword,
|
||||
SettingsRow::DownloadDir,
|
||||
SettingsRow::Share(0),
|
||||
]
|
||||
);
|
||||
// The row below "change password" is the download folder, not a
|
||||
// logout the window cannot perform.
|
||||
s.handle_key(key(KeyCode::Down));
|
||||
assert_eq!(s.selected_row(), SettingsRow::DownloadDir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user