[Add] Proper User managment

This commit is contained in:
Alex Emmet 2026-09-02 22:42:25 +02:00
commit b38b68ad96
No known key found for this signature in database
38 changed files with 4331 additions and 1065 deletions

View file

@ -11,6 +11,36 @@ use ratatui::{
widgets::Paragraph,
};
#[derive(Debug, Clone, Copy)]
pub struct HeaderItem {
pub label: &'static str,
pub intent: ButtonIntent,
pub action: AppAction,
}
pub const HEADER_ITEMS: &[HeaderItem] = &[
HeaderItem {
label: "Overview",
intent: ButtonIntent::Primary,
action: AppAction::OpenOverview,
},
HeaderItem {
label: "Users",
intent: ButtonIntent::Neutral,
action: AppAction::OpenUsers,
},
HeaderItem {
label: "Settings",
intent: ButtonIntent::Neutral,
action: AppAction::OpenSettings,
},
HeaderItem {
label: "Quit",
intent: ButtonIntent::Destructive,
action: AppAction::Quit,
},
];
fn connection_badge(
state: &IpcConnectionState,
theme: &ResolvedTheme,
@ -59,22 +89,14 @@ pub fn render_header(
let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[0]);
let cells2 = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[1]);
let constraints = std::iter::once(Constraint::Min(28))
.chain(
HEADER_ITEMS
.iter()
.map(|item| Constraint::Length(if item.label == "Quit" { 8 } else { 12 })),
)
.collect::<Vec<_>>();
let cells = Layout::horizontal(constraints.clone()).split(rows[0]);
let (ipc_label, ipc_style) = connection_badge(connection, theme);
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
@ -99,46 +121,15 @@ pub fn render_header(
);
hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [
(
cells[1],
cells2[1],
"Overview",
ButtonIntent::Primary,
AppAction::OpenOverview,
),
(
cells[2],
cells2[2],
"Users",
ButtonIntent::Neutral,
AppAction::OpenUsers,
),
(
cells[3],
cells2[3],
"Settings",
ButtonIntent::Neutral,
AppAction::OpenSettings,
),
(
cells[4],
cells2[4],
"Quit",
ButtonIntent::Destructive,
AppAction::Quit,
),
]
.into_iter()
.enumerate()
{
for (index, item) in HEADER_ITEMS.iter().enumerate() {
let top = cells[index + 1];
let button_area = Rect {
x: top.x,
y: top.y,
width: top.width,
height: area.height,
};
let style = match (intent, focused_action == Some(index)) {
let style = match (item.intent, focused_action == Some(index)) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
@ -148,9 +139,9 @@ pub fn render_header(
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
};
let display_label = if focused_action == Some(index) {
format!(" {label}")
format!(" {}", item.label)
} else {
label.to_owned()
item.label.to_owned()
};
frame.render_widget(
Paragraph::new(vec![
@ -159,6 +150,6 @@ pub fn render_header(
]),
button_area,
);
hits.register(button_area, action);
hits.register(button_area, item.action);
}
}

View file

@ -0,0 +1,119 @@
use crossterm::event::KeyCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem<T> {
pub label: String,
pub description: Option<String>,
pub value: T,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuState<T> {
items: Vec<MenuItem<T>>,
selected: Option<usize>,
}
impl<T> MenuState<T> {
pub fn new(items: Vec<MenuItem<T>>) -> Self {
let selected = items.iter().position(|item| item.enabled);
Self { items, selected }
}
pub fn items(&self) -> &[MenuItem<T>] {
&self.items
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_item(&self) -> Option<&MenuItem<T>> {
self.selected.and_then(|index| self.items.get(index))
}
pub fn move_next(&mut self) {
self.move_by(1);
}
pub fn move_previous(&mut self) {
self.move_by(-1);
}
pub fn first(&mut self) {
self.selected = self.items.iter().position(|item| item.enabled);
}
pub fn last(&mut self) {
self.selected = self.items.iter().rposition(|item| item.enabled);
}
pub fn handle_key(&mut self, key: KeyCode) -> bool {
match key {
KeyCode::Up => self.move_previous(),
KeyCode::Down => self.move_next(),
KeyCode::Home => self.first(),
KeyCode::End => self.last(),
_ => return false,
}
true
}
fn move_by(&mut self, delta: isize) {
let Some(current) = self.selected else {
return;
};
for offset in 1..=self.items.len() {
let next = (current as isize + delta * offset as isize)
.rem_euclid(self.items.len() as isize) as usize;
if self.items[next].enabled {
self.selected = Some(next);
return;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn navigation_skips_disabled_items() {
let mut menu = MenuState::new(vec![
MenuItem {
label: "A".into(),
description: None,
value: 1,
enabled: true,
disabled_reason: None,
},
MenuItem {
label: "B".into(),
description: None,
value: 2,
enabled: false,
disabled_reason: Some("Unavailable".into()),
},
MenuItem {
label: "C".into(),
description: None,
value: 3,
enabled: true,
disabled_reason: None,
},
]);
menu.move_next();
assert_eq!(menu.selected_index(), Some(2));
}
#[test]
fn empty_enabled_set_is_safe() {
let mut menu = MenuState::new(vec![MenuItem {
label: "A".into(),
description: None,
value: (),
enabled: false,
disabled_reason: None,
}]);
menu.move_next();
menu.last();
assert_eq!(menu.selected_index(), None);
}
}

View file

@ -4,7 +4,9 @@ pub mod checkbox_group;
pub mod choice;
pub mod dialog;
pub mod header;
pub mod menu;
pub mod navigation;
pub mod panel;
pub mod radio_group;
pub mod scroll;
pub mod text_input;

View file

@ -0,0 +1,99 @@
use crossterm::event::KeyCode;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TextInput {
value: String,
cursor: usize,
label: Option<String>,
placeholder: Option<String>,
validation: Option<String>,
secret: bool,
}
impl TextInput {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
..Self::default()
}
}
pub fn value(&self) -> &str {
&self.value
}
pub fn display_value(&self) -> String {
if self.secret {
"".repeat(self.value.chars().count())
} else {
self.value.clone()
}
}
pub fn set_placeholder(&mut self, value: impl Into<String>) {
self.placeholder = Some(value.into());
}
pub fn placeholder(&self) -> Option<&str> {
self.placeholder.as_deref()
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn validation(&self) -> Option<&str> {
self.validation.as_deref()
}
pub fn set_validation(&mut self, value: Option<String>) {
self.validation = value;
}
pub fn set_secret(&mut self, secret: bool) {
self.secret = secret;
}
pub fn handle_key(&mut self, key: KeyCode) -> bool {
match key {
KeyCode::Backspace => {
if self.cursor > 0 {
let start = self.value[..self.cursor]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0);
self.value.drain(start..self.cursor);
self.cursor = start;
}
}
KeyCode::Delete => {
if self.cursor < self.value.len() {
let end = self.value[self.cursor..]
.char_indices()
.nth(1)
.map(|(index, _)| self.cursor + index)
.unwrap_or(self.value.len());
self.value.drain(self.cursor..end);
}
}
KeyCode::Left => {
if self.cursor > 0 {
self.cursor = self.value[..self.cursor]
.char_indices()
.last()
.map(|(index, _)| index)
.unwrap_or(0);
}
}
KeyCode::Right => {
if self.cursor < self.value.len() {
self.cursor = self.value[self.cursor..]
.char_indices()
.nth(1)
.map(|(index, _)| self.cursor + index)
.unwrap_or(self.value.len());
}
}
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.value.len(),
KeyCode::Char(character) => {
self.value.insert(self.cursor, character);
self.cursor += character.len_utf8();
}
_ => return false,
}
true
}
}

View file

@ -6,7 +6,7 @@ use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
use std::io::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::net::UnixStream;
@ -44,6 +44,7 @@ pub struct DaemonStatus {
pub health: iota_ipc::HealthStatus,
pub deployment_mode: Option<iota_ipc::DeploymentMode>,
pub supervisor: Option<iota_ipc::SupervisorKind>,
pub capabilities: Vec<String>,
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
}
@ -71,6 +72,7 @@ pub struct IpcClient {
writer: Mutex<Option<ActiveWriter>>,
next_generation: AtomicU64,
next_request_id: AtomicU64,
protocol_version: AtomicU16,
pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>,
daemon_status: watch::Sender<DaemonStatus>,
@ -103,6 +105,7 @@ impl IpcClient {
health: negotiated.ack.health,
deployment_mode: Some(negotiated.ack.deployment_mode),
supervisor: Some(negotiated.ack.supervisor),
capabilities: negotiated.ack.capabilities.clone(),
components: std::collections::BTreeMap::new(),
};
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
@ -115,6 +118,7 @@ impl IpcClient {
})),
next_generation: AtomicU64::new(2),
next_request_id: AtomicU64::new(1),
protocol_version: AtomicU16::new(negotiated.ack.protocol_version),
pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx,
daemon_status: daemon_status_tx,
@ -180,7 +184,7 @@ impl IpcClient {
write_msg(
&mut writer,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
supported_versions: (MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).collect(),
},
),
)
@ -432,6 +436,8 @@ impl IpcClient {
}
fn update_hello_ack(&self, ack: HelloAck) {
self.protocol_version
.store(ack.protocol_version, Ordering::Release);
self.daemon_status.send_modify(|status| {
status.version = ack.daemon_version;
status.instance_id = ack.instance_id;
@ -440,6 +446,7 @@ impl IpcClient {
status.health = ack.health;
status.deployment_mode = Some(ack.deployment_mode);
status.supervisor = Some(ack.supervisor);
status.capabilities = ack.capabilities;
});
}
@ -480,6 +487,23 @@ impl IpcClient {
ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id)
}
ResponsePayload::TuCredentialPreview(preview) => {
format!("Inspected {} ({})", preview.username, preview.user_id)
}
ResponsePayload::UserReconciled(result) => {
format!("Reconciled user {}: {:?}", result.user_id, result.action)
}
ResponsePayload::UserDiagnostics(diagnostics) => format!(
"User {}: state={:?}, data={}, credential={:?}, trusted_apps={}",
diagnostics.user_id,
diagnostics.local_state,
diagnostics.data_present,
diagnostics.credential_status,
diagnostics.trusted_app_count,
),
ResponsePayload::UserCredentialExport { .. } => {
"Credential export payload withheld.".into()
}
ResponsePayload::UserRemoved { user_id } => {
format!("Removed user {}", user_id)
}
@ -589,7 +613,7 @@ impl IpcClient {
let envelope = RequestEnvelope {
request_id,
protocol_version: PROTOCOL_VERSION,
protocol_version: self.protocol_version.load(Ordering::Acquire),
request,
};
if let Err(error) = self.send(ClientMessage::Request(envelope)).await {

View file

@ -35,11 +35,9 @@ pub enum AppEvent {
},
ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>),
UserRemoved {
user_id: i64,
result: Result<(), String>,
},
TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
UserOperationFinished(Result<String, String>),
CredentialExportFinished(Result<String, String>),
RegenerateKeysRequested,
KeysRegenerated(Result<(), String>),
}
@ -52,14 +50,16 @@ pub enum AppAction {
OpenMetrics,
ToggleMetrics,
AddUser,
RemoveUser,
OpenUserGroup(crate::screens::users::model::UserActionGroup),
SetUserAdminTab(crate::screens::users::model::UserAdminTab),
ActivateUserAction(crate::screens::users::model::UserAction),
Back,
Quit,
FocusLogs,
FocusConsole,
FocusMetrics,
OpenMain,
SelectUser(usize),
SelectUser(i64),
ConfirmDialog,
CancelDialog,
RegenerateKeys,

View file

@ -1,741 +0,0 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU8, AtomicUsize, Ordering},
},
};
#[derive(Clone, Debug)]
pub struct UserEntry {
pub user_id: i64,
pub username: String,
pub state: iota_ipc::LocalUserState,
pub data_present: bool,
pub credential_present: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
AddButton,
RemoveButton,
Back,
}
#[derive(Clone, Debug)]
enum Dialog {
Add { username: String },
Remove { user: UserEntry },
}
pub struct UsersScreen {
users: Vec<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
tick: AtomicU8,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
Self {
users,
focused_index: 0,
focus: Focus::List,
ipc,
message: None,
dialog: None,
pending_dialog: None,
loading: false,
pending: false,
scroll_offset: 0,
viewport_height: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
tick: AtomicU8::new(0),
}
}
pub fn loading(ipc: Arc<IpcClient>) -> Self {
let mut screen = Self::new(ipc, Vec::new());
screen.loading = true;
screen.message = Some("Loading users…".into());
screen
}
fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let visible_indices = self.filtered_indices();
let title = if self.filter.is_empty() {
format!("Users ({})", self.users.len())
} else {
format!(
"Users ({}/{}) filter: {}",
visible_indices.len(),
self.users.len(),
self.filter
)
};
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(
f,
area,
&title,
self.focus == Focus::List,
context.theme,
)
} else {
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if self.loading {
const SPINNERS: &[u8] = b"|/-\\";
let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()];
f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner);
return;
}
if visible_indices.is_empty() {
let par = Paragraph::new(if self.users.is_empty() {
"No users found."
} else {
"No users match the filter."
});
f.render_widget(par, inner);
return;
}
let mut lines = Vec::new();
self.viewport_height
.store(inner.height as usize, Ordering::Relaxed);
let labels: Vec<(usize, String)> = visible_indices
.iter()
.skip(self.scroll_offset)
.take(inner.height as usize)
.map(|user_index| {
let user = &self.users[*user_index];
(
*user_index,
format!(
"{:>6} {} {}{}",
user.user_id,
user.username,
match user.state {
iota_ipc::LocalUserState::Managed => "managed",
iota_ipc::LocalUserState::Released => "released",
},
if user.data_present {
""
} else {
", data purged"
}
),
)
})
.collect();
for (user_index, label) in &labels {
let visual = crate::controls::choice::ChoiceVisualState {
selected: false,
focused: self.focus == Focus::List && *user_index == self.focused_index,
enabled: !self.loading && !self.pending,
};
lines.push(render_choice_line(
&label,
ChoiceKind::Radio,
visual,
context.theme,
));
}
let par = Paragraph::new(lines);
f.render_widget(par, inner);
}
fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
if let Some(msg) = &self.message {
let par = Paragraph::new(Line::from(Span::styled(
msg.as_str(),
context.theme.text.muted,
)));
f.render_widget(par, rows[0]);
}
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(rows[1]);
render_button(
f,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[1],
ActionButton {
label: "Add",
intent: ButtonIntent::Primary,
focused: self.focus == Focus::AddButton,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[2],
ActionButton {
label: "Release",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RemoveButton,
enabled: !self.loading && !self.pending && !self.users.is_empty(),
},
context.theme,
);
}
fn activate(&mut self) -> InteractionResult {
if self.loading || self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::Add { username } if !username.trim().is_empty() => {
let name = username.trim().to_owned();
self.pending_dialog = Some(Dialog::Add { username });
self.pending = true;
self.message = Some("Creating user…".into());
let ipc = self.ipc.clone();
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
Err(error) => Err(format!("Cannot create user: {error}")),
};
UiEvent::App(AppEvent::UserCreated(result))
}),
};
}
Dialog::Remove { user } => {
self.pending_dialog = Some(Dialog::Remove { user: user.clone() });
let ipc = self.ipc.clone();
let id = user.user_id;
self.pending = true;
self.message = Some(format!("Releasing {}", user.username));
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()),
Err(error) => Err(format!("Cannot release user: {error}")),
};
UiEvent::App(AppEvent::UserRemoved {
user_id: id,
result,
})
}),
};
}
Dialog::Add { .. } => self.message = Some("A username is required.".into()),
}
return InteractionResult::Handled;
}
match self.focus {
Focus::Back => InteractionResult::CloseScreen,
Focus::AddButton => {
self.dialog = Some(Dialog::Add {
username: String::new(),
});
InteractionResult::Handled
}
Focus::RemoveButton => {
if let Some(user) = self.users.get(self.focused_index) {
self.dialog = Some(Dialog::Remove { user: user.clone() });
}
InteractionResult::Handled
}
Focus::List => InteractionResult::Handled,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::AddButton,
Focus::AddButton => Focus::RemoveButton,
Focus::RemoveButton => Focus::Back,
Focus::Back => Focus::List,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Back,
Focus::Back => Focus::RemoveButton,
Focus::RemoveButton => Focus::AddButton,
Focus::AddButton => Focus::List,
};
}
fn keep_focused_user_visible(&mut self) {
let indices = self.filtered_indices();
let Some(position) = indices
.iter()
.position(|index| *index == self.focused_index)
else {
self.scroll_offset = 0;
return;
};
let height = self.viewport_height.load(Ordering::Relaxed).max(1);
if position < self.scroll_offset {
self.scroll_offset = position;
} else if position >= self.scroll_offset + height {
self.scroll_offset = position + 1 - height;
}
}
fn move_user_focus(&mut self, index: usize) {
if !self.users.is_empty() {
self.focused_index = index.min(self.users.len() - 1);
self.keep_focused_user_visible();
}
}
fn filtered_indices(&self) -> Vec<usize> {
let needle = self.filter.to_ascii_lowercase();
self.users
.iter()
.enumerate()
.filter(|(_, user)| {
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
})
.map(|(index, _)| index)
.collect()
}
fn move_visible(&mut self, delta: isize) {
let indices = self.filtered_indices();
if indices.is_empty() {
return;
}
let current = indices
.iter()
.position(|index| *index == self.focused_index)
.unwrap_or(0);
let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize;
self.move_user_focus(indices[next]);
}
fn reset_focus_to_filter(&mut self) {
self.scroll_offset = 0;
if let Some(index) = self.filtered_indices().first().copied() {
self.focused_index = index;
}
}
}
impl Screen for UsersScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
let outer_block = Block::default()
.title(" Users ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Users", false, context.theme)
} else {
let inner = outer_block.inner(rect);
f.render_widget(outer_block, rect);
inner
};
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
self.render_user_list(f, chunks[0], context);
self.render_actions(f, chunks[1], context);
if let Some(dialog) = &self.dialog {
f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect);
let popup = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let text = match dialog {
Dialog::Add { username } => {
format!("Add user\nUsername: {username}")
}
Dialog::Remove { user } => format!(
"Remove user {} (ID {})?\nThis removes the local user record.",
user.username, user.user_id
),
};
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
f.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
f.render_widget(
Paragraph::new(text).style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
f,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
f,
dialog_buttons[1],
ActionButton {
label: match dialog {
Dialog::Add { .. } => "Create",
Dialog::Remove { .. } => "Remove",
},
intent: match dialog {
Dialog::Add { .. } => ButtonIntent::Primary,
Dialog::Remove { .. } => ButtonIntent::Destructive,
},
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
let buttons = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(chunks[1]);
if self.dialog.is_none() {
hits.register(buttons[0], AppAction::Back);
}
if self.dialog.is_none() && !self.loading && !self.pending {
hits.register(buttons[1], AppAction::AddUser);
}
if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() {
hits.register(buttons[2], AppAction::RemoveUser);
}
if self.dialog.is_none() {
let list_height = chunks[0].height.saturating_sub(2) as usize;
let filtered_indices = self.filtered_indices();
for visible in 0..list_height {
let position = self.scroll_offset + visible;
let Some(index) = filtered_indices.get(position).copied() else {
break;
};
hits.register(
Rect {
x: chunks[0].x.saturating_add(1),
y: chunks[0].y.saturating_add(1 + visible as u16),
width: chunks[0].width.saturating_sub(2),
height: 1,
},
AppAction::SelectUser(index),
);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::UsersLoaded(result)) => {
self.loading = false;
match result {
Ok(users) => {
self.users = users;
self.message = None;
}
Err(error) => self.message = Some(error),
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserCreated(result)) => {
self.pending = false;
match result {
Ok(user) => {
self.pending_dialog = None;
self.focused_index = self.users.len();
self.users.push(user.clone());
self.message = Some(format!(
"Created user {} ({}).",
user.username, user.user_id
));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserRemoved { user_id, result }) => {
self.pending = false;
match result {
Ok(()) => {
self.pending_dialog = None;
if let Some(user) =
self.users.iter_mut().find(|user| user.user_id == user_id)
{
user.state = iota_ipc::LocalUserState::Released;
user.credential_present = false;
}
self.message =
Some(format!("Released user {user_id}; hosted data retained."));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => {
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
username.push_str(&text.replace(['\r', '\n'], " "));
}
return InteractionResult::Handled;
}
UiEvent::Key(event) => event,
_ => return InteractionResult::Unhandled,
};
if self.filtering && self.dialog.is_none() {
match event.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.reset_focus_to_filter();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.reset_focus_to_filter();
}
KeyCode::Char(c)
if !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
self.reset_focus_to_filter();
}
_ => {}
}
return InteractionResult::Handled;
}
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
match event.code {
KeyCode::Esc => {
self.dialog = None;
return InteractionResult::Handled;
}
KeyCode::Enter => return self.activate(),
KeyCode::Backspace => {
username.pop();
return InteractionResult::Handled;
}
KeyCode::Char(c)
if !c.is_control()
&& !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
username.push(c);
return InteractionResult::Handled;
}
_ => return InteractionResult::Handled,
}
}
if self.dialog.is_some() {
return match event.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
match event.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('/') if self.focus == Focus::List => {
self.filtering = true;
self.filter.clear();
self.reset_focus_to_filter();
InteractionResult::Handled
}
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == Focus::List {
self.move_visible(1);
}
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == Focus::List {
self.move_visible(-1);
}
InteractionResult::Handled
}
KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(page as isize);
InteractionResult::Handled
}
KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(-(page as isize));
InteractionResult::Handled
}
KeyCode::Home if self.focus == Focus::List => {
if let Some(index) = self.filtered_indices().first().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => {
if let Some(index) = self.filtered_indices().last().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::AddUser => {
self.focus = Focus::AddButton;
self.activate()
}
AppAction::RemoveUser => {
self.focus = Focus::RemoveButton;
self.activate()
}
AppAction::SelectUser(index) if self.dialog.is_none() => {
self.focus = Focus::List;
self.move_user_focus(index);
InteractionResult::Handled
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Up/Down",
action: "Select user",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -0,0 +1,66 @@
use super::model::{UserAction, UserActionGroup};
use crate::controls::menu::{MenuItem, MenuState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserActionMenu {
pub group: UserActionGroup,
pub menu: MenuState<UserAction>,
}
impl UserActionMenu {
pub fn new(
group: UserActionGroup,
actions: Vec<UserAction>,
credential_export_supported: bool,
) -> Self {
Self {
group,
menu: MenuState::new(
actions
.into_iter()
.map(|action| {
let enabled = action != UserAction::ExportTu || credential_export_supported;
MenuItem {
label: action.label().into(),
description: None,
value: action,
enabled,
disabled_reason: (!enabled)
.then(|| "Not supported by connected daemon".into()),
}
})
.collect(),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_is_disabled_without_daemon_capability() {
let menu = UserActionMenu::new(
UserActionGroup::Credential,
vec![UserAction::ExportTu],
false,
);
assert!(menu.menu.selected_item().is_none());
assert!(!menu.menu.items()[0].enabled);
}
#[test]
fn export_is_enabled_with_daemon_capability() {
let menu = UserActionMenu::new(
UserActionGroup::Credential,
vec![UserAction::ExportTu],
true,
);
assert_eq!(
menu.menu.selected_item().map(|item| item.value),
Some(UserAction::ExportTu)
);
}
}

View file

@ -0,0 +1,126 @@
use crate::controls::{
menu::{MenuItem, MenuState},
text_input::TextInput,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddUserMethod {
Invitation,
CreateLocal,
ImportTu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddUserPhase {
ChooseMethod,
ConfigureCreate,
ConfigureImport,
ConfigureInvitation,
InspectingTu,
ReviewImport,
Pending,
Result,
}
#[derive(Debug, Clone)]
pub struct AddUserFlow {
pub phase: AddUserPhase,
pub methods: MenuState<AddUserMethod>,
pub username: TextInput,
pub import_path: TextInput,
pub credential: Option<iota_ipc::SecretString>,
pub preview: Option<iota_ipc::TuCredentialPreview>,
pub error: Option<String>,
}
impl AddUserFlow {
pub fn new(invitation_supported: bool, tu_inspection_supported: bool) -> Self {
Self {
phase: AddUserPhase::ChooseMethod,
methods: MenuState::new(vec![
MenuItem {
label: "Share an invitation".into(),
description: Some("Not supported by the connected daemon".into()),
value: AddUserMethod::Invitation,
enabled: invitation_supported,
disabled_reason: Some("Not supported by the connected daemon".into()),
},
MenuItem {
label: "Create user on this Iota".into(),
description: None,
value: AddUserMethod::CreateLocal,
enabled: true,
disabled_reason: None,
},
MenuItem {
label: "Import existing user from TU".into(),
description: None,
value: AddUserMethod::ImportTu,
enabled: tu_inspection_supported,
disabled_reason: (!tu_inspection_supported)
.then(|| "TU inspection is not supported by the connected daemon".into()),
},
]),
username: TextInput::new("Username"),
import_path: TextInput::new("TU path"),
credential: None,
preview: None,
error: None,
}
}
pub fn choose(&mut self) {
self.phase = match self.methods.selected_item().map(|item| item.value) {
Some(AddUserMethod::CreateLocal) => AddUserPhase::ConfigureCreate,
Some(AddUserMethod::ImportTu) => AddUserPhase::ConfigureImport,
Some(AddUserMethod::Invitation) => AddUserPhase::ConfigureInvitation,
None => AddUserPhase::ChooseMethod,
};
}
pub fn inspection_succeeded(&mut self, preview: iota_ipc::TuCredentialPreview) {
self.preview = Some(preview);
self.error = None;
self.phase = AddUserPhase::ReviewImport;
}
pub fn inspection_failed(&mut self, error: String) {
self.preview = None;
self.error = Some(error);
self.phase = AddUserPhase::ConfigureImport;
}
pub fn attachment_started(&mut self) {
self.phase = AddUserPhase::Pending;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invitation_is_disabled_without_capability() {
let flow = AddUserFlow::new(false, false);
assert!(!flow.methods.items()[0].enabled);
}
#[test]
fn create_and_import_have_distinct_configuration_phases() {
let mut flow = AddUserFlow::new(false, true);
flow.choose();
assert_eq!(flow.phase, AddUserPhase::ConfigureCreate);
flow.phase = AddUserPhase::ChooseMethod;
flow.methods.move_next();
flow.choose();
assert_eq!(flow.phase, AddUserPhase::ConfigureImport);
}
#[test]
fn inspection_result_controls_import_review() {
let mut flow = AddUserFlow::new(false, true);
flow.inspection_succeeded(iota_ipc::TuCredentialPreview {
user_id: 42,
username: "alice".into(),
assigned_iota_id: None,
});
assert_eq!(flow.phase, AddUserPhase::ReviewImport);
flow.inspection_failed("invalid credential".into());
assert_eq!(flow.phase, AddUserPhase::ConfigureImport);
assert_eq!(flow.error.as_deref(), Some("invalid credential"));
}
}

View file

@ -0,0 +1,55 @@
use super::{UserEntry, model::UserAction};
#[derive(Debug, Clone)]
pub struct UserConfirmation {
pub user: UserEntry,
pub action: UserAction,
}
impl UserConfirmation {
pub fn title(&self) -> &'static str {
match self.action {
UserAction::Release => "Release user",
UserAction::PurgeData => "Purge hosted data",
UserAction::ExportTu => "Export TU",
UserAction::DeleteAccount => "Delete Tensamin account",
UserAction::Reconcile => "Reconcile with Omega",
UserAction::Diagnostics => "User diagnostics",
UserAction::ForceDetach => "Force local detach",
UserAction::ForgetResidency => "Forget residency record",
}
}
pub fn message(&self) -> String {
match self.action {
UserAction::Release => format!(
"Stop managing {} on this Iota?\nHosted data is retained. Local credentials are removed.",
self.user.username
),
UserAction::PurgeData => format!(
"Delete hosted data for {}?\nThe account and Iota assignment are retained.",
self.user.username
),
UserAction::ExportTu => format!("Export the local TU for {}?", self.user.username),
UserAction::DeleteAccount => format!(
"Delete the Tensamin account {}?\nHosted data erasure is requested.",
self.user.username
),
UserAction::Reconcile => {
format!("Compare {} with its Omega assignment?", self.user.username)
}
UserAction::Diagnostics => {
format!("Load local diagnostics for {}?", self.user.username)
}
UserAction::ForceDetach => format!(
"Force local detach for {}?\nOmega assignment will not be changed. Hosted data is retained.",
self.user.username
),
UserAction::ForgetResidency => format!(
"Forget the released residency record for {}?\nThis is allowed only when hosted data is empty.",
self.user.username
),
}
}
pub fn confirm_label(&self) -> &'static str {
self.action.confirmation_label()
}
}

View file

@ -0,0 +1,29 @@
use super::{UserEntry, model::UserActionGroup};
pub fn state_label(user: &UserEntry) -> &'static str {
match user.state {
iota_ipc::LocalUserState::Managed => "Managed",
iota_ipc::LocalUserState::Released => "Released",
}
}
pub fn group_label(group: UserActionGroup) -> &'static str {
group.label()
}
pub fn credential_status_label(status: iota_ipc::CredentialStatus) -> &'static str {
match status {
iota_ipc::CredentialStatus::LocalPresent => "Local",
iota_ipc::CredentialStatus::External => "External",
iota_ipc::CredentialStatus::Missing => "Missing",
iota_ipc::CredentialStatus::None => "None",
}
}
pub fn pending_operation_label(operation: &iota_ipc::UserOperationSummary) -> String {
let kind = match operation.operation {
iota_ipc::UserOperationKind::Create => "Create",
iota_ipc::UserOperationKind::Attach => "Attach",
iota_ipc::UserOperationKind::Release => "Release",
iota_ipc::UserOperationKind::Purge => "Purge",
};
format!("{kind} ({})", operation.phase)
}

View file

@ -0,0 +1,85 @@
use super::UserEntry;
use crate::controls::text_input::TextInput;
use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::Path,
};
#[derive(Clone, Debug)]
pub struct CredentialExportState {
pub user: UserEntry,
pub destination: TextInput,
pub error: Option<String>,
}
impl CredentialExportState {
pub fn new(user: UserEntry) -> Self {
Self {
user,
destination: TextInput::new("Destination"),
error: None,
}
}
}
/* Export credentials with owner-only permissions and refuse to replace an
* existing destination so a typo cannot destroy another file. */
pub fn write_private_export(path: &Path, contents: &[u8]) -> io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "export path has no parent"))?;
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
let result = (|| {
file.write_all(contents)?;
file.sync_all()
})();
if result.is_err() {
drop(file);
let _ = fs::remove_file(path);
}
result
}
#[cfg(test)]
mod tests {
use super::write_private_export;
#[test]
fn export_refuses_to_replace_existing_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("alice.tu");
write_private_export(&path, b"first").unwrap();
assert_eq!(
write_private_export(&path, b"second").unwrap_err().kind(),
std::io::ErrorKind::AlreadyExists
);
assert_eq!(std::fs::read(path).unwrap(), b"first");
}
#[cfg(unix)]
#[test]
fn export_uses_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("alice.tu");
write_private_export(&path, b"secret").unwrap();
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}

