[Add] Proper User managment
This commit is contained in:
parent
430c12e139
commit
b38b68ad96
38 changed files with 4331 additions and 1065 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
iota-cli/src/controls/menu.rs
Normal file
119
iota-cli/src/controls/menu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
99
iota-cli/src/controls/text_input.rs
Normal file
99
iota-cli/src/controls/text_input.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
66
iota-cli/src/screens/users/action_menu.rs
Normal file
66
iota-cli/src/screens/users/action_menu.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
126
iota-cli/src/screens/users/add_flow.rs
Normal file
126
iota-cli/src/screens/users/add_flow.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
55
iota-cli/src/screens/users/confirmations.rs
Normal file
55
iota-cli/src/screens/users/confirmations.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
29
iota-cli/src/screens/users/context.rs
Normal file
29
iota-cli/src/screens/users/context.rs
Normal 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)
|
||||
}
|
||||
85
iota-cli/src/screens/users/credential_export.rs
Normal file
85
iota-cli/src/screens/users/credential_export.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
38
iota-cli/src/screens/users/invitations.rs
Normal file
38
iota-cli/src/screens/users/invitations.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
7
iota-cli/src/screens/users/list.rs
Normal file
7
iota-cli/src/screens/users/list.rs
Normal 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)
|
||||
}
|
||||
1281
iota-cli/src/screens/users/mod.rs
Normal file
1281
iota-cli/src/screens/users/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
230
iota-cli/src/screens/users/model.rs
Normal file
230
iota-cli/src/screens/users/model.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue