bos-settings/src/ui/views/users.rs
Breadway d0fc16bc84 bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels
Continues reducing terminal-reliance for graphical system control. Five
new panels, plus three GUI apps shipped for things better served by an
existing dedicated tool than reimplemented in bos-settings
(gnome-disk-utility, gufw, mission-center).

- Power: battery status/health, brightness, charge-limit thresholds where
  the hardware exposes them, TLP profile shown read-only by design (no
  Balanced/Performance switcher — TLP auto-selects by power source, and
  power-profiles-daemon isn't installed because it conflicts with tlp).
- Firewall: ufw enable/disable, add/remove rules, view active rules.
  ufw's own status check requires root (confirmed against the installed
  script — not just changes, reads too), so unlike every other panel this
  one does NOT query state in build(): every view is constructed eagerly
  at app launch, and an unconditional privileged read here would mean a
  polkit prompt on every single bos-settings open. Starts blank with a
  "Status not loaded" placeholder; state loads only on an explicit Refresh
  click, with a guard so refresh's own set_active() doesn't loop back into
  triggering ufw enable/disable.
- Users: add/remove accounts, change passwords. All through pkexec on a
  background thread. Can't remove the account you're currently running as.
- AUR: search via yay. Installing deliberately opens a terminal instead of
  a silent --noconfirm install — yay's interactive PKGBUILD diff review and
  sudo prompt are the actual safety mechanism against a malicious AUR
  package, not a formality worth automating away.
- Firmware: fwupd device list + updates, same stream-output-then-refresh
  pattern as Packages.

packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/
task-manager GUIs that don't need reinventing inside bos-settings.
2026-07-04 11:04:16 +08:00

293 lines
11 KiB
Rust