View file

@ -0,0 +1,38 @@
/* Invitation state remains capability-gated until the daemon implements its protocol. */
pub const UNSUPPORTED_MESSAGE: &str =
"Invitation management is not supported by the connected daemon.";
pub fn empty_message(supported: bool) -> &'static str {
if supported {
"No invitations.\n\nInvitation protocol support is not implemented in this client build."
} else {
"No invitations.\n\nInvitation management is not supported by the connected daemon."
}
}
pub fn render(
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
context: &crate::render_context::RenderContext<'_>,
supported: bool,
) {
frame.render_widget(
ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted),
area,
);
}
#[cfg(test)]
mod tests {
use super::{UNSUPPORTED_MESSAGE, empty_message};
#[test]
fn absent_capability_is_explained_in_the_empty_state() {
assert!(empty_message(false).contains(UNSUPPORTED_MESSAGE));
}
#[test]
fn supported_daemon_does_not_show_the_capability_error() {
assert!(!empty_message(true).contains(UNSUPPORTED_MESSAGE));
}
}

View file

@ -0,0 +1,7 @@
use super::UserEntry;
pub fn matches_filter(user: &UserEntry, filter: &str) -> bool {
let needle = filter.to_ascii_lowercase();
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusZone {
Toolbar,
Search,
UserList,
UserActions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserAdminTab {
Users,
Invitations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserActionGroup {
Hosting,
Data,
Credential,
Account,
Repair,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserAction {
Release,
PurgeData,
ExportTu,
DeleteAccount,
Reconcile,
Diagnostics,
ForceDetach,
ForgetResidency,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionGroupEntry {
pub group: UserActionGroup,
pub actions: Vec<UserAction>,
}
use super::UserEntry;
/* Maps local daemon facts into the operator actions that are safe to present. */
pub fn available_action_groups(user: &UserEntry) -> Vec<ActionGroupEntry> {
let mut groups = Vec::new();
if user.state == iota_ipc::LocalUserState::Managed {
groups.push(ActionGroupEntry {
group: UserActionGroup::Hosting,
actions: vec![UserAction::Release],
});
}
groups.push(ActionGroupEntry {
group: UserActionGroup::Repair,
actions: if user.state == iota_ipc::LocalUserState::Managed {
vec![
UserAction::Reconcile,
UserAction::Diagnostics,
UserAction::ForceDetach,
]
} else {
vec![UserAction::Reconcile, UserAction::Diagnostics]
},
});
if user.state == iota_ipc::LocalUserState::Released && !user.data_present {
groups.push(ActionGroupEntry {
group: UserActionGroup::Record,
actions: vec![UserAction::ForgetResidency],
});
}
if user.data_present {
groups.push(ActionGroupEntry {
group: UserActionGroup::Data,
actions: vec![UserAction::PurgeData],
});
}
if user.state == iota_ipc::LocalUserState::Managed
&& user.credential_status == iota_ipc::CredentialStatus::LocalPresent
{
groups.push(ActionGroupEntry {
group: UserActionGroup::Credential,
actions: vec![UserAction::ExportTu],
});
}
if user.state == iota_ipc::LocalUserState::Managed {
groups.push(ActionGroupEntry {
group: UserActionGroup::Account,
actions: vec![UserAction::DeleteAccount],
});
}
groups
}
impl UserActionGroup {
pub fn label(self) -> &'static str {
match self {
Self::Hosting => "Hosting",
Self::Data => "Data",
Self::Credential => "Credential",
Self::Account => "Account",
Self::Repair => "Repair",
Self::Record => "Record",
}
}
}
impl UserAction {
pub fn label(self) -> &'static str {
match self {
Self::Release => "Release from this Iota",
Self::PurgeData => "Purge hosted data",
Self::ExportTu => "Export TU",
Self::DeleteAccount => "Delete Tensamin account",
Self::Reconcile => "Reconcile with Omega",
Self::Diagnostics => "Diagnostics",
Self::ForceDetach => "Force local detach",
Self::ForgetResidency => "Forget residency record",
}
}
pub fn requires_confirmation(self) -> bool {
matches!(
self,
Self::Release
| Self::PurgeData
| Self::DeleteAccount
| Self::ForceDetach
| Self::ForgetResidency
)
}
pub fn confirmation_label(self) -> &'static str {
match self {
Self::Release => "Release user",
Self::PurgeData => "Purge data",
Self::DeleteAccount => "Delete account",
Self::ForceDetach => "Force detach",
Self::ForgetResidency => "Forget record",
Self::ExportTu => "Export TU",
Self::Reconcile => "Reconcile",
Self::Diagnostics => "Load diagnostics",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(state: iota_ipc::LocalUserState, data_present: bool) -> UserEntry {
UserEntry {
user_id: 1,
username: "alice".into(),
state,
data_present,
credential_status: iota_ipc::CredentialStatus::LocalPresent,
pending_operation: None,
}
}
#[test]
fn released_users_cannot_be_released() {
assert!(
!available_action_groups(&user(iota_ipc::LocalUserState::Released, true))
.iter()
.any(|entry| entry.actions.contains(&UserAction::Release))
);
}
#[test]
fn empty_data_does_not_offer_purge() {
assert!(
!available_action_groups(&user(iota_ipc::LocalUserState::Managed, false))
.iter()
.any(|entry| entry.actions.contains(&UserAction::PurgeData))
);
}
#[test]
fn released_empty_user_can_forget_but_cannot_force_detach() {
let actions = available_action_groups(&user(iota_ipc::LocalUserState::Released, false));
assert!(
actions
.iter()
.any(|entry| entry.actions.contains(&UserAction::ForgetResidency))
);
assert!(
!actions
.iter()
.any(|entry| entry.actions.contains(&UserAction::ForceDetach))
);
}
#[test]
fn external_credential_does_not_offer_export() {
let mut external = user(iota_ipc::LocalUserState::Managed, true);
external.credential_status = iota_ipc::CredentialStatus::External;
assert!(
!available_action_groups(&external)
.iter()
.any(|entry| entry.actions.contains(&UserAction::ExportTu))
);
}
#[test]
fn local_credential_offers_export_only_in_credential_group() {
let groups = available_action_groups(&user(iota_ipc::LocalUserState::Managed, true));
assert!(groups.iter().any(|entry| {
entry.group == UserActionGroup::Credential
&& entry.actions.contains(&UserAction::ExportTu)
}));
assert!(
groups
.iter()
.filter(|entry| entry.group != UserActionGroup::Credential)
.all(|entry| !entry.actions.contains(&UserAction::ExportTu))
);
}
#[test]
fn only_lifecycle_mutations_require_confirmation() {
assert!(UserAction::Release.requires_confirmation());
assert!(UserAction::PurgeData.requires_confirmation());
assert!(UserAction::DeleteAccount.requires_confirmation());
assert!(UserAction::ForceDetach.requires_confirmation());
assert!(UserAction::ForgetResidency.requires_confirmation());
assert!(!UserAction::Reconcile.requires_confirmation());
assert!(!UserAction::Diagnostics.requires_confirmation());
assert!(!UserAction::ExportTu.requires_confirmation());
}
}

View file

@ -1,5 +1,5 @@
use crate::{
controls::header::render_header,
controls::header::{HEADER_ITEMS, render_header},
help_overlay::HelpOverlay,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
@ -432,19 +432,16 @@ impl UI {
if header_is_focused {
let mut action = None;
if let Ok(mut focus) = self.header_focus.lock() {
let index = focus.unwrap_or(0);
let index = focus.unwrap_or(0).min(HEADER_ITEMS.len().saturating_sub(1));
match key.code {
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
KeyCode::Left | KeyCode::BackTab => {
*focus = Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len())
}
KeyCode::Right | KeyCode::Tab => {
*focus = Some((index + 1) % HEADER_ITEMS.len())
}
KeyCode::Enter | KeyCode::Char(' ') => {
action = Some(
[
AppAction::OpenOverview,
AppAction::OpenUsers,
AppAction::OpenSettings,
AppAction::Quit,
][index],
);
action = HEADER_ITEMS.get(index).map(|item| item.action);
*focus = None;
}
KeyCode::Esc => *focus = None,
@ -607,7 +604,8 @@ impl UI {
username: u.username,
state: u.state,
data_present: u.data_present,
credential_present: u.credential_present,
credential_status: u.credential_status,
pending_operation: u.pending_operation,
})
.collect())
}