mahbot 0.4.1

An autonomous agentic engineering system that manages software development through role separation, subagents, and deterministic diagnostics.
Documentation
//! Users dashboard page — manage user preferences.

use crate::Role;
use crate::users::{FieldUpdate, UserRecord, UserStore};

use std::collections::HashMap;

use strum::IntoEnumIterator;

use iced::Task;

/// De-duplicated access to the global user store.
pub(crate) fn user_store() -> Result<&'static UserStore, String> {
    crate::users::USER_STORE
        .get()
        .ok_or_else(|| "User store not initialized".to_string())
}

/// Run a single-field `update_user`, mapping an empty value to
/// [`FieldUpdate::Clear`]. `is_role` selects which column is updated.
async fn update_user_field(sender: String, value: String, is_role: bool) -> Result<(), String> {
    let store = user_store()?;
    let val = if value.is_empty() {
        FieldUpdate::Clear
    } else {
        FieldUpdate::Set(&value)
    };
    let (role, ws) = if is_role {
        (val, FieldUpdate::Unchanged)
    } else {
        (FieldUpdate::Unchanged, val)
    };
    store
        .update_user(&sender, role, ws, FieldUpdate::Unchanged)
        .await
        .map_err(|e| e.to_string())
}

#[derive(Debug, Clone)]
pub enum UsersMessage {
    Refreshed(Vec<UserRecord>, Vec<super::widgets::PickOption>),
    RefreshError(String),
    UpdateRole(String, String),
    UpdateWorkspace(String, String),
    UpdateResult(Result<(), String>),
    /// Result of an active-role change via the users-table picker.
    RoleUpdateResult(Result<(), String>),
    DeleteUser(String),
    ConfirmDelete(String),
    CancelDelete,
    DeleteResult(Result<(), String>, String),

    /// Switch active user to this one (icon button on users page).
    SwitchUser(String),

    /// Open the inline Telegram binding input for a user.
    OpenBindInput(String),
    /// Close the inline binding input.
    CloseBindInput,
    /// Inline binding text input changed.
    BindInputChanged(String),
    /// Confirm binding the entered Telegram username to the target user.
    SubmitBind(String),
    /// Unbind a Telegram channel from a user.
    UnbindChannel(String, String),
    /// Result of a bind/unbind operation.
    BindResult(Result<(), String>, String),

    /// Open the role-pool editor for a user.
    OpenPoolEdit(String),
    /// Close the role-pool editor.
    ClosePoolEdit,
    /// Toggle a role checkbox in the pool editor (index into [`Role::iter`]).
    TogglePoolRole(usize),
    /// Save the edited role pool.
    SubmitPoolEdit(String),
    /// Result of saving a role pool.
    PoolEditResult(Result<(), String>),

    /// Dismiss modals/panels (Escape key).
    Escape,

    /// Request toast notification.
    Toast(super::ToastMessage),
}

pub struct UsersState {
    pub(crate) users: Vec<UserRecord>,
    pub(crate) load_state: super::common::AsyncLoadState,

    // Dropdown options (populated on refresh)
    pub(crate) workspace_options: Vec<super::widgets::PickOption>,
    pub(crate) role_options: Vec<super::widgets::PickOption>,
    /// Per-user active-role picker options, restricted to each user's pool.
    pub(crate) active_role_options: HashMap<String, Vec<super::widgets::PickOption>>,

    // Delete confirmation
    pub(crate) delete_target: Option<String>,
    pub(crate) deleting: bool,

    // Telegram binding inline input (single-target, like delete_target)
    pub(crate) bind_target: Option<String>,
    pub(crate) bind_input: String,
    pub(crate) bind_error: Option<String>,
    pub(crate) binding: bool,

    // Role-pool editor (modal, single-target)
    pub(crate) pool_edit_target: Option<String>,
    pub(crate) pool_edit_checked: Vec<bool>,
}

impl UsersState {
    #[must_use]
    pub fn new() -> Self {
        Self {
            users: Vec::new(),
            load_state: super::common::AsyncLoadState::new(),
            workspace_options: Vec::new(),
            role_options: Vec::new(),
            active_role_options: HashMap::new(),
            delete_target: None,
            deleting: false,
            bind_target: None,
            bind_input: String::new(),
            bind_error: None,
            binding: false,
            pool_edit_target: None,
            pool_edit_checked: Vec::new(),
        }
    }

