[Add] Proper User managment

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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