//! Servicio de administración de usuarios: listado, CRUD, roles y estado. use std::collections::HashMap; use pagetop::prelude::*; use pagetop_seaorm::db::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, EntityTrait, Order, Paginated, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, dbconn, flatten_txn_err, paginate, }; use crate::account::UserStatus; use crate::entity::{role, user, user_role}; use crate::error::AuthError; use crate::password; use crate::session; // **< listado >************************************************************************************ #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(crate) enum UserSortField { #[default] Username, Email, CreatedAt, } impl UserSortField { pub(crate) fn from_query(s: Option<&str>) -> Self { match s { Some("email") => UserSortField::Email, Some("created_at") => UserSortField::CreatedAt, _ => UserSortField::Username, } } pub(crate) fn as_str(self) -> &'static str { match self { UserSortField::Username => "username", UserSortField::Email => "email", UserSortField::CreatedAt => "created_at", } } } #[derive(Clone, Debug)] pub(crate) struct UserListItem { pub id: i32, pub username: String, pub email: String, pub display_name: Option, pub status: UserStatus, pub roles: Vec, pub is_admin: bool, } pub(crate) struct UserListParams { pub query: Option, pub sort: UserSortField, pub dir: SortDir, pub page: u64, pub per_page: u64, } /// Devuelve una página de usuarios. Usado por el listado de administración de usuarios. pub(crate) async fn list_users( params: &UserListParams, ) -> Result, AuthError> { let mut select = user::Entity::find(); if let Some(q) = params.query.as_deref().and_then(util::non_blank) { select = select.filter( Condition::any() .add(user::Column::Username.contains(q)) .add(user::Column::Email.contains(q)) .add(user::Column::DisplayName.contains(q)), ); } let order = if params.dir == SortDir::Desc { Order::Desc } else { Order::Asc }; select = match params.sort { UserSortField::Username => select.order_by(user::Column::Username, order), UserSortField::Email => select.order_by(user::Column::Email, order), UserSortField::CreatedAt => select.order_by(user::Column::CreatedAt, order), }; paginate(select, params.page, params.per_page) .await? .map_items(user_items) .await } async fn user_items(users: Vec) -> Result, AuthError> { let user_ids: Vec = users.iter().map(|u| u.id).collect(); let role_rows = user_role::Entity::find() .filter(user_role::Column::UserId.is_in(user_ids)) .find_also_related(role::Entity) .all(dbconn()) .await?; let mut roles_by_user: HashMap> = HashMap::new(); for (ur, role) in role_rows { if let Some(role) = role { roles_by_user .entry(ur.user_id) .or_default() .push(role.machine_name); } } Ok(users .into_iter() .map(|u| UserListItem { id: u.id, username: u.username, email: u.email, display_name: u.display_name, status: UserStatus::from_i16(u.status), roles: roles_by_user.remove(&u.id).unwrap_or_default(), is_admin: u.is_admin, }) .collect()) } // **< find_user / user_role_ids >****************************************************************** pub(crate) async fn find_user(user_id: i32) -> Result { user::Entity::find_by_id(user_id) .one(dbconn()) .await? .ok_or(AuthError::UserNotFound) } pub(crate) async fn user_role_ids(user_id: i32) -> Result, AuthError> { let rows = user_role::Entity::find() .filter(user_role::Column::UserId.eq(user_id)) .all(dbconn()) .await?; Ok(rows.into_iter().map(|r| r.role_id).collect()) } pub(crate) async fn user_roles(user_id: i32) -> Result, AuthError> { let role_ids = user_role_ids(user_id).await?; Ok(role::Entity::find() .filter(role::Column::Id.is_in(role_ids)) .order_by(role::Column::Weight, Order::Asc) .all(dbconn()) .await?) } // **< create_user >******************************************************************************** pub(crate) struct NewUserData<'a> { pub username: &'a str, pub email: &'a str, pub password: &'a str, pub confirm_password: &'a str, pub display_name: Option<&'a str>, pub language: Option<&'a str>, pub timezone: Option<&'a str>, pub theme: Option<&'a str>, pub initial_role_ids: &'a [i32], /// El *caller* es responsable de comprobar que sólo un administrador puede pasar `true`. pub is_admin: bool, } /// Da de alta un usuario administrativamente. A diferencia de `auth::register`, el usuario queda /// activo y con el email verificado de inmediato (lo crea un administrador de confianza), y admite /// asignar roles iniciales. pub(crate) async fn create_user(data: NewUserData<'_>) -> Result { password::validate_strength(data.password)?; password::passwords_match(data.password, data.confirm_password)?; let language = validate_language(data.language)?; let timezone = validate_timezone(data.timezone)?; let theme = validate_theme(data.theme)?; ensure_username_available(data.username, None).await?; ensure_email_available(data.email, None).await?; let hash = password::hash_password(data.password)?; let now = Utc::now(); let new_user = user::ActiveModel { id: ActiveValue::NotSet, username: Set(data.username.to_owned()), email: Set(data.email.to_owned()), email_verified_at: Set(Some(now)), password_hash: Set(hash), status: Set(UserStatus::Active.as_i16()), language: Set(language.map(str::to_owned)), timezone: Set(timezone.map(str::to_owned)), theme: Set(theme.map(str::to_owned)), display_name: Set(data.display_name.map(str::to_owned)), about: Set(None), last_login_at: Set(None), last_access_at: Set(None), failed_login_count: Set(0), locked_until: Set(None), is_admin: Set(data.is_admin), created_at: Set(now), updated_at: Set(now), }; let result = user::Entity::insert(new_user).exec(dbconn()).await?; let user_id = result.last_insert_id; for role_id in data.initial_role_ids { crate::auth::assign_role(user_id, *role_id).await?; } Ok(user_id) } // **< update_user >******************************************************************************** /// Longitud máxima, en caracteres, del texto "Sobre mí". `u16` porque también limita el campo del /// formulario (`maxlength`). pub(crate) const ABOUT_MAX_CHARS: u16 = 2000; pub(crate) struct UserUpdateData<'a> { pub username: &'a str, pub email: &'a str, pub display_name: Option<&'a str>, pub about: Option<&'a str>, pub language: Option<&'a str>, pub timezone: Option<&'a str>, pub theme: Option<&'a str>, } pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> { let language = validate_language(data.language)?; let timezone = validate_timezone(data.timezone)?; let theme = validate_theme(data.theme)?; // El navegador envía los saltos de línea de un `