    #[allow(clippy::unused_self)]
    pub fn refresh(&self) -> Task<UsersMessage> {
        Task::perform(
            async {
                let store = user_store()?;
                let users = store.list_users().await.map_err(|e| e.to_string())?;

                // Also load workspace options — prepend "Personal" for NULL.
                let mut ws_options = Vec::new();
                ws_options.push(super::widgets::PickOption {
                    value: String::new(),
                    label: "Personal".to_string(),
                });
                if let Ok(ws_list) = crate::workspace::store().list().await {
                    for ws in ws_list {
                        let display = ws.display_name();
                        ws_options.push(super::widgets::PickOption {
                            value: ws.name,
                            label: display,
                        });
                    }
                }

                Ok::<_, String>((users, ws_options))
            },
            |res| match res {
                Ok((users, ws_options)) => UsersMessage::Refreshed(users, ws_options),
                Err(e) => UsersMessage::RefreshError(e),
            },
        )
    }

    #[expect(clippy::too_many_lines)]
    pub fn update(&mut self, msg: UsersMessage) -> Task<UsersMessage> {
        match msg {
            UsersMessage::Refreshed(users, ws_options) => {
                self.users = users;
                self.load_state.finish_loading();

                self.workspace_options = ws_options;

                // Build role options from Role::iter()
                self.role_options = Role::iter()
                    .map(|r| {
                        let name = r.to_string();
                        super::widgets::PickOption {
                            value: name.clone(),
                            label: name,
                        }
                    })
                    .collect();

                // Per-user active-role options, restricted to each user's pool.
                self.active_role_options = self
                    .users
                    .iter()
                    .map(|u| {
                        let options = u
                            .roles
                            .iter()
                            .filter_map(|name| {
                                self.role_options.iter().find(|o| o.value == *name).cloned()
                            })
                            .collect();
                        (u.name.clone(), options)
                    })
                    .collect();

                Task::none()
            }
            UsersMessage::RefreshError(e) => {
                self.load_state.fail(e);
                Task::none()
            }
            UsersMessage::UpdateRole(sender, role) => Task::perform(
                async move { update_user_field(sender, role, true).await },
                UsersMessage::RoleUpdateResult,
            ),
            UsersMessage::UpdateWorkspace(sender, ws) => Task::perform(
                async move { update_user_field(sender, ws, false).await },
                UsersMessage::UpdateResult,
            ),
            UsersMessage::UpdateResult(Ok(())) | UsersMessage::RoleUpdateResult(Ok(())) => {
                self.refresh()
            }
            UsersMessage::UpdateResult(Err(e)) | UsersMessage::RoleUpdateResult(Err(e)) => {
                self.load_state.fail(e.clone());
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
            UsersMessage::DeleteUser(sender) => {
                self.delete_target = Some(sender);
                Task::none()
            }
            UsersMessage::ConfirmDelete(sender) => {
                self.delete_target = None;
                self.deleting = true;
                let s = sender;
                let s_clone = s.clone();
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store.delete_user(&s).await.map_err(|e| e.to_string())
                    },
                    move |res| UsersMessage::DeleteResult(res, s_clone),
                )
            }
            UsersMessage::CancelDelete | UsersMessage::Escape => {
                self.delete_target = None;
                self.bind_target = None;
                self.bind_input.clear();
                self.bind_error = None;
                self.pool_edit_target = None;
                self.pool_edit_checked.clear();
                Task::none()
            }
            UsersMessage::DeleteResult(Ok(()), _deleted_user) => {
                self.deleting = false;
                self.load_state.clear_error();
                self.refresh()
            }
            UsersMessage::DeleteResult(Err(e), _deleted_user) => {
                self.deleting = false;
                self.load_state.fail(e.clone());
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
            UsersMessage::Toast(_) => Task::none(),
            UsersMessage::SwitchUser(_) => {
                // Intercepted by Dashboard — no-op in UsersState.
                Task::none()
            }
            UsersMessage::OpenBindInput(user_name) => {
                self.bind_target = Some(user_name);
                self.bind_input.clear();
                self.bind_error = None;
                // Also cancel any pending delete confirmation (mutual exclusion).
                self.delete_target = None;
                Task::none()
            }
            UsersMessage::CloseBindInput => {
                self.bind_target = None;
                self.bind_input.clear();
                self.bind_error = None;
                Task::none()
            }
            UsersMessage::BindInputChanged(v) => {
                self.bind_input = v;
                self.bind_error = None;
                Task::none()
            }
            UsersMessage::SubmitBind(user_name) => {
                if self.bind_input.trim().is_empty() {
                    self.bind_error = Some("Telegram username required".into());
                    return Task::none();
                }
                self.binding = true;
                self.bind_error = None;
                // Strip leading @ if the admin typed it.
                let mut identifier = self.bind_input.trim().to_string();
                if let Some(stripped) = identifier.strip_prefix('@') {
                    identifier = stripped.to_string();
                }
                let user_clone = user_name.clone();
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store
                            .bind_channel(&user_clone, "telegram", &identifier)
                            .await
                            .map_err(|e| e.to_string())
                    },
                    move |res| UsersMessage::BindResult(res, user_name),
                )
            }
            UsersMessage::UnbindChannel(user_name, identifier) => {
                self.binding = true;
                self.bind_error = None;
                let user_clone = user_name.clone();
                Task::perform(
                    async move {
                        let store = user_store()?;
                        store
                            .unbind_channel(&user_clone, "telegram", &identifier)
                            .await
                            .map_err(|e| e.to_string())
                    },
                    move |res| UsersMessage::BindResult(res, user_name),
                )
            }
            UsersMessage::BindResult(Ok(()), _user_name) => {
                self.binding = false;
                self.bind_target = None;
                self.bind_input.clear();
                self.bind_error = None;
                self.refresh()
            }
            UsersMessage::BindResult(Err(e), user_name) => {
                self.binding = false;
                if self.bind_target.as_deref() == Some(&user_name) {
                    self.bind_error = Some(format!("Failed to bind: {e}"));
                } else {
                    self.load_state.fail(format!("Failed to unbind: {e}"));
                }
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
            UsersMessage::OpenPoolEdit(user_name) => {
                // Seed the checkbox state from the user's current pool.
                let checked = self.users.iter().find(|u| u.name == user_name).map_or_else(
                    || Role::iter().map(|_| false).collect(),
                    |u| {
                        Role::iter()
                            .map(|r| u.roles.iter().any(|name| name == r.as_str()))
                            .collect()
                    },
                );
                self.pool_edit_target = Some(user_name);
                self.pool_edit_checked = checked;
                // Also cancel any pending delete confirmation / bind input (mutual exclusion).
                self.delete_target = None;
                self.bind_target = None;
                Task::none()
            }
            UsersMessage::ClosePoolEdit => {
                self.pool_edit_target = None;
                self.pool_edit_checked.clear();
                Task::none()
            }
            UsersMessage::TogglePoolRole(idx) => {
                if let Some(checked) = self.pool_edit_checked.get_mut(idx) {
                    *checked = !*checked;
                }
                Task::none()
            }
            UsersMessage::SubmitPoolEdit(user_name) => {
                let roles: Vec<Role> = Role::iter()
                    .zip(self.pool_edit_checked.iter())
                    .filter(|(_, checked)| **checked)
                    .map(|(r, _)| r)
                    .collect();
                let Some(store) = crate::users::USER_STORE.get() else {
                    return Task::none();
                };
                let name = user_name.clone();
                Task::perform(
                    async move {
                        store
                            .set_user_roles(&name, &roles)
                            .await
                            .map_err(|e| e.to_string())
                    },
                    UsersMessage::PoolEditResult,
                )
            }
            UsersMessage::PoolEditResult(Ok(())) => {
                self.pool_edit_target = None;
                self.pool_edit_checked.clear();
                self.refresh()
            }
            UsersMessage::PoolEditResult(Err(e)) => {
                Task::done(UsersMessage::Toast(super::ToastMessage::Error(e)))
            }
        }
    }
}