feat: advance login workflow and integration gates
This commit is contained in:
@@ -19,6 +19,8 @@ use ratatui::{
|
||||
const MAX_IMAGE_SIDE: u32 = 480;
|
||||
const MAX_SOURCE_SIDE: u32 = 8_192;
|
||||
const MAX_SOURCE_PIXELS: u64 = 16_777_216;
|
||||
const MAX_ANIMATION_WIDTH: usize = 160;
|
||||
const MAX_ANIMATION_HEIGHT: usize = 64;
|
||||
const FRAME_INTERVAL: Duration = Duration::from_millis(120);
|
||||
|
||||
/// Background state owned by the TUI thread.
|
||||
@@ -189,6 +191,10 @@ struct Automata {
|
||||
|
||||
impl Automata {
|
||||
fn resize(&mut self, width: usize, height: usize) {
|
||||
// Keep procedural state bounded even when a terminal reports an
|
||||
// unusually large window. Rendering remains clipped to this layer.
|
||||
let width = width.min(MAX_ANIMATION_WIDTH);
|
||||
let height = height.min(MAX_ANIMATION_HEIGHT);
|
||||
if self.width == width && self.height == height {
|
||||
return;
|
||||
}
|
||||
@@ -221,11 +227,11 @@ impl Automata {
|
||||
let mut count = 0;
|
||||
for dy in [self.height.wrapping_sub(1), 0, 1] {
|
||||
for dx in [self.width.wrapping_sub(1), 0, 1] {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let nx = (x + dx) % self.width;
|
||||
let ny = (y + dy) % self.height;
|
||||
if nx == x && ny == y {
|
||||
continue;
|
||||
}
|
||||
count += self.current[ny * self.width + nx];
|
||||
}
|
||||
}
|
||||
@@ -264,4 +270,16 @@ mod tests {
|
||||
automata.tick();
|
||||
assert_eq!(automata.current.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automata_caps_untrusted_terminal_dimensions() {
|
||||
let mut automata = Automata::default();
|
||||
automata.resize(usize::MAX, usize::MAX);
|
||||
assert_eq!(automata.width, MAX_ANIMATION_WIDTH);
|
||||
assert_eq!(automata.height, MAX_ANIMATION_HEIGHT);
|
||||
assert_eq!(
|
||||
automata.current.len(),
|
||||
MAX_ANIMATION_WIDTH * MAX_ANIMATION_HEIGHT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use zeroize::Zeroizing;
|
||||
|
||||
use latchd_auth::{AuthService, AuthTicket};
|
||||
use latchd_core::{
|
||||
accounts::{self, FaillockStatus, Identity},
|
||||
accounts::{self, AccountState, AccountStatus, FaillockStatus, Identity},
|
||||
config::Config,
|
||||
error::{Error, ResultExt},
|
||||
session::{self, SessionDescriptor},
|
||||
@@ -30,6 +30,16 @@ const MIN_WIDTH: u16 = 60;
|
||||
const MIN_HEIGHT: u16 = 16;
|
||||
const MAX_INPUT: usize = 256;
|
||||
|
||||
fn unknown_account_status() -> AccountStatus {
|
||||
AccountStatus {
|
||||
state: AccountState::Unknown,
|
||||
password_changed_day: None,
|
||||
password_expiry_day: None,
|
||||
account_expiry_day: None,
|
||||
inactive_after_day: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of one completed login-screen interaction.
|
||||
pub struct LoginSelection {
|
||||
/// Authenticated PAM ticket.
|
||||
@@ -45,6 +55,20 @@ enum Focus {
|
||||
Password,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FilterTarget {
|
||||
Sessions,
|
||||
Accounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FilterState {
|
||||
target: FilterTarget,
|
||||
query: String,
|
||||
}
|
||||
|
||||
type DiscoverySnapshot = (Vec<SessionDescriptor>, Vec<Identity>, Vec<AccountStatus>);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AuthState {
|
||||
Idle,
|
||||
@@ -71,6 +95,7 @@ struct App {
|
||||
preview: bool,
|
||||
sessions: Vec<SessionDescriptor>,
|
||||
accounts: Vec<Identity>,
|
||||
account_statuses: Vec<AccountStatus>,
|
||||
account_index: Option<usize>,
|
||||
faillock_receiver: Option<Receiver<Result<FaillockStatus, Error>>>,
|
||||
faillock_status: Option<FaillockStatus>,
|
||||
@@ -80,6 +105,8 @@ struct App {
|
||||
password: Zeroizing<String>,
|
||||
status: String,
|
||||
auth_state: AuthState,
|
||||
auth_failures: u32,
|
||||
auth_blocked_until: Option<Instant>,
|
||||
show_help: bool,
|
||||
power_confirmation: Option<usize>,
|
||||
should_quit: bool,
|
||||
@@ -89,6 +116,10 @@ struct App {
|
||||
notice: Option<Notice>,
|
||||
animation_frame: usize,
|
||||
reduced_motion: bool,
|
||||
filter: Option<FilterState>,
|
||||
refresh_receiver: Option<Receiver<DiscoverySnapshot>>,
|
||||
refreshing: bool,
|
||||
high_contrast: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -111,6 +142,7 @@ impl App {
|
||||
auth,
|
||||
preview,
|
||||
sessions,
|
||||
account_statuses: vec![unknown_account_status(); accounts.len()],
|
||||
accounts,
|
||||
account_index,
|
||||
faillock_receiver: None,
|
||||
@@ -121,6 +153,8 @@ impl App {
|
||||
password: Zeroizing::new(String::new()),
|
||||
status: "Enter your username".into(),
|
||||
auth_state: AuthState::Idle,
|
||||
auth_failures: 0,
|
||||
auth_blocked_until: None,
|
||||
show_help: false,
|
||||
power_confirmation: None,
|
||||
should_quit: false,
|
||||
@@ -130,12 +164,17 @@ impl App {
|
||||
notice: None,
|
||||
animation_frame: 0,
|
||||
reduced_motion,
|
||||
filter: None,
|
||||
refresh_receiver: None,
|
||||
refreshing: false,
|
||||
high_contrast: std::env::var_os("LATCHD_HIGH_CONTRAST").is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<Option<LoginSelection>, Error> {
|
||||
loop {
|
||||
self.poll_faillock();
|
||||
self.poll_refresh();
|
||||
let now = Instant::now();
|
||||
if self.background.tick_if_due(now) {
|
||||
self.animation_frame = self.animation_frame.wrapping_add(1);
|
||||
@@ -153,6 +192,8 @@ impl App {
|
||||
if let Some(result) = self.poll_auth() {
|
||||
match result {
|
||||
Ok(ticket) => {
|
||||
self.auth_failures = 0;
|
||||
self.auth_blocked_until = None;
|
||||
let selected = self
|
||||
.sessions
|
||||
.get(self.selected)
|
||||
@@ -167,6 +208,9 @@ impl App {
|
||||
tracing::warn!(%error, "authentication attempt failed");
|
||||
self.auth_state = AuthState::Failed;
|
||||
self.password.clear();
|
||||
self.auth_failures = self.auth_failures.saturating_add(1);
|
||||
self.auth_blocked_until =
|
||||
Some(Instant::now() + auth_backoff(self.auth_failures));
|
||||
self.status = "Authentication failed".into();
|
||||
self.notify("Authentication failed", NoticeLevel::Warning);
|
||||
self.start_faillock_query();
|
||||
@@ -230,6 +274,98 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_refresh(&mut self) {
|
||||
let Some(receiver) = &self.refresh_receiver else {
|
||||
return;
|
||||
};
|
||||
match receiver.try_recv() {
|
||||
Ok((sessions, accounts, account_statuses)) => {
|
||||
self.sessions = sessions;
|
||||
self.accounts = accounts;
|
||||
self.account_statuses = account_statuses;
|
||||
self.refresh_receiver = None;
|
||||
self.refreshing = false;
|
||||
self.reconcile_selection();
|
||||
self.status = format!(
|
||||
"Discovery refreshed: {} session(s), {} account(s)",
|
||||
self.sessions.len(),
|
||||
self.accounts.len()
|
||||
);
|
||||
self.notify("Account and session discovery refreshed", NoticeLevel::Info);
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.refresh_receiver = None;
|
||||
self.refreshing = false;
|
||||
self.status = "Discovery refresh failed".into();
|
||||
self.notify(
|
||||
"Could not refresh account and session discovery",
|
||||
NoticeLevel::Warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_refresh(&mut self) {
|
||||
if self.refreshing {
|
||||
self.status = "Discovery refresh is already running".into();
|
||||
return;
|
||||
}
|
||||
let config = self.config.clone();
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
let task = thread::Builder::new()
|
||||
.name("latchd-discovery".into())
|
||||
.spawn(move || {
|
||||
let sessions = session::discover(&config);
|
||||
let (accounts, account_statuses) = if config.accounts.enumerate {
|
||||
let records = accounts::discover_with_status(&config.accounts);
|
||||
records
|
||||
.into_iter()
|
||||
.map(|record| (record.identity, record.status))
|
||||
.unzip()
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let _ = sender.send((sessions, accounts, account_statuses));
|
||||
});
|
||||
match task {
|
||||
Ok(_) => {
|
||||
self.refresh_receiver = Some(receiver);
|
||||
self.refreshing = true;
|
||||
self.status = "Refreshing accounts and sessions…".into();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "could not start discovery refresh");
|
||||
self.status = format!("Could not start discovery refresh: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_selection(&mut self) {
|
||||
if self.sessions.is_empty() {
|
||||
self.selected = 0;
|
||||
} else if self.selected >= self.sessions.len() {
|
||||
self.selected = self.sessions.len() - 1;
|
||||
}
|
||||
if self.accounts.is_empty() {
|
||||
self.account_index = None;
|
||||
self.account_statuses.clear();
|
||||
} else if self.account_statuses.len() != self.accounts.len() {
|
||||
self.account_statuses
|
||||
.resize(self.accounts.len(), unknown_account_status());
|
||||
} else if self
|
||||
.account_index
|
||||
.is_some_and(|index| index >= self.accounts.len())
|
||||
{
|
||||
self.account_index = Some(0);
|
||||
}
|
||||
if let Some(index) = self.account_index {
|
||||
if let Some(account) = self.accounts.get(index) {
|
||||
self.username = account.username.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_faillock_query(&mut self) {
|
||||
let username = self.username.clone();
|
||||
if username.is_empty() {
|
||||
@@ -262,6 +398,149 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_filter(&mut self) {
|
||||
let target = match self.focus {
|
||||
Focus::Sessions => FilterTarget::Sessions,
|
||||
Focus::Username => FilterTarget::Accounts,
|
||||
Focus::Password => return,
|
||||
};
|
||||
self.filter = Some(FilterState {
|
||||
target,
|
||||
query: String::new(),
|
||||
});
|
||||
self.status = match target {
|
||||
FilterTarget::Sessions => {
|
||||
"Filter sessions: type text, Enter selects, Esc clears".into()
|
||||
}
|
||||
FilterTarget::Accounts => {
|
||||
"Filter accounts: type text, Enter selects, Esc clears".into()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn filter_key(&mut self, key: KeyEvent) {
|
||||
let Some(target) = self.filter.as_ref().map(|filter| filter.target) else {
|
||||
return;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.filter = None;
|
||||
self.status = "Filter cleared".into();
|
||||
}
|
||||
KeyCode::Enter => self.accept_filter(),
|
||||
KeyCode::Backspace => {
|
||||
if let Some(filter) = &mut self.filter {
|
||||
filter.query.pop();
|
||||
}
|
||||
self.update_filter_status();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Left => match target {
|
||||
FilterTarget::Sessions => self.previous_session(),
|
||||
FilterTarget::Accounts => self.previous_account(),
|
||||
},
|
||||
KeyCode::Down | KeyCode::Right => match target {
|
||||
FilterTarget::Sessions => self.next_session(),
|
||||
FilterTarget::Accounts => self.next_account(),
|
||||
},
|
||||
KeyCode::Char(character)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||
&& self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_some_and(|filter| filter.query.chars().count() < MAX_INPUT) =>
|
||||
{
|
||||
if let Some(filter) = &mut self.filter {
|
||||
filter.query.push(character);
|
||||
}
|
||||
self.update_filter_status();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_filter_status(&mut self) {
|
||||
let Some(filter) = &self.filter else {
|
||||
return;
|
||||
};
|
||||
let count = match filter.target {
|
||||
FilterTarget::Sessions => self.matching_session_indices().len(),
|
||||
FilterTarget::Accounts => self.matching_account_indices().len(),
|
||||
};
|
||||
self.status = format!(
|
||||
"Filter {}: {} match(es) — Enter selects, Esc clears",
|
||||
match filter.target {
|
||||
FilterTarget::Sessions => "sessions",
|
||||
FilterTarget::Accounts => "accounts",
|
||||
},
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
fn accept_filter(&mut self) {
|
||||
let Some(filter) = self.filter.take() else {
|
||||
return;
|
||||
};
|
||||
match filter.target {
|
||||
FilterTarget::Sessions => {
|
||||
let index = self
|
||||
.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, session)| matches_filter(&session.title, &filter.query))
|
||||
.map(|(index, _)| index);
|
||||
if let Some(index) = index {
|
||||
self.selected = index;
|
||||
self.status = format!("Selected session {}", self.sessions[index].title);
|
||||
} else {
|
||||
self.status = "No matching sessions".into();
|
||||
}
|
||||
}
|
||||
FilterTarget::Accounts => {
|
||||
let index = self
|
||||
.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, account)| matches_filter(&account.username, &filter.query))
|
||||
.map(|(index, _)| index);
|
||||
if let Some(index) = index {
|
||||
self.select_account_index(index);
|
||||
} else {
|
||||
self.status = "No matching accounts".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn matching_session_indices(&self) -> Vec<usize> {
|
||||
let query = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.filter(|filter| filter.target == FilterTarget::Sessions)
|
||||
.map_or("", |filter| filter.query.as_str());
|
||||
self.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, session)| matches_filter(&session.title, query))
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matching_account_indices(&self) -> Vec<usize> {
|
||||
let query = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.filter(|filter| filter.target == FilterTarget::Accounts)
|
||||
.map_or("", |filter| filter.query.as_str());
|
||||
self.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, account)| matches_filter(&account.username, query))
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
self.should_quit = true;
|
||||
@@ -279,6 +558,10 @@ impl App {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.filter.is_some() {
|
||||
self.filter_key(key);
|
||||
return;
|
||||
}
|
||||
if self.power_confirmation.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
match key.code {
|
||||
KeyCode::Char('n') => {
|
||||
@@ -314,6 +597,10 @@ impl App {
|
||||
);
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
self.start_refresh();
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -328,6 +615,7 @@ impl App {
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('?') => self.show_help = true,
|
||||
KeyCode::Char('/') => self.begin_filter(),
|
||||
KeyCode::Esc => {
|
||||
self.password.clear();
|
||||
self.status = "Login cancelled".into();
|
||||
@@ -409,6 +697,16 @@ impl App {
|
||||
self.status = "Username and password are required".into();
|
||||
return;
|
||||
}
|
||||
if let Some(until) = self.auth_blocked_until {
|
||||
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
|
||||
self.status = format!(
|
||||
"Authentication paused after failed attempts; try again in {}s",
|
||||
remaining.as_secs().saturating_add(1)
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.auth_blocked_until = None;
|
||||
}
|
||||
let username = self.username.clone();
|
||||
let password = self.password.as_bytes().to_vec();
|
||||
let service = self.config.pam_service.clone();
|
||||
@@ -483,17 +781,23 @@ impl App {
|
||||
}
|
||||
|
||||
fn next_session(&mut self) {
|
||||
if !self.sessions.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.sessions.len();
|
||||
let indices = self.matching_session_indices();
|
||||
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
|
||||
self.selected = indices[(position + 1) % indices.len()];
|
||||
} else if let Some(&index) = indices.first() {
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_session(&mut self) {
|
||||
if !self.sessions.is_empty() {
|
||||
self.selected = self
|
||||
.selected
|
||||
.checked_sub(1)
|
||||
.unwrap_or(self.sessions.len() - 1);
|
||||
let indices = self.matching_session_indices();
|
||||
if let Some(position) = indices.iter().position(|&index| index == self.selected) {
|
||||
self.selected = indices
|
||||
.get(position.checked_sub(1).unwrap_or(indices.len() - 1))
|
||||
.copied()
|
||||
.unwrap_or(self.selected);
|
||||
} else if let Some(&index) = indices.first() {
|
||||
self.selected = index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,20 +810,36 @@ impl App {
|
||||
}
|
||||
|
||||
fn select_account(&mut self, delta: isize) {
|
||||
if self.accounts.is_empty() {
|
||||
let indices = self.matching_account_indices();
|
||||
if indices.is_empty() {
|
||||
self.notify(
|
||||
"No eligible accounts discovered; enter a username manually",
|
||||
NoticeLevel::Info,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let current = self.account_index.unwrap_or(0) as isize;
|
||||
let length = self.accounts.len() as isize;
|
||||
let index = (current + delta).rem_euclid(length) as usize;
|
||||
let current = self
|
||||
.account_index
|
||||
.and_then(|index| indices.iter().position(|&candidate| candidate == index))
|
||||
.unwrap_or(0) as isize;
|
||||
let index = indices[(current + delta).rem_euclid(indices.len() as isize) as usize];
|
||||
self.select_account_index(index);
|
||||
}
|
||||
|
||||
fn select_account_index(&mut self, index: usize) {
|
||||
let Some(username) = self
|
||||
.accounts
|
||||
.get(index)
|
||||
.map(|account| account.username.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.account_index = Some(index);
|
||||
self.username = self.accounts[index].username.clone();
|
||||
self.username = username;
|
||||
self.password.clear();
|
||||
self.auth_state = AuthState::Idle;
|
||||
self.auth_failures = 0;
|
||||
self.auth_blocked_until = None;
|
||||
self.faillock_status = None;
|
||||
self.status = format!("Selected account {}", self.username);
|
||||
self.start_faillock_query();
|
||||
@@ -576,10 +896,10 @@ impl App {
|
||||
} else {
|
||||
vertical[1]
|
||||
};
|
||||
let session_items: Vec<ListItem> = self
|
||||
.sessions
|
||||
let session_indices = self.matching_session_indices();
|
||||
let session_items: Vec<ListItem> = session_indices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|&index| self.sessions.get(index).map(|session| (index, session)))
|
||||
.map(|(index, session)| {
|
||||
let marker = if index == self.selected {
|
||||
if self.ascii { ">" } else { "▶" }
|
||||
@@ -600,8 +920,22 @@ impl App {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sessions =
|
||||
List::new(session_items).block(self.block("Session", self.focus == Focus::Sessions));
|
||||
let session_items = if session_items.is_empty() {
|
||||
vec![ListItem::new(" No matching sessions").style(self.style_muted())]
|
||||
} else {
|
||||
session_items
|
||||
};
|
||||
let session_title = if self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_some_and(|filter| filter.target == FilterTarget::Sessions)
|
||||
{
|
||||
format!("Session {}/{}", session_indices.len(), self.sessions.len())
|
||||
} else {
|
||||
"Session".into()
|
||||
};
|
||||
let sessions = List::new(session_items)
|
||||
.block(self.block(&session_title, self.focus == Focus::Sessions));
|
||||
frame.render_widget(sessions, session_area);
|
||||
|
||||
let fields = Layout::default()
|
||||
@@ -622,26 +956,97 @@ impl App {
|
||||
frame.render_widget(password, fields[1]);
|
||||
let status = self.status_line();
|
||||
frame.render_widget(Paragraph::new(status).wrap(Wrap { trim: true }), fields[2]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Tab focus ←/→ session Ctrl-N/P account Ctrl-B effects Ctrl-M motion ? help",
|
||||
),
|
||||
vertical[2],
|
||||
);
|
||||
frame.render_widget(Paragraph::new(self.footer_hint()), vertical[2]);
|
||||
frame.render_widget(
|
||||
Paragraph::new("PAM authentication • session output stays off the login screen"),
|
||||
vertical[3],
|
||||
);
|
||||
if self.show_help {
|
||||
self.draw_help(frame, area);
|
||||
} else if self.filter.is_some() {
|
||||
self.draw_filter(frame, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_hint(&self) -> String {
|
||||
let mut hint = match self.focus {
|
||||
Focus::Sessions => "Tab focus ←/→ choose / filter Ctrl-R refresh ? help",
|
||||
Focus::Username => "Tab focus / filter Ctrl-N/P switch Ctrl-R refresh ? help",
|
||||
Focus::Password => "Tab focus Enter login Ctrl-M motion ? help",
|
||||
}
|
||||
.to_owned();
|
||||
if self.no_color {
|
||||
hint.push_str(" [monochrome]");
|
||||
} else if self.high_contrast {
|
||||
hint.push_str(" [high contrast]");
|
||||
}
|
||||
if self.reduced_motion {
|
||||
hint.push_str(" [reduced motion]");
|
||||
}
|
||||
hint
|
||||
}
|
||||
|
||||
fn draw_filter(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let Some(filter) = &self.filter else {
|
||||
return;
|
||||
};
|
||||
let popup = centered_rect(72, 52, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
let (label, values): (&str, Vec<String>) = match filter.target {
|
||||
FilterTarget::Sessions => (
|
||||
"Sessions",
|
||||
self.matching_session_indices()
|
||||
.iter()
|
||||
.filter_map(|&index| self.sessions.get(index))
|
||||
.map(|session| session.title.clone())
|
||||
.collect(),
|
||||
),
|
||||
FilterTarget::Accounts => (
|
||||
"Accounts",
|
||||
self.matching_account_indices()
|
||||
.iter()
|
||||
.filter_map(|&index| self.accounts.get(index))
|
||||
.map(|account| account.username.clone())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let mut lines = vec![
|
||||
Line::from(format!("{label} /{}", filter.query)),
|
||||
Line::raw(""),
|
||||
];
|
||||
if values.is_empty() {
|
||||
lines.push(Line::from("No matches"));
|
||||
} else {
|
||||
for value in values.iter().take(8) {
|
||||
lines.push(Line::from(format!(
|
||||
" {}",
|
||||
truncate_cells(value, popup.width.saturating_sub(5) as usize)
|
||||
)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::raw(""));
|
||||
lines.push(Line::from(
|
||||
"Type to filter • ↑/↓ choose • Enter accept • Esc clear",
|
||||
));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(self.block("Filter", true))
|
||||
.wrap(Wrap { trim: true }),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_help(&self, frame: &mut ratatui::Frame, area: Rect) {
|
||||
let popup = centered_rect(70, 50, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
frame.render_widget(Paragraph::new("Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\nCtrl-N / Ctrl-P switch account\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate\nEsc clear password\nCtrl-C quit cleanly\n? close help")
|
||||
.block(self.block("Help", false)).wrap(Wrap { trim: true }), popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Keyboard help\n\nTab / Shift-Tab move focus\nArrow keys choose session\n/ filter sessions or accounts\nCtrl-N / Ctrl-P switch account\nCtrl-R refresh discovery\nCtrl-B toggle background\nCtrl-M toggle reduced motion\nEnter authenticate / accept filter\nEsc clear password / close popup\nCtrl-C quit cleanly\n? close help",
|
||||
)
|
||||
.block(self.block("Help", false))
|
||||
.wrap(Wrap { trim: true }),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
|
||||
fn block(&self, title: &str, focused: bool) -> Block<'static> {
|
||||
@@ -680,6 +1085,14 @@ impl App {
|
||||
};
|
||||
status = format!("{spinner} Authenticating…");
|
||||
}
|
||||
if let Some(until) = self.auth_blocked_until {
|
||||
if let Some(remaining) = until.checked_duration_since(Instant::now()) {
|
||||
status.push_str(&format!(
|
||||
" • retry cooldown {}s",
|
||||
remaining.as_secs().saturating_add(1)
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lockout) = self.faillock_status {
|
||||
if lockout.locked_out {
|
||||
status
|
||||
@@ -694,6 +1107,21 @@ impl App {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(index) = self.account_index {
|
||||
if let Some(account) = self.account_statuses.get(index) {
|
||||
let policy = match account.state {
|
||||
AccountState::Active | AccountState::Unknown => None,
|
||||
AccountState::Locked => Some("account password is locked"),
|
||||
AccountState::PasswordExpired => Some("password must be changed"),
|
||||
AccountState::AccountExpired => Some("account has expired"),
|
||||
AccountState::AccountInactive => Some("account is inactive"),
|
||||
AccountState::NoLoginShell => Some("account has no login shell"),
|
||||
};
|
||||
if let Some(policy) = policy {
|
||||
status.push_str(&format!(" • {policy}; PAM remains authoritative"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(notice) = &self.notice {
|
||||
let marker = match notice.level {
|
||||
NoticeLevel::Info => {
|
||||
@@ -713,12 +1141,20 @@ impl App {
|
||||
};
|
||||
status.push_str(&format!(" {marker} {}", notice.message));
|
||||
}
|
||||
if self.refreshing {
|
||||
status.push_str(" • refreshing discovery");
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn style_accent(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else if self.high_contrast {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::White)
|
||||
.bg(ratatui::prelude::Color::Blue)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Cyan)
|
||||
@@ -728,6 +1164,11 @@ impl App {
|
||||
fn style_selected(&self) -> Style {
|
||||
if self.no_color {
|
||||
Style::default().add_modifier(ratatui::prelude::Modifier::REVERSED)
|
||||
} else if self.high_contrast {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Black)
|
||||
.bg(ratatui::prelude::Color::White)
|
||||
.add_modifier(ratatui::prelude::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(ratatui::prelude::Color::Yellow)
|
||||
@@ -746,13 +1187,18 @@ pub fn run(
|
||||
preview: bool,
|
||||
) -> Result<Option<LoginSelection>, Error> {
|
||||
let sessions = session::discover(&config);
|
||||
let accounts = if config.accounts.enumerate {
|
||||
accounts::discover(&config.accounts)
|
||||
let (accounts, account_statuses) = if config.accounts.enumerate {
|
||||
let records = accounts::discover_with_status(&config.accounts);
|
||||
records
|
||||
.into_iter()
|
||||
.map(|record| (record.identity, record.status))
|
||||
.unzip()
|
||||
} else {
|
||||
Vec::new()
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let background = Background::load(&config.visual)?;
|
||||
let mut app = App::new(config, auth, preview, sessions, accounts, background);
|
||||
app.account_statuses = account_statuses;
|
||||
app.account_index = (!app.accounts.is_empty()).then_some(0);
|
||||
app.start_faillock_query();
|
||||
ratatui::run(|terminal| app.run(terminal)).map_err(|error| Error::Terminal(error.to_string()))
|
||||
@@ -795,6 +1241,22 @@ fn truncate_cells(value: &str, width: usize) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
fn matches_filter(value: &str, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if query.chars().any(char::is_uppercase) {
|
||||
value.contains(query)
|
||||
} else {
|
||||
value.to_lowercase().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_backoff(failures: u32) -> Duration {
|
||||
let shift = failures.saturating_sub(1).min(4);
|
||||
Duration::from_secs(1u64 << shift)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -839,6 +1301,40 @@ mod tests {
|
||||
assert!(rendered.contains("Password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_without_layout_errors_at_supported_breakpoints() {
|
||||
for (width, height) in [(80, 24), (79, 23), (60, 24)] {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
vec![SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual)
|
||||
.expect("default background should load"),
|
||||
);
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).expect("test terminal should initialize");
|
||||
terminal
|
||||
.draw(|frame| app.draw(frame))
|
||||
.expect("frame should render");
|
||||
let rendered = terminal.backend().buffer().content();
|
||||
assert!(rendered.iter().any(|cell| cell.symbol() == "l"));
|
||||
assert!(
|
||||
!rendered
|
||||
.iter()
|
||||
.any(|cell| cell.symbol() == "terminal too small")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_truthful_too_small_state() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
@@ -898,4 +1394,106 @@ mod tests {
|
||||
assert!(app.password.is_empty());
|
||||
assert!(matches!(app.auth_state, AuthState::Idle));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_policy_state_is_visible_without_overriding_pam() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let account = Identity {
|
||||
username: "alice".into(),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
home: "/home/alice".into(),
|
||||
shell: "/bin/sh".into(),
|
||||
};
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
Vec::new(),
|
||||
vec![account],
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.account_statuses[0].state = AccountState::Locked;
|
||||
let status = app.status_line();
|
||||
assert!(status.contains("password is locked"));
|
||||
assert!(status.contains("PAM remains authoritative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_backoff_is_exponential_and_bounded() {
|
||||
assert_eq!(auth_backoff(1), Duration::from_secs(1));
|
||||
assert_eq!(auth_backoff(2), Duration::from_secs(2));
|
||||
assert_eq!(auth_backoff(5), Duration::from_secs(16));
|
||||
assert_eq!(auth_backoff(100), Duration::from_secs(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtering_is_smart_case_and_selects_a_matching_session() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
vec![
|
||||
SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
},
|
||||
SessionDescriptor {
|
||||
kind: session::SessionKind::Wayland,
|
||||
title: "Sway session".into(),
|
||||
argv: vec!["sway".into()],
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.focus = Focus::Sessions;
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE));
|
||||
assert_eq!(app.matching_session_indices(), vec![1]);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(app.selected, 1);
|
||||
assert!(app.filter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_reconciles_selection_without_blocking_state() {
|
||||
let auth = AuthService::new().expect("PAM worker should start");
|
||||
let mut app = App::new(
|
||||
Config::default(),
|
||||
auth,
|
||||
true,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Background::load(&Config::default().visual).expect("default background should load"),
|
||||
);
|
||||
app.refresh_receiver = Some({
|
||||
let (sender, receiver) = mpsc::sync_channel(1);
|
||||
sender
|
||||
.send((
|
||||
vec![SessionDescriptor {
|
||||
kind: session::SessionKind::Tty,
|
||||
title: "TTY shell".into(),
|
||||
argv: Vec::new(),
|
||||
current_desktop: None,
|
||||
session_desktop: None,
|
||||
}],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
))
|
||||
.expect("refresh result should queue");
|
||||
receiver
|
||||
});
|
||||
app.refreshing = true;
|
||||
app.poll_refresh();
|
||||
assert!(!app.refreshing);
|
||||
assert_eq!(app.sessions.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user