git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

commita694f1c89ae7a78d9a2f73cf952b4827d3294b74
parentff73404883
authorLucas Galante <[email protected]>
date2026-07-27 12:36
feat: account passwords go to the Secret Service keyring + save-while-editing fix

- AddAccountSave writes the password to the same keyring entry cce-email
  resolves (service "cce-email", account = address) and stores a blank
  password field in accounts.json; plaintext-on-disk remains only as the
  fallback when no keyring answers (cce-email migrates it later). Status
  message says which happened. DeleteAccount removes the keyring entry
  alongside the file row and mail cache.
- live_text(): save handlers read the in-progress edit buffer of a
  still-focused TextBox — reading .text alone dropped whatever was typed
  into the last field, so Save always failed with 'All fields must be
  filled!' unless the user clicked elsewhere first. Applies to the
  account form and the Google API creds form.

Live-verified: added a throwaway account (password landed in KeePassXC,
file field blank, "password in keyring" status), deleted it (keyring
entry gone, gmail entry untouched).

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml            |  1 +
 src/pages/accounts.rs | 53 ++++++++++++++++++++++++++++++++++++++++++---------
 2 files changed, 45 insertions(+), 9 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 7f0a033..8db6b74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
+keyring = { version = "3", features = ["sync-secret-service"] }
 cce-ui = { path = "../cce-ui" }
 smithay-client-toolkit = "0.19.2"
 calloop = "0.13.0"
diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs
index 7e24bfc..e63eb2f 100644
--- a/src/pages/accounts.rs
+++ b/src/pages/accounts.rs
@@ -648,6 +648,19 @@ pub fn view(state: &mut AccountsState, cx: f32, cy: f32, cw: f32, ch: f32, layou
     final_pc
 }
 
+/// A TextBox's live contents: the in-progress edit buffer while the box is
+/// still focused, the committed text otherwise. Reading `.text` alone drops
+/// whatever was typed into the last-focused field (its buffer only commits on
+/// FocusOut), which made Save fail with "All fields must be filled!" unless
+/// the user happened to click elsewhere first.
+fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
+    if tb.editing {
+        tb.edit_buffer.trim().to_string()
+    } else {
+        tb.text.trim().to_string()
+    }
+}
+
 pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
     match msg {
         AccountsMessage::Refreshed(accs) => {
@@ -683,22 +696,37 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             state.selected_idx = if state.accounts.is_empty() { None } else { Some(0) };
         }
         AccountsMessage::AddAccountSave => {
-            let email = state.email_box.text.trim().to_string();
-            let password = state.password_box.text.trim().to_string();
-            let imap = state.imap_box.text.trim().to_string();
-            let smtp = state.smtp_box.text.trim().to_string();
+            let email = live_text(&state.email_box);
+            let password = live_text(&state.password_box);
+            let imap = live_text(&state.imap_box);
+            let smtp = live_text(&state.smtp_box);
 
             if email.is_empty() || password.is_empty() || imap.is_empty() || smtp.is_empty() {
                 state.status_msg = Some("All fields must be filled!".to_string());
                 return;
             }
 
+            // The password goes to the Secret Service under the SAME entry
+            // cce-email resolves (service "cce-email", account = address) and
+            // the on-disk field stays blank; plaintext-on-disk only as the
+            // fallback when no keyring answers (cce-email migrates it later).
+            let mut stored_password = password.clone();
+            let mut in_keyring = false;
+            if password != "mock_password" {
+                if let Ok(entry) = keyring::Entry::new("cce-email", &email) {
+                    if entry.set_password(&password).is_ok() {
+                        stored_password = String::new();
+                        in_keyring = true;
+                    }
+                }
+            }
+
             let new_acc = AccountInfo {
                 email: email.clone(),
                 imap,
                 smtp,
                 is_default: state.accounts.is_empty(),
-                password,
+                password: stored_password,
                 is_oauth: false,
                 access_token: None,
                 refresh_token: None,
@@ -716,7 +744,11 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             save_accounts(&state.accounts);
             state.adding_new = false;
             state.selected_idx = state.accounts.iter().position(|a| a.email == email);
-            state.status_msg = Some("Account saved successfully!".to_string());
+            state.status_msg = Some(if in_keyring {
+                "Account saved (password in keyring)".to_string()
+            } else {
+                "Account saved (keyring unavailable — password stored in file)".to_string()
+            });
         }
         AccountsMessage::DeleteAccount(idx) => {
             if idx < state.accounts.len() {
@@ -725,7 +757,10 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
                     state.accounts[0].is_default = true;
                 }
                 save_accounts(&state.accounts);
-                // Drop cce-email's cached mail for the account too.
+                // Drop the keyring password and cce-email's cached mail too.
+                if let Ok(entry) = keyring::Entry::new("cce-email", &deleted.email) {
+                    let _ = entry.delete_credential();
+                }
                 let safe_email = deleted.email.replace('@', "_").replace('.', "_");
                 let cache = cce_ui::config::cce_config_dir().join(format!("emails_{}.json", safe_email));
                 let _ = std::fs::remove_file(cache);
@@ -776,8 +811,8 @@ pub fn update(state: &mut AccountsState, msg: AccountsMessage) {
             state.oauth_client_secret_box.edit_buffer = config.client_secret;
         }
         AccountsMessage::EditOAuthCredsSave => {
-            let client_id = state.oauth_client_id_box.text.trim().to_string();
-            let client_secret = state.oauth_client_secret_box.text.trim().to_string();
+            let client_id = live_text(&state.oauth_client_id_box);
+            let client_secret = live_text(&state.oauth_client_secret_box);
             if client_id.is_empty() || client_secret.is_empty() {
                 state.status_msg = Some("Both Client ID and Client Secret are required!".to_string());
                 return;