iota/iota-cli/src/screens/users/mod.rs
2026-09-02 22:42:25 +02:00

1281 lines
53 KiB
Rust

pub mod action_menu;
pub mod add_flow;
pub mod confirmations;
pub mod context;
pub mod credential_export;
pub mod invitations;
pub mod list;
pub mod model;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use action_menu::UserActionMenu;
use add_flow::{AddUserFlow, AddUserPhase};
use confirmations::UserConfirmation;
use credential_export::{CredentialExportState, write_private_export};
use crossterm::event::{KeyCode, KeyModifiers};
use model::{FocusZone, UserAction, UserAdminTab, available_action_groups};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear, 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_status: iota_ipc::CredentialStatus,
pub pending_operation: Option<iota_ipc::UserOperationSummary>,
}
#[derive(Clone, Debug)]
enum UsersOverlay {
Add(AddUserFlow),
UserMenu(UserActionMenu),
Confirm(UserConfirmation),
CredentialExport(CredentialExportState),
}
pub struct UsersScreen {
ipc: Arc<IpcClient>,
users: Vec<UserEntry>,
selected_user_id: Option<i64>,
active_tab: UserAdminTab,
focus_zone: FocusZone,
toolbar_index: usize,
action_group_index: usize,
filter: String,
filtering: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
overlay: Option<UsersOverlay>,
loading: bool,
pending: bool,
message: Option<String>,
tick: AtomicU8,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
let selected_user_id = users.first().map(|user| user.user_id);
Self {
ipc,
users,
selected_user_id,
active_tab: UserAdminTab::Users,
focus_zone: FocusZone::UserList,
toolbar_index: 0,
action_group_index: 0,
filter: String::new(),
filtering: false,
scroll_offset: 0,
viewport_height: AtomicUsize::new(1),
overlay: None,
loading: false,
pending: false,
message: None,
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 selected_user(&self) -> Option<&UserEntry> {
let id = self.selected_user_id?;
self.users.iter().find(|user| user.user_id == id)
}
fn visible_users(&self) -> Vec<&UserEntry> {
self.users
.iter()
.filter(|user| list::matches_filter(user, &self.filter))
.collect()
}
fn restore_selection(&mut self) {
if self.selected_user().is_none() {
self.selected_user_id = self.visible_users().first().map(|user| user.user_id);
}
self.keep_selected_visible();
}
fn keep_selected_visible(&mut self) {
let Some(id) = self.selected_user_id else {
self.scroll_offset = 0;
return;
};
let visible = self.visible_users();
let Some(position) = visible.iter().position(|user| user.user_id == id) 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_selection(&mut self, delta: isize) {
let visible = self.visible_users();
if visible.is_empty() {
return;
}
let current = self
.selected_user_id
.and_then(|id| visible.iter().position(|user| user.user_id == id))
.unwrap_or(0);
let next = (current as isize + delta).clamp(0, visible.len() as isize - 1) as usize;
self.selected_user_id = Some(visible[next].user_id);
self.keep_selected_visible();
}
fn groups(&self) -> Vec<model::ActionGroupEntry> {
self.selected_user()
.map(available_action_groups)
.unwrap_or_default()
}
fn begin_action(&mut self, action: UserAction) -> InteractionResult {
if action == UserAction::ExportTu && !self.credential_export_supported() {
self.message =
Some("Credential export is not supported by the connected daemon.".into());
return InteractionResult::Handled;
}
if let Some(user) = self.selected_user().cloned() {
if action == UserAction::ExportTu {
self.overlay = Some(UsersOverlay::CredentialExport(CredentialExportState::new(
user,
)));
return InteractionResult::Handled;
}
if action.requires_confirmation() {
self.overlay = Some(UsersOverlay::Confirm(UserConfirmation { user, action }));
} else {
return self.start_operation(action, user.user_id);
}
}
InteractionResult::Handled
}
fn credential_export_supported(&self) -> bool {
self.ipc
.daemon_status()
.borrow()
.capabilities
.iter()
.any(|capability| capability == "credential_export_v1")
}
fn invitation_supported(&self) -> bool {
self.ipc
.daemon_status()
.borrow()
.capabilities
.iter()
.any(|capability| capability == "user_invitations_v1")
}
fn new_add_flow(&self) -> AddUserFlow {
let status = self.ipc.daemon_status();
let capabilities = status.borrow().capabilities.clone();
AddUserFlow::new(
capabilities
.iter()
.any(|value| value == "user_invitations_v1"),
capabilities.iter().any(|value| value == "tu_inspection_v1"),
)
}
fn start_tu_inspection(&mut self, credential: iota_ipc::SecretString) -> InteractionResult {
let ipc = self.ipc.clone();
if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() {
flow.phase = AddUserPhase::InspectingTu;
flow.credential = Some(credential.clone());
flow.error = None;
}
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::InspectTuCredential { credential })
.await
{
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::TuCredentialPreview(preview),
)) => Ok(preview),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Credential inspection failed: {error}"))
}
Ok(_) => Err(
"Credential inspection failed: daemon returned an unexpected response."
.into(),
),
Err(error) => Err(format!("Credential inspection failed: {error}")),
};
UiEvent::App(AppEvent::TuInspected(result))
}),
}
}
fn start_tu_attachment(&mut self, credential: iota_ipc::SecretString) -> InteractionResult {
self.pending = true;
self.overlay = None;
self.message = Some("Attaching existing user…".into());
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::AttachUserFromTu { credential })
.await
{
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok("Attached existing user.".into()),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Attach existing user failed: {error}"))
}
Err(error) => Err(format!("Attach existing user failed: {error}")),
};
UiEvent::App(AppEvent::UserOperationFinished(result))
}),
}
}
fn start_operation(&mut self, action: UserAction, user_id: i64) -> InteractionResult {
self.pending = true;
self.overlay = None;
self.message = Some(format!("{}", action.label()));
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let response = match action {
UserAction::Release => {
ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id })
.await
}
UserAction::PurgeData => {
ipc.send_request(iota_ipc::LocalRequest::PurgeUserData { user_id })
.await
}
UserAction::ExportTu => {
return UiEvent::App(AppEvent::UserOperationFinished(Err(
"Credential export requires a destination path.".into(),
)));
}
UserAction::DeleteAccount => {
ipc.send_request(iota_ipc::LocalRequest::CompleteDeleteUser {
user_id,
credential: None,
})
.await
}
UserAction::Reconcile => {
ipc.send_request(iota_ipc::LocalRequest::ReconcileUser { user_id })
.await
}
UserAction::Diagnostics => {
ipc.send_request(iota_ipc::LocalRequest::GetUserDiagnostics { user_id })
.await
}
UserAction::ForceDetach => {
ipc.send_request(iota_ipc::LocalRequest::ForceDetachUser { user_id })
.await
}
UserAction::ForgetResidency => {
ipc.send_request(iota_ipc::LocalRequest::ForgetReleasedUser { user_id })
.await
}
};
let result = match response {
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::UserDiagnostics(diagnostics),
)) => Ok(format!(
"Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}",
diagnostics.local_state,
if diagnostics.data_present {
"present"
} else {
"empty"
},
context::credential_status_label(diagnostics.credential_status),
diagnostics.trusted_app_count,
diagnostics
.pending_operation
.unwrap_or_else(|| "none".into()),
)),
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::UserReconciled(result),
)) => Ok(format!("Reconciliation result: {:?}.", result.action)),
Ok(iota_ipc::ResponseResult::Ok(_)) => {
Ok(format!("{} completed.", action.label()))
}
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("{} failed: {error}", action.label()))
}
Err(error) => Err(format!("{} failed: {error}", action.label())),
};
UiEvent::App(AppEvent::UserOperationFinished(result))
}),
}
}
fn start_credential_export(
&mut self,
user_id: i64,
destination: std::path::PathBuf,
) -> InteractionResult {
self.pending = true;
self.overlay = None;
self.message = Some("Exporting credential…".into());
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::ExportUserCredential { user_id })
.await
{
Ok(iota_ipc::ResponseResult::Ok(
iota_ipc::ResponsePayload::UserCredentialExport {
user_id: response_user_id,
username,
credential,
},
)) if response_user_id == user_id => {
write_private_export(&destination, credential.0.as_bytes())
.map(|()| {
format!(
"Exported credential for {username} to {}.",
destination.display()
)
})
.map_err(|error| {
format!(
"Credential export failed for {}: {error}",
destination.display()
)
})
}
Ok(iota_ipc::ResponseResult::Ok(_)) => Err(
"Credential export failed: daemon returned an unexpected response.".into(),
),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Credential export failed: {error}"))
}
Err(error) => Err(format!("Credential export failed: {error}")),
};
UiEvent::App(AppEvent::CredentialExportFinished(result))
}),
}
}
fn start_create(&mut self, username: String) -> InteractionResult {
self.pending = true;
self.message = Some("Creating user…".into());
self.overlay = None;
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::CreateUser { username })
.await
{
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated {
username,
user_id,
})) => Ok(format!("Created user {username} ({user_id}).")),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Create user failed: {error}"))
}
Ok(_) => {
Err("Create user failed: daemon returned an unexpected response.".into())
}
Err(error) => Err(format!("Create user failed: {error}")),
};
UiEvent::App(AppEvent::UserOperationFinished(result))
}),
}
}
fn refresh(&self) -> InteractionResult {
let ipc = self.ipc.clone();
InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
Ok(users
.into_iter()
.map(|user| UserEntry {
user_id: user.user_id,
username: user.username,
state: user.state,
data_present: user.data_present,
credential_status: user.credential_status,
pending_operation: user.pending_operation,
})
.collect())
}
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Cannot reload users: {error}"))
}
Ok(_) => {
Err("Cannot reload users: daemon returned an unexpected response.".into())
}
Err(error) => Err(format!("Cannot reload users: {error}")),
};
UiEvent::App(AppEvent::UsersLoaded(result))
}),
}
}
fn render_list(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Users ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
self.viewport_height
.store(inner.height as usize, Ordering::Relaxed);
if self.loading {
let spinner = b"|/-\\"[self.tick.fetch_add(1, Ordering::Relaxed) as usize % 4] as char;
frame.render_widget(Paragraph::new(format!("{spinner} Loading users…")), inner);
return;
}
let visible = self.visible_users();
if visible.is_empty() {
let text = if self.users.is_empty() {
"No users are managed or retained on this Iota."
} else {
"No users match the current filter."
};
frame.render_widget(Paragraph::new(text), inner);
return;
}
let mut lines = Vec::new();
for (row, user) in visible
.iter()
.skip(self.scroll_offset)
.take(inner.height as usize)
.enumerate()
{
let label = format!(
"{:<18} {:<9} {:<8} {}",
user.username,
context::state_label(user),
if user.data_present {
"Present"
} else {
"Empty"
},
context::credential_status_label(user.credential_status)
);
let prefix = if self.focus_zone == FocusZone::UserList
&& self.selected_user_id == Some(user.user_id)
{
"> "
} else {
" "
};
lines.push(Line::from(format!("{prefix}{label}")));
hits.register(
Rect {
x: inner.x,
y: inner.y + row as u16,
width: inner.width,
height: 1,
},
AppAction::SelectUser(user.user_id),
);
}
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_context(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Selected user ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(user) = self.selected_user() else {
frame.render_widget(
Paragraph::new("Select a user to inspect local lifecycle state."),
inner,
);
return;
};
let groups = self.groups();
let details = vec![
Line::from(Span::styled(
user.username.as_str(),
context.theme.text.normal,
)),
Line::from(format!("State {}", context::state_label(user))),
Line::from(format!(
"Hosted data {}",
if user.data_present {
"Present"
} else {
"Empty"
}
)),
Line::from(format!(
"Credential {}",
context::credential_status_label(user.credential_status)
)),
Line::from(format!(
"Pending {}",
user.pending_operation
.as_ref()
.map(context::pending_operation_label)
.unwrap_or_else(|| "None".into())
)),
];
let rows = Layout::vertical([Constraint::Min(5), Constraint::Length(3)]).split(inner);
frame.render_widget(Paragraph::new(details), rows[0]);
let widths = vec![Constraint::Ratio(1, groups.len().max(1) as u32); groups.len().max(1)];
let buttons = Layout::horizontal(widths).split(rows[1]);
for (index, entry) in groups.iter().enumerate() {
let focused =
self.focus_zone == FocusZone::UserActions && self.action_group_index == index;
render_button(
frame,
buttons[index],
ActionButton {
label: entry.group.label(),
intent: ButtonIntent::Neutral,
focused,
enabled: !self.pending,
},
context.theme,
);
if !self.pending {
hits.register(buttons[index], AppAction::OpenUserGroup(entry.group));
}
}
}
fn render_overlay(
&self,
frame: &mut Frame,
rect: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let Some(overlay) = &self.overlay else {
return;
};
let area = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 56,
height: 12,
},
);
frame.render_widget(Clear, area);
let block = Block::default()
.title(match overlay {
UsersOverlay::Add(flow) => match flow.phase {
AddUserPhase::ChooseMethod => " Add User ",
AddUserPhase::ConfigureCreate => " Create User ",
_ => " Add User ",
},
UsersOverlay::UserMenu(menu) => menu.group.label(),
UsersOverlay::Confirm(confirm) => confirm.title(),
UsersOverlay::CredentialExport(_) => " Export TU ",
})
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let inner = block.inner(area);
frame.render_widget(block, area);
match overlay {
UsersOverlay::Add(flow) => match flow.phase {
AddUserPhase::ChooseMethod => {
let lines = flow
.methods
.items()
.iter()
.enumerate()
.map(|(index, item)| {
Line::from(format!(
"{} {}{}",
if flow.methods.selected_index() == Some(index) {
">"
} else {
" "
},
item.label,
item.disabled_reason
.as_ref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default()
))
})
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), inner);
}
AddUserPhase::ConfigureCreate => {
let value = flow.username.display_value();
let message = flow
.error
.as_deref()
.unwrap_or("Enter selects Create. Esc returns to methods.");
frame.render_widget(
Paragraph::new(format!("Username [{value}]\n\n{message}")),
inner,
);
}
AddUserPhase::ConfigureImport => {
let value = flow.import_path.display_value();
let message = flow
.error
.as_deref()
.unwrap_or("Enter inspects the credential. Esc returns to methods.");
frame.render_widget(
Paragraph::new(format!("TU path [{value}]\n\n{message}")),
inner,
);
}
AddUserPhase::InspectingTu => {
frame.render_widget(Paragraph::new("Inspecting credential…"), inner);
}
AddUserPhase::ReviewImport => {
if let Some(preview) = &flow.preview {
let assignment = preview
.assigned_iota_id
.map(|value| value.to_string())
.unwrap_or_else(|| "Unassigned".into());
frame.render_widget(
Paragraph::new(format!(
"Username {}\nUser ID {}\nCurrent Iota {}\nDestination This Iota\n\nEnter attaches. Esc returns to the path.",
preview.username, preview.user_id, assignment
)),
inner,
);
}
}
_ => frame.render_widget(Paragraph::new(invitations::UNSUPPORTED_MESSAGE), inner),
},
UsersOverlay::UserMenu(menu) => {
let lines = menu
.menu
.items()
.iter()
.enumerate()
.map(|(index, item)| {
Line::from(format!(
"{} {}{}",
if menu.menu.selected_index() == Some(index) {
">"
} else {
" "
},
item.label,
item.disabled_reason
.as_ref()
.map(|reason| format!(" ({reason})"))
.unwrap_or_default()
))
})
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(lines), inner);
}
UsersOverlay::Confirm(confirm) => {
let rows =
Layout::vertical([Constraint::Min(5), Constraint::Length(3)]).split(inner);
frame.render_widget(Paragraph::new(confirm.message()), rows[0]);
let buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_button(
frame,
buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
frame,
buttons[1],
ActionButton {
label: confirm.confirm_label(),
intent: ButtonIntent::Destructive,
focused: true,
enabled: !self.pending,
},
context.theme,
);
hits.register(buttons[0], AppAction::CancelDialog);
hits.register(buttons[1], AppAction::ConfirmDialog);
}
UsersOverlay::CredentialExport(export) => {
let value = export.destination.display_value();
let message = export
.error
.as_deref()
.unwrap_or("Enter exports the TU. Esc cancels.");
frame.render_widget(
Paragraph::new(format!(
"User {}\nDestination [{value}]\n\n{message}",
export.user.username
)),
inner,
);
}
}
}
}
impl Screen for UsersScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
rect: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let outer = Block::default()
.title(" User management ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = outer.inner(rect);
frame.render_widget(outer, rect);
let rows = Layout::vertical([
Constraint::Length(3),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
let header = Layout::horizontal([
Constraint::Length(10),
Constraint::Length(14),
Constraint::Min(20),
Constraint::Length(18),
])
.split(rows[0]);
for (index, (label, tab)) in [
("Users", UserAdminTab::Users),
("Invitations", UserAdminTab::Invitations),
]
.into_iter()
.enumerate()
{
render_button(
frame,
header[index],
ActionButton {
label,
intent: ButtonIntent::Neutral,
focused: self.active_tab == tab,
enabled: !self.pending,
},
context.theme,
);
if !self.pending {
hits.register(header[index], AppAction::SetUserAdminTab(tab));
}
}
frame.render_widget(
Paragraph::new(if self.active_tab == UserAdminTab::Users {
format!("Search [{}]", self.filter)
} else {
"Pending, redeemed, revoked, and expired invitations".into()
})
.style(if self.filtering {
context.theme.text.normal
} else {
context.theme.text.muted
}),
header[2],
);
let add_enabled = !self.pending
&& (self.active_tab == UserAdminTab::Users || self.invitation_supported());
render_button(
frame,
header[3],
ActionButton {
label: if self.active_tab == UserAdminTab::Users {
"+ Add User"
} else {
"+ New Invitation"
},
intent: ButtonIntent::Primary,
focused: self.focus_zone == FocusZone::Toolbar && self.toolbar_index == 0,
enabled: add_enabled,
},
context.theme,
);
if add_enabled {
hits.register(header[3], AppAction::AddUser);
}
let panes = if rows[1].width >= 70 {
Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(rows[1])
} else {
Layout::horizontal([Constraint::Percentage(100), Constraint::Length(0)]).split(rows[1])
};
if self.active_tab == UserAdminTab::Users {
self.render_list(frame, panes[0], context, hits);
if panes[1].width > 0 {
self.render_context(frame, panes[1], context, hits);
}
} else {
invitations::render(frame, rows[1], context, self.invitation_supported());
}
if let Some(message) = &self.message {
frame.render_widget(
Paragraph::new(message.as_str()).style(context.theme.text.muted),
rows[2],
);
}
self.render_overlay(frame, rect, context, hits);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
match event {
UiEvent::App(AppEvent::UsersLoaded(result)) => {
self.loading = false;
match result {
Ok(users) => {
self.users = users;
self.restore_selection();
}
Err(error) => self.message = Some(error),
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserOperationFinished(result)) => {
self.pending = false;
self.message = Some(match result {
Ok(message) => message,
Err(error) => error,
});
return self.refresh();
}
UiEvent::App(AppEvent::CredentialExportFinished(result)) => {
self.pending = false;
self.message = Some(match result {
Ok(message) => message,
Err(error) => error,
});
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::TuInspected(result)) => {
if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() {
match result {
Ok(preview) => {
flow.inspection_succeeded(preview);
}
Err(error) => {
flow.inspection_failed(error);
}
}
}
return InteractionResult::Handled;
}
UiEvent::Paste(text) => {
if let Some(UsersOverlay::Add(flow)) = self.overlay.as_mut() {
let sanitized = text.replace(['\r', '\n'], " ");
let input = match flow.phase {
AddUserPhase::ConfigureCreate => Some(&mut flow.username),
AddUserPhase::ConfigureImport => Some(&mut flow.import_path),
_ => None,
};
if let Some(input) = input {
for character in sanitized.chars() {
input.handle_key(KeyCode::Char(character));
}
return InteractionResult::Handled;
}
}
if let Some(UsersOverlay::CredentialExport(export)) = self.overlay.as_mut() {
for character in text.replace(['\r', '\n'], " ").chars() {
export.destination.handle_key(KeyCode::Char(character));
}
return InteractionResult::Handled;
}
if self.filtering {
self.filter.push_str(&text.replace(['\r', '\n'], " "));
self.restore_selection();
return InteractionResult::Handled;
}
return InteractionResult::Unhandled;
}
UiEvent::Key(key) => {
if let Some(overlay) = &mut self.overlay {
match overlay {
UsersOverlay::Add(flow) => match flow.phase {
AddUserPhase::ChooseMethod => {
if flow.methods.handle_key(key.code) {
return InteractionResult::Handled;
}
match key.code {
KeyCode::Enter => {
flow.choose();
InteractionResult::Handled
}
KeyCode::Esc => {
self.overlay = None;
InteractionResult::Handled
}
_ => InteractionResult::Handled,
}
}
AddUserPhase::ConfigureCreate => match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ChooseMethod;
InteractionResult::Handled
}
KeyCode::Enter => {
let username = flow.username.value().trim().to_owned();
if username.is_empty() {
flow.error = Some("A username is required.".into());
InteractionResult::Handled
} else {
self.start_create(username)
}
}
_ => {
flow.username.handle_key(key.code);
InteractionResult::Handled
}
},
AddUserPhase::ConfigureImport => match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ChooseMethod;
InteractionResult::Handled
}
KeyCode::Enter => {
let path = flow.import_path.value().trim();
if path.is_empty() {
flow.error = Some("A TU path is required.".into());
InteractionResult::Handled
} else {
match std::fs::read_to_string(path) {
Ok(contents) => self.start_tu_inspection(
iota_ipc::SecretString(contents),
),
Err(error) => {
flow.error = Some(format!(
"Cannot read credential {path}: {error}"
));
InteractionResult::Handled
}
}
}
}
_ => {
flow.import_path.handle_key(key.code);
InteractionResult::Handled
}
},
AddUserPhase::ReviewImport => match key.code {
KeyCode::Esc => {
flow.phase = AddUserPhase::ConfigureImport;
InteractionResult::Handled
}
KeyCode::Enter => {
if let Some(credential) = flow.credential.clone() {
flow.attachment_started();
self.start_tu_attachment(credential)
} else {
flow.error =
Some("Credential must be inspected again.".into());
flow.phase = AddUserPhase::ConfigureImport;
InteractionResult::Handled
}
}
_ => InteractionResult::Handled,
},
_ => {
self.overlay = None;
InteractionResult::Handled
}
},
UsersOverlay::UserMenu(menu) => {
if menu.menu.handle_key(key.code) {
return InteractionResult::Handled;
}
match key.code {
KeyCode::Esc => {
self.overlay = None;
InteractionResult::Handled
}
KeyCode::Enter => {
let action = menu.menu.selected_item().map(|item| item.value);
action.map_or(InteractionResult::Handled, |action| {
self.begin_action(action)
})
}
_ => InteractionResult::Handled,
}
}
UsersOverlay::Confirm(confirm) => match key.code {
KeyCode::Esc => {
self.overlay = None;
InteractionResult::Handled
}
KeyCode::Enter => {
let action = confirm.action;
let user_id = confirm.user.user_id;
self.start_operation(action, user_id)
}
_ => InteractionResult::Handled,
},
UsersOverlay::CredentialExport(export) => match key.code {
KeyCode::Esc => {
self.overlay = None;
InteractionResult::Handled
}
KeyCode::Enter => {
let destination = export.destination.value().trim();
if destination.is_empty() {
export.error = Some("A destination path is required.".into());
InteractionResult::Handled
} else {
let user_id = export.user.user_id;
let destination = std::path::PathBuf::from(destination);
self.start_credential_export(user_id, destination)
}
}
_ => {
export.destination.handle_key(key.code);
InteractionResult::Handled
}
},
}
} else if self.active_tab == UserAdminTab::Invitations {
match key.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('a') | KeyCode::Char('A') => {
if self.invitation_supported() {
self.overlay = Some(UsersOverlay::Add(self.new_add_flow()));
} else {
self.message = Some(invitations::UNSUPPORTED_MESSAGE.into());
}
InteractionResult::Handled
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.active_tab = UserAdminTab::Users;
self.focus_zone = FocusZone::UserList;
InteractionResult::Handled
}
KeyCode::Char('i') | KeyCode::Char('I') => InteractionResult::Handled,
_ => InteractionResult::Unhandled,
}
} else if self.filtering {
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.restore_selection();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.restore_selection();
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(character);
self.restore_selection();
}
_ => {}
};
InteractionResult::Handled
} else {
match key.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('/') => {
self.filtering = true;
InteractionResult::Handled
}
KeyCode::Char('a') | KeyCode::Char('A') => {
self.overlay = Some(UsersOverlay::Add(self.new_add_flow()));
InteractionResult::Handled
}
KeyCode::Char('i') | KeyCode::Char('I') => {
self.active_tab = UserAdminTab::Invitations;
self.filtering = false;
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_selection(1);
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.move_selection(-1);
InteractionResult::Handled
}
KeyCode::PageDown => {
self.move_selection(
self.viewport_height.load(Ordering::Relaxed) as isize
);
InteractionResult::Handled
}
KeyCode::PageUp => {
self.move_selection(
-(self.viewport_height.load(Ordering::Relaxed) as isize),
);
InteractionResult::Handled
}
KeyCode::Home => {
self.selected_user_id =
self.visible_users().first().map(|user| user.user_id);
self.keep_selected_visible();
InteractionResult::Handled
}
KeyCode::End => {
self.selected_user_id =
self.visible_users().last().map(|user| user.user_id);
self.keep_selected_visible();
InteractionResult::Handled
}
KeyCode::Tab => {
self.focus_zone = match self.focus_zone {
FocusZone::Toolbar => FocusZone::Search,
FocusZone::Search => FocusZone::UserList,
FocusZone::UserList => FocusZone::UserActions,
FocusZone::UserActions => FocusZone::Toolbar,
};
InteractionResult::Handled
}
KeyCode::Left if self.focus_zone == FocusZone::UserActions => {
self.action_group_index = self.action_group_index.saturating_sub(1);
InteractionResult::Handled
}
KeyCode::Right if self.focus_zone == FocusZone::UserActions => {
let last = self.groups().len().saturating_sub(1);
self.action_group_index = (self.action_group_index + 1).min(last);
InteractionResult::Handled
}
KeyCode::Enter if self.focus_zone == FocusZone::Toolbar => {
self.overlay = Some(UsersOverlay::Add(self.new_add_flow()));
InteractionResult::Handled
}
KeyCode::Enter if self.focus_zone == FocusZone::UserActions => {
if let Some(entry) = self.groups().get(self.action_group_index) {
self.overlay = Some(UsersOverlay::UserMenu(UserActionMenu::new(
entry.group,
entry.actions.clone(),
self.credential_export_supported(),
)));
}
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::AddUser => {
if self.active_tab == UserAdminTab::Invitations && !self.invitation_supported() {
self.message = Some(invitations::UNSUPPORTED_MESSAGE.into());
} else {
self.overlay = Some(UsersOverlay::Add(self.new_add_flow()));
}
InteractionResult::Handled
}
AppAction::SetUserAdminTab(tab) if self.overlay.is_none() => {
self.active_tab = tab;
self.filtering = false;
self.focus_zone = if tab == UserAdminTab::Users {
FocusZone::UserList
} else {
FocusZone::Toolbar
};
InteractionResult::Handled
}
AppAction::SelectUser(id) if self.overlay.is_none() => {
self.selected_user_id = Some(id);
self.focus_zone = FocusZone::UserList;
self.keep_selected_visible();
InteractionResult::Handled
}
AppAction::OpenUserGroup(group) if self.overlay.is_none() => {
if let Some(entry) = self.groups().into_iter().find(|entry| entry.group == group) {
self.overlay = Some(UsersOverlay::UserMenu(UserActionMenu::new(
group,
entry.actions,
self.credential_export_supported(),
)));
}
InteractionResult::Handled
}
AppAction::ActivateUserAction(action) => self.begin_action(action),
AppAction::ConfirmDialog => match &self.overlay {
Some(UsersOverlay::Confirm(confirm)) => {
self.start_operation(confirm.action, confirm.user.user_id)
}
_ => InteractionResult::Unhandled,
},
AppAction::CancelDialog => {
self.overlay = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.overlay.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Select",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "↑/↓",
action: "Select user",
},
KeyHint {
keys: "A",
action: "Add user",
},
KeyHint {
keys: "/",
action: "Search",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}