//! User account management — add/remove users, change passwords. Everything
//! here needs root (useradd/userdel/chpasswd), so every action goes through
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
//! async_channel + glib::spawn_future_local handoff used elsewhere for
//! destructive/root actions (see snapshots.rs).
use gtk4::prelude::*;
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::ui::widgets as w;
#[derive(Clone)]
struct Account {
username: String,
full_name: String,
}
fn list_accounts() -> Vec<Account> {
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split(':').collect();
if f.len() < 7 {
return None;
}
let uid: u32 = f[2].parse().ok()?;
let shell = f[6];
// Real human accounts: normal UID range, a real login shell (not
// nologin/false — excludes system/service accounts like
// greeter, avahi, etc).
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
return None;
}
Some(Account {
username: f[0].to_string(),
full_name: f[4].split(',').next().unwrap_or("").to_string(),
})
})
.collect()
}
fn current_user() -> String {
std::env::var("USER").unwrap_or_default()
}
/// Run a root command that needs a line of input on stdin (chpasswd's own
/// "user:password" format) on a background thread, reporting success back
/// via a channel. `pkexec` inherits the spawning process's stdin only when
/// explicitly piped, so this pipes it through rather than relying on that.
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = (|| -> std::io::Result<bool> {
let mut child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
Ok(child.wait()?.success())
})()
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
on_done(ok);
});
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let me = current_user();
for acc in list_accounts() {
let row = ListBoxRow::new();
row.set_selectable(false);
let card = GBox::new(Orientation::Vertical, 6);
card.add_css_class("card");
card.set_margin_top(3);
card.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let label_text = if acc.full_name.is_empty() {
acc.username.clone()
} else {
format!("{} ({})", acc.username, acc.full_name)
};
let name_lbl = Label::new(Some(&label_text));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let change_pw_btn = Button::with_label("Change password");
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
// Don't let the panel remove the account it's currently running as
// — that's a self-lockout, not a normal account-management action.
remove_btn.set_sensitive(acc.username != me);
top.append(&name_lbl);
top.append(&change_pw_btn);
top.append(&remove_btn);
card.append(&top);
// Inline password row, hidden until "Change password" is clicked —
// same pattern as Network's secured-connection password prompt.
let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new();
pw_entry.set_visibility(false);
pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("New password"));
let pw_apply_btn = Button::with_label("Apply");
pw_row.append(&pw_entry);
pw_row.append(&pw_apply_btn);
pw_row.set_visible(false);
card.append(&pw_row);
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
card.append(&status);
{
let pw_row = pw_row.clone();
change_pw_btn.connect_clicked(move |_| {
pw_row.set_visible(!pw_row.is_visible());
});
}
{
let username = acc.username.clone();
let pw_entry = pw_entry.clone();
let pw_row = pw_row.clone();
let status = status.clone();
pw_apply_btn.connect_clicked(move |_| {
let password = pw_entry.text().to_string();
if password.is_empty() {
return;
}
let input = format!("{username}:{password}\n");
let status2 = status.clone();
let pw_entry2 = pw_entry.clone();
let pw_row2 = pw_row.clone();
status.set_text("Applying…");
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
status2.set_text("Password changed");
pw_entry2.set_text("");
pw_row2.set_visible(false);
} else {
status2.set_text("Failed to change password");
}
});
});
}
{
let list = list.clone();
let username = acc.username.clone();
remove_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Remove user {username}?"))
.detail("Deletes the account and its home directory. This cannot be undone.")
.buttons(["Cancel", "Remove"])
.cancel_button(0)
.default_button(0)
.build();
let list2 = list.clone();
let username2 = username.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list3 = list2.clone();
w::stream_command_then(
&["pkexec", "userdel", "-r", &username2],
log_buf,
move || populate(&list3),
);
});
});
}
row.set_child(Some(&card));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Users");
content.append(&w::hint(
"Real login accounts on this machine (system/service accounts \
aren't shown). Your own account can't be removed from here.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
content.append(&w::section("Add user"));
let username_entry = Entry::new();
username_entry.set_placeholder_text(Some("username"));
content.append(&w::row("Username", &username_entry));
let fullname_entry = Entry::new();
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
content.append(&w::row("Full name", &fullname_entry));
let password_entry = Entry::new();
password_entry.set_visibility(false);
password_entry.set_placeholder_text(Some("password"));
content.append(&w::row("Password", &password_entry));
let add_status = Label::new(None);
add_status.add_css_class("dim-label");
add_status.set_xalign(0.0);
let add_btn = Button::with_label("Add user");
add_btn.add_css_class("suggested-action");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let list = list.clone();
let username_entry = username_entry.clone();
let fullname_entry = fullname_entry.clone();
let password_entry = password_entry.clone();
let add_status = add_status.clone();
add_btn.connect_clicked(move |_| {
let username = username_entry.text().to_string();
let full_name = fullname_entry.text().to_string();
let password = password_entry.text().to_string();
if username.trim().is_empty() || password.is_empty() {
add_status.set_text("Username and password are required.");
return;
}
add_status.set_text("Adding…");
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
if !full_name.trim().is_empty() {
useradd_args.push("-c");
useradd_args.push(full_name.trim());
}
useradd_args.push(username.trim());
let list2 = list.clone();
let username2 = username.trim().to_string();
let password2 = password.clone();
let add_status2 = add_status.clone();
let username_entry2 = username_entry.clone();
let fullname_entry2 = fullname_entry.clone();
let password_entry2 = password_entry.clone();
let log_buf = gtk4::TextBuffer::new(None);
w::stream_command_then(&useradd_args, log_buf, move || {
let input = format!("{username2}:{password2}\n");
let list3 = list2.clone();
let add_status3 = add_status2.clone();
let username_entry3 = username_entry2.clone();
let fullname_entry3 = fullname_entry2.clone();
let password_entry3 = password_entry2.clone();
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
add_status3.set_text("User added.");
username_entry3.set_text("");
fullname_entry3.set_text("");
password_entry3.set_text("");
populate(&list3);
} else {
add_status3.set_text("User created, but setting the password failed.");
populate(&list3);
}
});
});
});
}
content.append(&add_btn);
content.append(&add_status);
outer
}