✨ (user): Asigna roles desde la edición de usuario
This commit is contained in:
parent
806ed9e0dc
commit
f819181872
26 changed files with 1219 additions and 544 deletions
|
|
@ -4,6 +4,8 @@ use std::collections::HashSet;
|
|||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::entity::user;
|
||||
|
||||
// **< UserStatus >*********************************************************************************
|
||||
|
||||
/// Estado de la cuenta de usuario.
|
||||
|
|
@ -59,39 +61,65 @@ impl PermissionSet {
|
|||
/// Se almacena en las extensiones de la petición HTTP durante la fase de middleware y se accede
|
||||
/// desde los handlers o desde handlers de [`CheckPermission`] mediante
|
||||
/// [`HttpRequest::extension::<Account>()`](pagetop::web::HttpRequest::extension).
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Getters)]
|
||||
pub struct Account {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub status: UserStatus,
|
||||
/// Identificador del usuario.
|
||||
id: i32,
|
||||
/// Nombre de usuario.
|
||||
username: String,
|
||||
/// Correo electrónico.
|
||||
email: String,
|
||||
/// Nombre para mostrar; cadena vacía si no está definido (ver [`Account::display`]).
|
||||
display_name: String,
|
||||
/// Estado de la cuenta.
|
||||
#[getters(copy)]
|
||||
status: UserStatus,
|
||||
/// Nombres de máquina de los roles asignados explícitamente. No incluye "authenticated", que
|
||||
/// es implícito (ver [`Account::has_role`]).
|
||||
pub roles: Vec<String>,
|
||||
/// Unión de permisos de todos sus roles.
|
||||
pub permissions: PermissionSet,
|
||||
/// `true` si alguno de sus roles tiene `is_admin = true`.
|
||||
pub is_admin: bool,
|
||||
roles: Vec<String>,
|
||||
// Unión de permisos de sus roles y del rol implícito "authenticated". Vacío si es
|
||||
// administrador: no se cargan porque `has_permission()` ya concede todos. Sin getter para que
|
||||
// la única consulta posible sea `has_permission()`.
|
||||
#[getters(skip)]
|
||||
permissions: PermissionSet,
|
||||
/// `true` si el usuario tiene acceso sin restricciones (`user.is_admin`), con independencia de
|
||||
/// sus roles.
|
||||
#[getters(copy)]
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
// Sólo se construye desde la carga de sesión, para que ningún otro crate pueda fabricar una
|
||||
// cuenta e inyectarla en la petición.
|
||||
pub(crate) fn new(user: user::Model, roles: Vec<String>, permissions: PermissionSet) -> Self {
|
||||
Account {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
display_name: user.display_name.unwrap_or_default(),
|
||||
status: UserStatus::from_i16(user.status),
|
||||
roles,
|
||||
permissions,
|
||||
is_admin: user.is_admin,
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprueba si la cuenta tiene el permiso indicado, teniendo en cuenta el flag `is_admin`.
|
||||
pub fn has_permission(&self, perm: PermissionRef) -> bool {
|
||||
self.is_admin || self.permissions.contains(perm.key().as_ref())
|
||||
self.is_admin() || self.permissions.contains(perm.key().as_ref())
|
||||
}
|
||||
|
||||
/// Devuelve el nombre visible: `display_name` si está definido, o `username`.
|
||||
pub fn display(&self) -> &str {
|
||||
if self.display_name.is_empty() {
|
||||
&self.username
|
||||
if self.display_name().is_empty() {
|
||||
self.username()
|
||||
} else {
|
||||
&self.display_name
|
||||
self.display_name()
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprueba si la cuenta tiene el rol indicado ("authenticated" siempre se cumple).
|
||||
pub fn has_role(&self, machine_name: &str) -> bool {
|
||||
machine_name == "authenticated" || self.roles.iter().any(|r| r == machine_name)
|
||||
machine_name == "authenticated" || self.roles().iter().any(|r| r == machine_name)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Lógica de autenticación: login, logout, registro, semilla inicial.
|
||||
//! Lógica de autenticación: login, logout, registro, cambio de contraseña, semilla inicial.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
use pagetop_seaorm::db::{
|
||||
|
|
@ -8,9 +8,10 @@ use pagetop_seaorm::db::{
|
|||
|
||||
use crate::account::UserStatus;
|
||||
use crate::config::SETTINGS;
|
||||
use crate::entity::{user, user_role};
|
||||
use crate::entity::{role_permission, user, user_role};
|
||||
use crate::error::AuthError;
|
||||
use crate::password;
|
||||
use crate::permission::UserPermission;
|
||||
use crate::session;
|
||||
|
||||
// **< login >**************************************************************************************
|
||||
|
|
@ -135,7 +136,9 @@ pub async fn register(
|
|||
status: Set(status.as_i16()),
|
||||
language: Set(None),
|
||||
timezone: Set(None),
|
||||
theme: Set(None),
|
||||
display_name: Set(None),
|
||||
about: Set(None),
|
||||
last_login_at: Set(None),
|
||||
last_access_at: Set(None),
|
||||
failed_login_count: Set(0),
|
||||
|
|
@ -171,6 +174,44 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// **< change_own_password >************************************************************************
|
||||
|
||||
/// Cambia la contraseña del propio usuario tras comprobar la actual, y cierra el resto de sus
|
||||
/// sesiones abiertas conservando la indicada en `current_sid` (la de quien hace el cambio).
|
||||
///
|
||||
/// Devuelve [`AuthError::InvalidCredentials`] si la contraseña actual no es correcta.
|
||||
pub(crate) async fn change_own_password(
|
||||
user_id: i32,
|
||||
current_password: &str,
|
||||
new_password: &str,
|
||||
current_sid: Option<&str>,
|
||||
) -> Result<(), AuthError> {
|
||||
let user_model = user::Entity::find_by_id(user_id)
|
||||
.one(dbconn())
|
||||
.await?
|
||||
.ok_or(AuthError::UserNotFound)?;
|
||||
if !password::verify_password(current_password, &user_model.password_hash) {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
password::validate_strength(new_password)?;
|
||||
let hash = password::hash_password(new_password)?;
|
||||
|
||||
user::ActiveModel {
|
||||
id: Set(user_id),
|
||||
password_hash: Set(hash),
|
||||
updated_at: Set(Utc::now()),
|
||||
..Default::default()
|
||||
}
|
||||
.update(dbconn())
|
||||
.await?;
|
||||
|
||||
match current_sid {
|
||||
Some(sid) => session::destroy_other_sessions(user_id, sid).await?,
|
||||
None => session::destroy_user_sessions(user_id).await?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// **< register_failed_login >**********************************************************************
|
||||
|
||||
async fn register_failed_login(
|
||||
|
|
@ -197,11 +238,15 @@ async fn register_failed_login(
|
|||
|
||||
// **< seed_initial_data >**************************************************************************
|
||||
|
||||
/// Crea el usuario administrador inicial si no existe ningún usuario en la base de datos.
|
||||
/// Prepara una instalación nueva si no existe ningún usuario en la base de datos.
|
||||
///
|
||||
/// Se llama desde `Extension::initialize()`. Si la tabla está vacía, crea el administrador
|
||||
/// con las credenciales configuradas en `[user.seed]`. La contraseña se genera aleatoriamente
|
||||
/// si no está configurada, y se imprime por stdout una sola vez para que el operador la recoja.
|
||||
///
|
||||
/// Concede además al rol "authenticated" los permisos que todo usuario espera tener sobre su
|
||||
/// propia cuenta: editar su perfil y cambiar su contraseña. Sólo en la instalación nueva; después
|
||||
/// los gestiona el administrador desde la interfaz.
|
||||
pub(crate) async fn seed_initial_data() {
|
||||
do_seed().await;
|
||||
}
|
||||
|
|
@ -246,7 +291,9 @@ async fn do_seed() {
|
|||
status: Set(UserStatus::Active.as_i16()),
|
||||
language: Set(None),
|
||||
timezone: Set(None),
|
||||
theme: Set(None),
|
||||
display_name: Set(Some("Administrator".into())),
|
||||
about: Set(None),
|
||||
last_login_at: Set(None),
|
||||
last_access_at: Set(None),
|
||||
failed_login_count: Set(0),
|
||||
|
|
@ -265,6 +312,28 @@ async fn do_seed() {
|
|||
);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("pagetop-user seed error: {}", e),
|
||||
Err(e) => {
|
||||
eprintln!("pagetop-user seed error: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let defaults = [
|
||||
UserPermission::EditOwnProfile,
|
||||
UserPermission::ChangeOwnPassword,
|
||||
];
|
||||
let rows = defaults.map(|perm| role_permission::ActiveModel {
|
||||
role_id: Set(crate::AUTHENTICATED_ROLE_ID),
|
||||
permission_key: Set(perm.key().into_owned()),
|
||||
granted_at: Set(now),
|
||||
});
|
||||
if let Err(e) = role_permission::Entity::insert_many(rows)
|
||||
.exec(dbconn())
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"pagetop-user seed error: failed to grant default permissions: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,75 @@
|
|||
pub(crate) mod admin;
|
||||
|
||||
mod account_menu;
|
||||
mod change_password_form;
|
||||
mod login_form;
|
||||
mod password_confirm;
|
||||
mod password_reset_confirm_form;
|
||||
mod password_reset_form;
|
||||
mod register_form;
|
||||
mod user_name;
|
||||
|
||||
pub use account_menu::{AccountMenu, account_menu};
|
||||
pub(crate) use change_password_form::ChangePasswordForm;
|
||||
pub use login_form::LoginForm;
|
||||
pub(crate) use password_confirm::PasswordConfirm;
|
||||
pub use password_reset_confirm_form::PasswordResetConfirmForm;
|
||||
pub use password_reset_form::PasswordResetForm;
|
||||
pub use register_form::RegisterForm;
|
||||
pub use user_name::UserName;
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
// Texto libre de varias líneas como contenido seguro: cada salto de línea se convierte en `<br>`,
|
||||
// sin depender del CSS del tema. Se usa para mostrar el "Sobre mí" del usuario.
|
||||
pub(crate) fn multiline_text(text: String) -> Html {
|
||||
Html::with(move |_| {
|
||||
html! {
|
||||
@for (i, line) in text.lines().enumerate() {
|
||||
@if i > 0 { br; }
|
||||
(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Nombre traducido del idioma guardado en el perfil de un usuario, o "-" si no tiene ninguno. Un
|
||||
// identificador que ya no esté entre los soportados se muestra tal cual.
|
||||
pub(crate) fn language_name(language: Option<&str>) -> Lc {
|
||||
let Some(code) = language else {
|
||||
return Lc::n("-");
|
||||
};
|
||||
Locale::supported_languages()
|
||||
.into_iter()
|
||||
.find(|(langid, _)| langid.to_string() == code)
|
||||
.map_or_else(|| Lc::n(code.to_owned()), |(_, name)| name)
|
||||
}
|
||||
|
||||
// Nombre traducido del tema guardado en el perfil de un usuario o, si no tiene ninguno, el del tema
|
||||
// predeterminado del sitio, que es el que se le aplica. Un tema que ya no esté habilitado se
|
||||
// muestra con el nombre guardado.
|
||||
pub(crate) fn theme_name(theme: Option<&str>) -> table::Cell {
|
||||
let Some(name) = theme else {
|
||||
// Los argumentos de `Lc` son texto fijo: el nombre del tema se traduce al renderizar.
|
||||
return Html::with(|cx| {
|
||||
let theme = default_theme();
|
||||
let name = theme
|
||||
.name()
|
||||
.lookup(cx)
|
||||
.unwrap_or_else(|| theme.short_name().to_owned());
|
||||
Lc::t("value-theme-site-default", &LOCALES_USER)
|
||||
.with_arg("theme", name)
|
||||
.using(cx)
|
||||
})
|
||||
.into();
|
||||
};
|
||||
theme_by_short_name(name)
|
||||
.map_or_else(|| Lc::n(name.to_owned()), |theme| theme.name())
|
||||
.into()
|
||||
}
|
||||
|
||||
// Banner de error de formulario; se renderiza vacío si `error` es `None`. Compartido por los
|
||||
// formularios de autenticación y por los de administración.
|
||||
pub(crate) fn error_banner(error: Option<Lc>) -> Html {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ mod role_form;
|
|||
mod role_permissions_form;
|
||||
mod role_table;
|
||||
mod user_form;
|
||||
mod user_roles_form;
|
||||
mod user_table;
|
||||
|
||||
pub(crate) use admin_password_form::AdminPasswordForm;
|
||||
|
|
@ -15,7 +14,6 @@ pub(crate) use role_form::{RoleForm, RoleFormMode};
|
|||
pub(crate) use role_permissions_form::RolePermissionsForm;
|
||||
pub(crate) use role_table::RoleTable;
|
||||
pub(crate) use user_form::{UserForm, UserFormMode};
|
||||
pub(crate) use user_roles_form::UserRolesForm;
|
||||
pub(crate) use user_table::{UserTable, status_key};
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
|
@ -41,10 +39,10 @@ pub(crate) type PermissionGroups = Vec<(Lc, Vec<PermissionItem>)>;
|
|||
|
||||
// **< HELPERS >************************************************************************************
|
||||
|
||||
// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla de
|
||||
// asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: todo usuario
|
||||
// autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), sin
|
||||
// necesidad de una fila en `user_role`.
|
||||
// `Fieldset` con las casillas para asignar roles (usado en el alta y en la edición de usuario). El
|
||||
// rol "authenticated" no se lista como casilla ni se envía: todo usuario autenticado lo tiene
|
||||
// concedido por definición (ver `session::load_user_from_session`), sin necesidad de una fila en
|
||||
// `user_role`.
|
||||
pub(crate) fn roles_fieldset(roles: &[(i32, String, bool)]) -> form::Fieldset {
|
||||
let mut field = form::check::Field::new().with_name("role_ids");
|
||||
for (role_id, label, checked) in roles {
|
||||
|
|
|
|||
|
|
@ -1,49 +1,23 @@
|
|||
//! Formulario de alta/edición de usuario.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
//! Formulario de alta/edición de usuario, también usado para que cada usuario edite su perfil.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
use crate::user_path;
|
||||
use crate::{ADMIN_USERS_PATH, PROFILE_EDIT_PATH};
|
||||
|
||||
use crate::component::{PasswordConfirm, error_banner};
|
||||
use crate::service::user_admin::ABOUT_MAX_CHARS;
|
||||
|
||||
use super::{USER_ADMIN_FORM_ID, roles_fieldset};
|
||||
|
||||
// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una
|
||||
// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`).
|
||||
const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"];
|
||||
|
||||
// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por
|
||||
// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`,
|
||||
// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final.
|
||||
static TZ_BY_REGION: LazyLock<Vec<(&'static str, Vec<&'static str>)>> = LazyLock::new(|| {
|
||||
let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new();
|
||||
for tz in TZ_VARIANTS.iter() {
|
||||
let name = tz.name();
|
||||
let Some((region, _)) = name.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") {
|
||||
continue;
|
||||
}
|
||||
regions.entry(region).or_default().push(name);
|
||||
}
|
||||
for names in regions.values_mut() {
|
||||
names.sort_unstable();
|
||||
}
|
||||
let etc = regions.remove_entry("Etc");
|
||||
regions.into_iter().chain(etc).collect()
|
||||
});
|
||||
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum UserFormMode {
|
||||
#[default]
|
||||
New,
|
||||
Edit,
|
||||
/// Edición del perfil propio: sin roles ni casilla de administrador.
|
||||
Profile,
|
||||
}
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
|
|
@ -56,12 +30,17 @@ pub(crate) struct UserForm {
|
|||
username: String,
|
||||
email: String,
|
||||
display_name: String,
|
||||
/// Texto "Sobre mí"; sólo se edita en los modos `Edit` y `Profile`, no en el alta.
|
||||
about: String,
|
||||
language: String,
|
||||
timezone: String,
|
||||
/// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`.
|
||||
theme: String,
|
||||
/// Roles asignables (excluye "anonymous" y "authenticated"); si no hay ninguno, no se muestra.
|
||||
roles: Vec<(i32, String, bool)>,
|
||||
/// Si se ofrece la casilla "administrador"; sólo cuando quien da de alta ya es administrador.
|
||||
allow_admin_field: bool,
|
||||
/// En modo `Profile`, si el nombre de usuario es editable; si no, se muestra de sólo lectura.
|
||||
allow_username_field: bool,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +54,10 @@ impl Component for UserForm {
|
|||
let action = match self.mode() {
|
||||
UserFormMode::New => util::join!(ADMIN_USERS_PATH, "/new"),
|
||||
UserFormMode::Edit => user_path(self.user_id().copied().unwrap_or_default(), "edit"),
|
||||
UserFormMode::Profile => PROFILE_EDIT_PATH.into(),
|
||||
};
|
||||
let username_readonly =
|
||||
*self.mode() == UserFormMode::Profile && !*self.allow_username_field();
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let mut form = Form::new()
|
||||
|
|
@ -89,6 +71,7 @@ impl Component for UserForm {
|
|||
.with_value(self.username())
|
||||
.with_label(Lc::t("field-username-admin", &LOCALES_USER))
|
||||
.with_required(true)
|
||||
.with_readonly(username_readonly)
|
||||
.with_maxlength(Some(64)),
|
||||
)
|
||||
.with_child(
|
||||
|
|
@ -105,33 +88,54 @@ impl Component for UserForm {
|
|||
.with_label(Lc::t("field-display-name", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
form::SelectLanguage::new()
|
||||
.with_name("language")
|
||||
.with_value(self.language())
|
||||
.with_label(Lc::t("field-language", &LOCALES_USER)),
|
||||
.with_label(Lc::t("field-language", &LOCALES_USER))
|
||||
.with_selected(self.language()),
|
||||
)
|
||||
.with_child(timezone_field(self.timezone()));
|
||||
.with_child(
|
||||
form::SelectTimezone::new()
|
||||
.with_name("timezone")
|
||||
.with_label(Lc::t("field-timezone", &LOCALES_USER))
|
||||
.with_selected(self.timezone()),
|
||||
)
|
||||
.with_child(
|
||||
form::SelectTheme::new()
|
||||
.with_name("theme")
|
||||
.with_label(Lc::t("field-theme", &LOCALES_USER))
|
||||
.with_selected(self.theme()),
|
||||
);
|
||||
|
||||
if *self.mode() == UserFormMode::New {
|
||||
form = form.with_child(PasswordConfirm::new());
|
||||
if !self.roles().is_empty() {
|
||||
form = form.with_child(roles_fieldset(self.roles()));
|
||||
}
|
||||
} else {
|
||||
form = form.with_child(
|
||||
form::Textarea::new()
|
||||
.with_name("about")
|
||||
.with_value(self.about())
|
||||
.with_label(Lc::t("field-about", &LOCALES_USER))
|
||||
.with_rows(Some(5))
|
||||
.with_maxlength(Some(ABOUT_MAX_CHARS)),
|
||||
);
|
||||
}
|
||||
|
||||
if *self.allow_admin_field() {
|
||||
form = form.with_child(
|
||||
form::Checkbox::check()
|
||||
.with_name("is_admin")
|
||||
.with_label(Lc::t("field-is-admin", &LOCALES_USER))
|
||||
.with_checked(*self.is_admin()),
|
||||
);
|
||||
}
|
||||
if *self.mode() != UserFormMode::Profile && !self.roles().is_empty() {
|
||||
form = form.with_child(roles_fieldset(self.roles()));
|
||||
}
|
||||
|
||||
if *self.mode() == UserFormMode::New && *self.allow_admin_field() {
|
||||
form = form.with_child(
|
||||
form::Checkbox::check()
|
||||
.with_name("is_admin")
|
||||
.with_label(Lc::t("field-is-admin", &LOCALES_USER))
|
||||
.with_checked(*self.is_admin()),
|
||||
);
|
||||
}
|
||||
|
||||
// En modo `Edit`, "Guardar" se renderiza fuera del formulario, junto al resto de acciones
|
||||
// de la pantalla (ver `USER_ADMIN_FORM_ID`); en modo `New` no hay ninguna botonera con la
|
||||
// que agruparlo, así que se queda aquí, dentro del propio `<form>`.
|
||||
if *self.mode() == UserFormMode::New {
|
||||
// de la pantalla (ver `USER_ADMIN_FORM_ID`); en los demás modos no hay ninguna botonera con
|
||||
// la que agruparlo, así que se queda aquí, dentro del propio `<form>`.
|
||||
if *self.mode() != UserFormMode::Edit {
|
||||
form = form.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
|
|
@ -181,6 +185,11 @@ impl UserForm {
|
|||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_about(mut self, about: impl Into<String>) -> Self {
|
||||
self.about = about.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_language(mut self, language: impl Into<String>) -> Self {
|
||||
self.language = language.into();
|
||||
self
|
||||
|
|
@ -191,6 +200,11 @@ impl UserForm {
|
|||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_theme(mut self, theme: impl Into<String>) -> Self {
|
||||
self.theme = theme.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self {
|
||||
self.roles = roles;
|
||||
self
|
||||
|
|
@ -201,34 +215,13 @@ impl UserForm {
|
|||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_allow_username_field(mut self, allow_username_field: bool) -> Self {
|
||||
self.allow_username_field = allow_username_field;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self {
|
||||
self.is_admin = is_admin;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// `<select>` de zona horaria: primero la opción de usar la predeterminada de la aplicación y luego
|
||||
// un `<optgroup>` por región. La etiqueta de cada opción es el nombre IANA completo.
|
||||
fn timezone_field(selected: &str) -> form::select::Field {
|
||||
let mut field = form::select::Field::new()
|
||||
.with_name("timezone")
|
||||
.with_label(Lc::t("field-timezone", &LOCALES_USER))
|
||||
.with_item(
|
||||
form::select::Item::new(
|
||||
"",
|
||||
Lc::t("field-timezone-site-default", &LOCALES_USER)
|
||||
.with_arg("tz", Timezone::default_tz().name()),
|
||||
)
|
||||
.with_selected(selected.is_empty()),
|
||||
);
|
||||
for (region, names) in TZ_BY_REGION.iter() {
|
||||
let mut group = form::select::Group::new(Lc::n(*region));
|
||||
for name in names {
|
||||
group = group.with_item(
|
||||
form::select::Item::new(*name, Lc::n(*name)).with_selected(*name == selected),
|
||||
);
|
||||
}
|
||||
field = field.with_group(group);
|
||||
}
|
||||
field
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
//! Formulario de asignación de roles a un usuario. Reemplaza siempre el conjunto completo.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
use crate::user_path;
|
||||
|
||||
use crate::component::error_banner;
|
||||
|
||||
use super::roles_fieldset;
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct UserRolesForm {
|
||||
user_id: i32,
|
||||
error: Option<Lc>,
|
||||
roles: Vec<(i32, String, bool)>,
|
||||
/// Listado de origen al que volver tras guardar (orden, búsqueda, página).
|
||||
waypoint: Waypoint,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for UserRolesForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = user_path(self.user_id(), "roles");
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id("user-roles-form")
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(roles_fieldset(self.roles()))
|
||||
.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl UserRolesForm {
|
||||
// **< UserRolesForm BUILDER >******************************************************************
|
||||
|
||||
pub(crate) fn with_user_id(mut self, user_id: i32) -> Self {
|
||||
self.user_id = user_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self {
|
||||
self.roles = roles;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,7 @@ use pagetop_htmx::hx_table::sort_link;
|
|||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
use crate::account::UserStatus;
|
||||
use crate::handlers::admin::users::available_roles;
|
||||
use crate::permission::UserPermission;
|
||||
use crate::account::{Account, UserStatus};
|
||||
use crate::service::user_admin::{UserListItem, UserSortField};
|
||||
use crate::user_path;
|
||||
|
||||
|
|
@ -66,14 +64,10 @@ impl Component for UserTable {
|
|||
.with_empty(Lc::t("empty-users-list", &LOCALES_USER));
|
||||
|
||||
let waypoint = Waypoint::from(self.list_href(cx));
|
||||
let can_assign_roles = cx
|
||||
let viewer_is_admin = cx
|
||||
.request()
|
||||
.is_some_and(|r| has_permission(r, &UserPermission::AssignRoles));
|
||||
// Sólo hace falta consultar si hay algún rol asignable cuando el botón vaya a mostrarse.
|
||||
let has_assignable_roles = can_assign_roles
|
||||
&& available_roles(&[])
|
||||
.await
|
||||
.is_ok_and(|roles| !roles.is_empty());
|
||||
.and_then(|r| r.extension::<Account>())
|
||||
.is_some_and(|a| a.is_admin());
|
||||
|
||||
for user in self.items() {
|
||||
let status = user.status;
|
||||
|
|
@ -85,10 +79,7 @@ impl Component for UserTable {
|
|||
.with_cell(user.display_name.as_deref().unwrap_or("-"))
|
||||
.with_cell(roles_cell(user, cx).await)
|
||||
.with_cell(Lc::t(status_key(status), &LOCALES_USER))
|
||||
.with_cell(
|
||||
actions_cell(user, &waypoint, can_assign_roles, has_assignable_roles, cx)
|
||||
.await,
|
||||
),
|
||||
.with_cell(actions_cell(user, &waypoint, viewer_is_admin, cx).await),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -207,19 +198,20 @@ fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html {
|
|||
})
|
||||
}
|
||||
|
||||
// Construye la celda de acciones: editar siempre, y gestionar roles sólo si el usuario autenticado
|
||||
// tiene permiso para asignarlos; en ese caso, deshabilitado si no hay ningún rol asignable en todo
|
||||
// el sistema. Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice
|
||||
// la celda, no al construir la fila.
|
||||
// Construye la celda de acciones con el botón de edición, salvo en la cuenta de un administrador
|
||||
// si quien mira no lo es (no puede gestionarla, ver `handlers::admin::users::require_manageable`).
|
||||
// Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice la celda, no
|
||||
// al construir la fila.
|
||||
async fn actions_cell(
|
||||
user: &UserListItem,
|
||||
waypoint: &Waypoint,
|
||||
can_assign_roles: bool,
|
||||
has_assignable_roles: bool,
|
||||
viewer_is_admin: bool,
|
||||
cx: &mut Context,
|
||||
) -> Html {
|
||||
let id = user.id;
|
||||
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||
if user.is_admin && !viewer_is_admin {
|
||||
return Html::default();
|
||||
}
|
||||
let edit_href = waypoint.append_to(cx.route(user_path(user.id, "edit")));
|
||||
|
||||
// El botón se renderiza aquí, no dentro del `Html::with()` de abajo: necesita pasar por su
|
||||
// propio ciclo de renderizado (`.render().await`) para que el tema activo lo estilice igual
|
||||
|
|
@ -230,62 +222,33 @@ async fn actions_cell(
|
|||
.render(cx)
|
||||
.await;
|
||||
|
||||
let roles_button = if can_assign_roles {
|
||||
let roles_href = waypoint.append_to(cx.route(user_path(id, "roles")));
|
||||
Some(
|
||||
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
|
||||
.with_style(button::Style::Solid(Intent::Neutral))
|
||||
.with_size(button::Size::Small)
|
||||
.with_disabled(!has_assignable_roles)
|
||||
.render(cx)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Html::with(move |_cx| {
|
||||
html! {
|
||||
(edit_button)
|
||||
@if let Some(roles_button) = &roles_button {
|
||||
" "
|
||||
(roles_button)
|
||||
}
|
||||
}
|
||||
})
|
||||
Html::with(move |_cx| edit_button.clone())
|
||||
}
|
||||
|
||||
// Construye la celda de roles: la insignia de administrador y las insignias de cada rol asignado,
|
||||
// o "-" si el usuario no tiene ningún rol ni es administrador. Los badges se renderizan aquí mismo
|
||||
// (con el `cx` del ciclo de renderizado de `Table`); el resto del marcado se difiere al `Html` que
|
||||
// se devuelve, igual que el resto de celdas.
|
||||
// Construye la celda de roles: sólo la insignia de administrador si lo es (tiene acceso sin
|
||||
// restricciones, así que sus roles no añaden nada), o las insignias de cada rol asignado, o "-" si
|
||||
// no tiene ninguno. Los badges se renderizan aquí mismo (con el `cx` del ciclo de renderizado de
|
||||
// `Table`); el resto del marcado se difiere al `Html` que se devuelve, igual que el resto de celdas.
|
||||
async fn roles_cell(user: &UserListItem, cx: &mut Context) -> Html {
|
||||
let admin_badge = if user.is_admin {
|
||||
Some(
|
||||
let mut badges = Vec::with_capacity(user.roles.len().max(1));
|
||||
if user.is_admin {
|
||||
badges.push(
|
||||
Badge::severe(Lc::t("badge-admin", &LOCALES_USER))
|
||||
.render(cx)
|
||||
.await,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut role_badges = Vec::with_capacity(user.roles.len());
|
||||
for role in &user.roles {
|
||||
role_badges.push(Badge::neutral(Lc::n(role.clone())).render(cx).await);
|
||||
for role in &user.roles {
|
||||
badges.push(Badge::neutral(Lc::n(role.clone())).render(cx).await);
|
||||
}
|
||||
}
|
||||
|
||||
let is_admin = user.is_admin;
|
||||
Html::with(move |_cx| {
|
||||
html! {
|
||||
@if let Some(badge) = &admin_badge {
|
||||
(badge)
|
||||
" "
|
||||
}
|
||||
@if role_badges.is_empty() {
|
||||
@if !is_admin { "-" }
|
||||
@if badges.is_empty() {
|
||||
"-"
|
||||
} @else {
|
||||
@for badge in &role_badges {
|
||||
@for badge in &badges {
|
||||
(badge)
|
||||
" "
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
//! Formulario para que el usuario autenticado cambie su propia contraseña.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::{LOCALES_USER, PROFILE_PASSWORD_PATH};
|
||||
|
||||
use crate::component::{PasswordConfirm, error_banner};
|
||||
|
||||
/// Pide la contraseña actual además de la nueva y su confirmación, a diferencia del
|
||||
/// restablecimiento por un administrador.
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct ChangePasswordForm {
|
||||
error: Option<Lc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for ChangePasswordForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut form = Form::new()
|
||||
.with_id("user-change-password-form")
|
||||
.with_action(cx.route(PROFILE_PASSWORD_PATH))
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(
|
||||
form::input::Field::password()
|
||||
.with_name("current_password")
|
||||
.with_label(Lc::t("field-current-password", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::current_password()))
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(
|
||||
PasswordConfirm::new()
|
||||
.with_password_label(Lc::t("field-new-password", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(
|
||||
Button::submit(Lc::t("btn-set-password", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl ChangePasswordForm {
|
||||
// **< ChangePasswordForm BUILDER >*************************************************************
|
||||
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
//! Par de campos "contraseña" + "confirmar contraseña", reutilizado en los formularios de
|
||||
//! registro, alta y restablecimiento de contraseña.
|
||||
//! registro, alta, restablecimiento y cambio de contraseña.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
|
|
@ -20,8 +20,8 @@ pub(crate) struct PasswordConfirm {
|
|||
#[async_trait]
|
||||
impl Component for PasswordConfirm {
|
||||
// Las etiquetas por defecto cubren los dos casos más habituales (alta de cuenta, alta de
|
||||
// usuario desde administración); `with_password_label()` cubre el caso distinto
|
||||
// (restablecimiento de contraseña por un administrador).
|
||||
// usuario desde administración); `with_password_label()` cubre los casos distintos
|
||||
// (restablecimiento por un administrador y cambio de la contraseña propia).
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
password_label: Lc::t("field-password", &LOCALES_USER),
|
||||
|
|
|
|||
109
extensions/pagetop-user/src/component/user_name.rs
Normal file
109
extensions/pagetop-user/src/component/user_name.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! Nombre de un usuario enlazado a su perfil público.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::Account;
|
||||
use crate::permission::UserPermission;
|
||||
use crate::profile_path;
|
||||
|
||||
/// Componente para mostrar el **nombre de un usuario**, enlazado a su perfil público cuando quien
|
||||
/// mira puede verlo.
|
||||
///
|
||||
/// Se renderiza como enlace a `/user/{id}` si quien hace la petición tiene `user:view_profiles` o
|
||||
/// es el propio usuario; si no, como texto. Las propiedades del componente (identificador, clases,
|
||||
/// atributos) se aplican en ambos casos.
|
||||
///
|
||||
/// Recibe el identificador y el nombre que el llamador ya tiene de su propia consulta, así que no
|
||||
/// consulta la base de datos al renderizarse. El nombre suele ser el nombre visible del usuario o,
|
||||
/// si no tiene, su nombre de usuario.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// use pagetop_user::prelude::*;
|
||||
///
|
||||
/// // En una celda de tabla, p. ej. el autor de un cambio en un historial.
|
||||
/// let row = table::Row::new()
|
||||
/// .with_cell(UserName::of(42, "Ana Pérez"))
|
||||
/// .with_cell("Cambio de ubicación");
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct UserName {
|
||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||
props: Props,
|
||||
/// Devuelve el identificador del usuario.
|
||||
user_id: i32,
|
||||
/// Devuelve el nombre que se muestra.
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for UserName {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &mut Context) {
|
||||
self.alter_prop(PropsOp::prepend_classes("user-name"));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let user_id = self.user_id();
|
||||
let can_view = cx.request().is_some_and(|request| {
|
||||
request
|
||||
.extension::<Account>()
|
||||
.is_some_and(|a| a.id() == user_id)
|
||||
|| has_permission(request, &UserPermission::ViewProfiles)
|
||||
});
|
||||
|
||||
Ok(if can_view {
|
||||
let href = cx.route(profile_path(user_id));
|
||||
html! { a (self.props().unpack(cx)) href=(href) { (self.name()) } }
|
||||
} else {
|
||||
html! { span (self.props().unpack(cx)) { (self.name()) } }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[builder_impl]
|
||||
impl UserName {
|
||||
/// Crea el nombre del usuario `user_id` con el texto indicado.
|
||||
pub fn of(user_id: i32, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
name: name.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// **< UserName BUILDER >***********************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.props.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
pub fn with_prop(mut self, op: impl Into<PropsOp>) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el identificador del usuario.
|
||||
pub fn with_user_id(mut self, user_id: i32) -> Self {
|
||||
self.user_id = user_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el nombre que se muestra.
|
||||
pub fn with_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +90,9 @@ async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> {
|
|||
status: Set(UserStatus::Active.as_i16()),
|
||||
language: Set(None),
|
||||
timezone: Set(None),
|
||||
theme: Set(None),
|
||||
display_name: Set(Some(format!("Demo User {n:02}"))),
|
||||
about: Set(None),
|
||||
last_login_at: Set(None),
|
||||
last_access_at: Set(None),
|
||||
failed_login_count: Set(0),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ pub struct Model {
|
|||
pub status: i16,
|
||||
pub language: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
/// Nombre corto del tema preferido (p. ej. `"Bootsier"`), si tiene uno.
|
||||
pub theme: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
/// Texto libre "Sobre mí" que el propio usuario escribe en su perfil.
|
||||
pub about: Option<String>,
|
||||
pub last_login_at: Option<DateTimeUtc>,
|
||||
pub last_access_at: Option<DateTimeUtc>,
|
||||
pub failed_login_count: i32,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ pub enum AuthError {
|
|||
#[error("invalid IANA timezone identifier")]
|
||||
InvalidTimezone,
|
||||
|
||||
#[error("unsupported language identifier")]
|
||||
InvalidLanguage,
|
||||
|
||||
#[error("unknown or disabled theme")]
|
||||
InvalidTheme,
|
||||
|
||||
#[error("about text must be at most {0} characters")]
|
||||
AboutTooLong(usize),
|
||||
|
||||
#[error("user not found")]
|
||||
UserNotFound,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,44 @@
|
|||
//! Handler HTTP para el perfil del propio usuario autenticado.
|
||||
//! Handlers HTTP para el perfil del propio usuario autenticado (consulta, edición de sus datos y
|
||||
//! cambio de contraseña) y para el perfil público de cualquier usuario.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::account::UserStatus;
|
||||
use crate::component::admin::status_key;
|
||||
use crate::account::{Account, UserStatus};
|
||||
use crate::auth;
|
||||
use crate::component::admin::{UserForm, UserFormMode, status_key};
|
||||
use crate::component::{ChangePasswordForm, language_name, multiline_text, theme_name};
|
||||
use crate::entity::{role, user};
|
||||
use crate::error::AuthError;
|
||||
use crate::handlers::admin::map_auth_error;
|
||||
use crate::password;
|
||||
use crate::permission::UserPermission;
|
||||
use crate::service::user_admin;
|
||||
use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_PATH};
|
||||
use crate::session;
|
||||
use crate::{LOCALES_USER, LOGIN_PATH, PROFILE_EDIT_PATH, PROFILE_PASSWORD_PATH, PROFILE_PATH};
|
||||
|
||||
// **< current_user_id / login_redirect >***********************************************************
|
||||
|
||||
// Identificador del usuario autenticado; el middleware de sesión sólo inserta `Account` si hay una
|
||||
// sesión activa.
|
||||
fn current_user_id(request: &HttpRequest) -> Option<i32> {
|
||||
request.extension::<Account>().map(|a| a.id())
|
||||
}
|
||||
|
||||
// Redirección al formulario de inicio de sesión conservando `next` como URL de retorno.
|
||||
fn login_redirect(request: HttpRequest, next: &'static str) -> Response {
|
||||
let cx = Context::new(request);
|
||||
Redirect::see_other(cx.route(LOGIN_PATH).with_param("next", next)).into_response()
|
||||
}
|
||||
|
||||
// **< profile_get >********************************************************************************
|
||||
|
||||
/// GET /user - Perfil del usuario autenticado. Redirige al formulario de inicio de sesión si no hay
|
||||
/// sesión activa, conservando la URL de retorno.
|
||||
pub(crate) async fn profile_get(request: HttpRequest) -> Response {
|
||||
let cx = Context::new(request.clone());
|
||||
let Some(id) = cx.current_user().id() else {
|
||||
let target = cx.route(LOGIN_PATH).with_param("next", PROFILE_PATH);
|
||||
return Redirect::see_other(target).into_response();
|
||||
let Some(id) = current_user_id(&request) else {
|
||||
return login_redirect(request, PROFILE_PATH);
|
||||
};
|
||||
|
||||
let user = match user_admin::find_user(id).await {
|
||||
|
|
@ -28,19 +50,50 @@ pub(crate) async fn profile_get(request: HttpRequest) -> Response {
|
|||
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
|
||||
};
|
||||
let status = UserStatus::from_i16(user.status);
|
||||
let can_edit = has_permission(&request, &UserPermission::EditOwnProfile);
|
||||
let can_change_password = has_permission(&request, &UserPermission::ChangeOwnPassword);
|
||||
|
||||
let mut page = Page::new(request);
|
||||
let details_block = profile_details(&user, status, page.context()).await;
|
||||
let roles_block = profile_roles(&roles, page.context()).await;
|
||||
let actions = profile_actions(can_edit, can_change_password, page.context());
|
||||
|
||||
page.with_title(Lc::t("title-profile", &LOCALES_USER))
|
||||
.with_child(details_block)
|
||||
.with_child(actions)
|
||||
.with_child(roles_block)
|
||||
.render()
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// Botones para editar el perfil y cambiar la contraseña, cada uno sólo si se tiene el permiso.
|
||||
// Sin ninguno de los dos, el contenedor queda vacío y no se renderiza.
|
||||
fn profile_actions(can_edit: bool, can_change_password: bool, cx: &Context) -> Flex {
|
||||
let mut actions = Flex::new()
|
||||
.with_wrap(flex::Behavior::Wrap)
|
||||
.with_gap(align::Gap::Both(UnitValue::RelRem(0.5)));
|
||||
if can_edit {
|
||||
actions = actions.with_child(
|
||||
Button::anchor(
|
||||
Lc::t("btn-edit-profile", &LOCALES_USER),
|
||||
cx.route(PROFILE_EDIT_PATH),
|
||||
)
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
}
|
||||
if can_change_password {
|
||||
actions = actions.with_child(
|
||||
Button::anchor(
|
||||
Lc::t("btn-set-password", &LOCALES_USER),
|
||||
cx.route(PROFILE_PASSWORD_PATH),
|
||||
)
|
||||
.with_style(button::Style::Solid(Intent::Neutral)),
|
||||
);
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
// Bloque de sólo lectura con los datos de perfil del usuario autenticado.
|
||||
async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Context) -> Block {
|
||||
let mut table = Table::new()
|
||||
|
|
@ -60,20 +113,40 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
|
|||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-about", &LOCALES_USER))
|
||||
.with_cell(multiline_text(
|
||||
user.about.clone().unwrap_or_else(|| "-".into()),
|
||||
)),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
||||
.with_cell(user.language.as_deref().unwrap_or("-")),
|
||||
.with_cell(language_name(user.language.as_deref())),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
||||
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-theme", &LOCALES_USER))
|
||||
.with_cell(theme_name(user.theme.as_deref())),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
||||
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
|
||||
.with_cell(cx.format_date(
|
||||
user.created_at.with_timezone(&cx.timezone()).date_naive(),
|
||||
DateFormat::Long,
|
||||
)),
|
||||
);
|
||||
|
||||
if user.is_admin {
|
||||
|
|
@ -96,6 +169,11 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
|
|||
// administración, no enlaza cada rol a su pantalla de detalle: un usuario sin permisos de
|
||||
// administración no puede acceder a ella.
|
||||
async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
||||
// Un bloque sin hijos no se renderiza.
|
||||
if roles.is_empty() {
|
||||
return Block::new();
|
||||
}
|
||||
|
||||
let mut items: Vec<(String, Option<Markup>)> = Vec::with_capacity(roles.len());
|
||||
for r in roles {
|
||||
let system_badge = if r.locked {
|
||||
|
|
@ -114,17 +192,13 @@ async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
|||
.with_title(Lc::t("field-roles", &LOCALES_USER))
|
||||
.with_child(Html::with(move |_cx| {
|
||||
html! {
|
||||
@if items.is_empty() {
|
||||
"-"
|
||||
} @else {
|
||||
ul class="user-profile-roles" {
|
||||
@for (label, system_badge) in &items {
|
||||
li {
|
||||
(label.as_str())
|
||||
@if let Some(badge) = system_badge {
|
||||
" "
|
||||
(badge)
|
||||
}
|
||||
ul class="user-profile-roles" {
|
||||
@for (label, system_badge) in &items {
|
||||
li {
|
||||
(label.as_str())
|
||||
@if let Some(badge) = system_badge {
|
||||
" "
|
||||
(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -132,3 +206,259 @@ async fn profile_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
|||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// **< public_profile_get >*************************************************************************
|
||||
|
||||
/// GET /user/{id} - Perfil público de un usuario: nombre de usuario, nombre visible, "Sobre mí" y
|
||||
/// fecha de alta. Requiere `user:view_profiles`, salvo para ver el propio. El perfil de una cuenta
|
||||
/// que no está activa sólo lo ve quien administra usuarios.
|
||||
pub(crate) async fn public_profile_get(
|
||||
request: HttpRequest,
|
||||
web::Path(id): web::Path<i32>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
// El permiso se comprueba antes de buscar al usuario para no revelar qué ids existen.
|
||||
if current_user_id(&request) != Some(id) {
|
||||
require_permission(&request, &UserPermission::ViewProfiles)?;
|
||||
}
|
||||
let Ok(user) = user_admin::find_user(id).await else {
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
};
|
||||
if UserStatus::from_i16(user.status) != UserStatus::Active
|
||||
&& !has_permission(&request, &UserPermission::AdminUsers)
|
||||
{
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
}
|
||||
|
||||
let title = match util::non_blank(user.display_name.as_deref().unwrap_or_default()) {
|
||||
Some(name) => name.to_owned(),
|
||||
None => user.username.clone(),
|
||||
};
|
||||
let mut page = Page::new(request);
|
||||
let details = public_profile_details(&user, page.context());
|
||||
|
||||
Ok(page
|
||||
.with_title(Lc::n(title))
|
||||
.with_child(details)
|
||||
.render()
|
||||
.await
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// Bloque con los únicos datos que se muestran a otros usuarios; nunca el email, los roles, el
|
||||
// estado ni la zona horaria.
|
||||
fn public_profile_details(user: &user::Model, cx: &Context) -> Block {
|
||||
let member_since = cx.format_date(
|
||||
user.created_at.with_timezone(&cx.timezone()).date_naive(),
|
||||
DateFormat::Long,
|
||||
);
|
||||
let table = Table::new()
|
||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
||||
.with_cell(user.username.as_str()),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-about", &LOCALES_USER))
|
||||
.with_cell(multiline_text(
|
||||
user.about.clone().unwrap_or_else(|| "-".into()),
|
||||
)),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
|
||||
.with_cell(member_since),
|
||||
);
|
||||
Block::new().with_child(table)
|
||||
}
|
||||
|
||||
// **< profile_edit_get / profile_edit_post >*******************************************************
|
||||
|
||||
/// GET /user/edit - Formulario de edición del perfil propio.
|
||||
pub(crate) async fn profile_edit_get(request: HttpRequest) -> Result<Response, ErrorPage> {
|
||||
let Some(id) = current_user_id(&request) else {
|
||||
return Ok(login_redirect(request, PROFILE_EDIT_PATH));
|
||||
};
|
||||
require_permission(&request, &UserPermission::EditOwnProfile)?;
|
||||
let Ok(user) = user_admin::find_user(id).await else {
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
};
|
||||
|
||||
let form = UserForm::new()
|
||||
.with_username(user.username)
|
||||
.with_email(user.email)
|
||||
.with_display_name(user.display_name.unwrap_or_default())
|
||||
.with_about(user.about.unwrap_or_default())
|
||||
.with_language(user.language.unwrap_or_default())
|
||||
.with_timezone(user.timezone.unwrap_or_default())
|
||||
.with_theme(user.theme.unwrap_or_default());
|
||||
Ok(render_profile_edit(request, form).await)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ProfileFormData {
|
||||
#[serde(default)]
|
||||
username: String,
|
||||
email: String,
|
||||
#[serde(default)]
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
about: String,
|
||||
#[serde(default)]
|
||||
language: String,
|
||||
#[serde(default)]
|
||||
timezone: String,
|
||||
#[serde(default)]
|
||||
theme: String,
|
||||
}
|
||||
|
||||
/// POST /user/edit - Guarda los datos del perfil propio. El nombre de usuario sólo cambia con
|
||||
/// `user:change_own_username`; roles, estado y acceso irrestricto no se tocan nunca desde aquí.
|
||||
pub(crate) async fn profile_edit_post(
|
||||
request: HttpRequest,
|
||||
web::Form(form): web::Form<ProfileFormData>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
let Some(id) = current_user_id(&request) else {
|
||||
return Ok(login_redirect(request, PROFILE_EDIT_PATH));
|
||||
};
|
||||
require_permission(&request, &UserPermission::EditOwnProfile)?;
|
||||
let Ok(user) = user_admin::find_user(id).await else {
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
};
|
||||
|
||||
// Sin permiso se conserva el nombre actual, aunque la petición se haya manipulado a mano.
|
||||
let username = if has_permission(&request, &UserPermission::ChangeOwnUsername) {
|
||||
form.username
|
||||
} else {
|
||||
user.username
|
||||
};
|
||||
|
||||
let result = user_admin::update_user(
|
||||
id,
|
||||
user_admin::UserUpdateData {
|
||||
username: &username,
|
||||
email: &form.email,
|
||||
display_name: util::non_blank(&form.display_name),
|
||||
about: util::non_blank(&form.about),
|
||||
language: util::non_blank(&form.language),
|
||||
timezone: util::non_blank(&form.timezone),
|
||||
theme: util::non_blank(&form.theme),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let cx = Context::new(request);
|
||||
Ok(Redirect::see_other(cx.route(PROFILE_PATH)).into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
let form = UserForm::new()
|
||||
.with_username(username)
|
||||
.with_email(form.email)
|
||||
.with_display_name(form.display_name)
|
||||
.with_about(form.about)
|
||||
.with_language(form.language)
|
||||
.with_timezone(form.timezone)
|
||||
.with_theme(form.theme)
|
||||
.with_error(map_auth_error(&err));
|
||||
Ok(render_profile_edit(request, form).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Página de edición del perfil propio con el formulario ya relleno.
|
||||
async fn render_profile_edit(request: HttpRequest, form: UserForm) -> Response {
|
||||
let can_change_username = has_permission(&request, &UserPermission::ChangeOwnUsername);
|
||||
let form = form
|
||||
.with_mode(UserFormMode::Profile)
|
||||
.with_allow_username_field(can_change_username);
|
||||
render_profile_page(request, Lc::t("title-profile-edit", &LOCALES_USER), form).await
|
||||
}
|
||||
|
||||
// **< password_get / password_post >***************************************************************
|
||||
|
||||
/// GET /user/password - Formulario de cambio de la contraseña propia.
|
||||
pub(crate) async fn password_get(request: HttpRequest) -> Result<Response, ErrorPage> {
|
||||
if current_user_id(&request).is_none() {
|
||||
return Ok(login_redirect(request, PROFILE_PASSWORD_PATH));
|
||||
}
|
||||
require_permission(&request, &UserPermission::ChangeOwnPassword)?;
|
||||
let title = Lc::t("title-profile-password", &LOCALES_USER);
|
||||
Ok(render_profile_page(request, title, ChangePasswordForm::new()).await)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ChangePasswordFormData {
|
||||
current_password: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
/// POST /user/password - Cambia la contraseña propia tras comprobar la actual y cierra el resto de
|
||||
/// sesiones abiertas del usuario, conservando la actual.
|
||||
pub(crate) async fn password_post(
|
||||
request: HttpRequest,
|
||||
web::Form(form): web::Form<ChangePasswordFormData>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
let Some(id) = current_user_id(&request) else {
|
||||
return Ok(login_redirect(request, PROFILE_PASSWORD_PATH));
|
||||
};
|
||||
require_permission(&request, &UserPermission::ChangeOwnPassword)?;
|
||||
|
||||
let sid = session::extract_sid(Some(request.headers()));
|
||||
let result = match password::passwords_match(&form.password, &form.confirm_password) {
|
||||
Ok(()) => {
|
||||
auth::change_own_password(id, &form.current_password, &form.password, sid.as_deref())
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let cx = Context::new(request);
|
||||
Ok(Redirect::see_other(cx.route(PROFILE_PATH)).into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
let error = match err {
|
||||
AuthError::InvalidCredentials => Lc::t("error-current-password", &LOCALES_USER),
|
||||
err => map_auth_error(&err),
|
||||
};
|
||||
let title = Lc::t("title-profile-password", &LOCALES_USER);
|
||||
let form = ChangePasswordForm::new().with_error(error);
|
||||
Ok(render_profile_page(request, title, form).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< render_profile_page >************************************************************************
|
||||
|
||||
// Página del perfil propio con un formulario y un botón para volver al perfil sin guardar.
|
||||
async fn render_profile_page(
|
||||
request: HttpRequest,
|
||||
title: Lc,
|
||||
form: impl Component + 'static,
|
||||
) -> Response {
|
||||
let mut page = Page::new(request);
|
||||
let cancel = Button::anchor(
|
||||
Lc::t("btn-cancel", &LOCALES_USER),
|
||||
page.context().route(PROFILE_PATH),
|
||||
);
|
||||
page.with_title(title.clone())
|
||||
.with_child(
|
||||
Block::new()
|
||||
.with_title(title)
|
||||
.with_child(form)
|
||||
.with_child(cancel),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ pub(crate) fn map_auth_error(err: &AuthError) -> Lc {
|
|||
}
|
||||
AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER),
|
||||
AuthError::InvalidTimezone => Lc::t("error-invalid-timezone", &LOCALES_USER),
|
||||
AuthError::InvalidLanguage => Lc::t("error-invalid-language", &LOCALES_USER),
|
||||
AuthError::InvalidTheme => Lc::t("error-invalid-theme", &LOCALES_USER),
|
||||
AuthError::AboutTooLong(n) => {
|
||||
Lc::t("error-about-too-long", &LOCALES_USER).with_arg("n", n.to_string())
|
||||
}
|
||||
AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER),
|
||||
AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
|
||||
AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ use crate::AUTHENTICATED_ROLE_ID;
|
|||
use crate::LOCALES_USER;
|
||||
use crate::account::{Account, UserStatus};
|
||||
use crate::component::admin::{
|
||||
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserRolesForm, UserTable,
|
||||
status_key,
|
||||
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserTable, status_key,
|
||||
};
|
||||
use crate::component::{language_name, multiline_text, theme_name};
|
||||
use crate::config::SETTINGS;
|
||||
use crate::entity::{role, user};
|
||||
use crate::error::AuthError;
|
||||
|
|
@ -112,11 +112,8 @@ fn search_bar(current_query: Option<String>) -> Html {
|
|||
// **< available_roles >****************************************************************************
|
||||
|
||||
// Roles asignables desde la UI de usuarios: excluye "anonymous" (nunca se asigna explícitamente)
|
||||
// y "authenticated" (implícito, nunca se asigna). `pub(crate)` porque también la usa
|
||||
// `component::admin::user_table` para decidir si hay algo que gestionar.
|
||||
pub(crate) async fn available_roles(
|
||||
selected: &[i32],
|
||||
) -> Result<Vec<(i32, String, bool)>, AuthError> {
|
||||
// y "authenticated" (implícito, nunca se asigna).
|
||||
async fn available_roles(selected: &[i32]) -> Result<Vec<(i32, String, bool)>, AuthError> {
|
||||
let items = role_admin::list_roles(&role_admin::RoleListParams {
|
||||
sort: role_admin::RoleSortField::Weight,
|
||||
dir: SortDir::Asc,
|
||||
|
|
@ -132,6 +129,27 @@ pub(crate) async fn available_roles(
|
|||
.collect())
|
||||
}
|
||||
|
||||
// Identificadores de rol enviados en el formulario de alta o edición.
|
||||
fn parse_role_ids(role_ids: &[String]) -> Vec<i32> {
|
||||
role_ids.iter().filter_map(|s| s.parse().ok()).collect()
|
||||
}
|
||||
|
||||
// **< require_manageable >*************************************************************************
|
||||
|
||||
// Carga el usuario y comprueba que quien hace la petición puede gestionarlo. La cuenta de un
|
||||
// administrador sólo la gestiona otro administrador: si no, quien administra usuarios podría
|
||||
// apoderarse de ella (p. ej. restableciendo su contraseña) y obtener acceso irrestricto, que sólo
|
||||
// un administrador puede conceder.
|
||||
async fn require_manageable(request: &HttpRequest, id: i32) -> Result<user::Model, ErrorPage> {
|
||||
let Ok(user) = user_admin::find_user(id).await else {
|
||||
return Err(ErrorPage::NotFound(Some(request.clone())));
|
||||
};
|
||||
if user.is_admin && !request.extension::<Account>().is_some_and(|a| a.is_admin()) {
|
||||
return Err(ErrorPage::AccessDenied(Some(request.clone())));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
// **< new_get / new_post >*************************************************************************
|
||||
|
||||
/// GET /admin/user/users/new - Formulario de alta de usuario.
|
||||
|
|
@ -146,7 +164,7 @@ pub(crate) async fn new_get(
|
|||
};
|
||||
// El campo "administrador" sólo se ofrece si quien da de alta ya es administrador: no es un
|
||||
// permiso del catálogo (igual que conceder/revocar en la edición, ver `set_user_admin`).
|
||||
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin);
|
||||
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin());
|
||||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
let title = Lc::t("title-admin-user-new", &LOCALES_USER);
|
||||
|
|
@ -181,6 +199,8 @@ pub(crate) struct NewUserFormData {
|
|||
#[serde(default)]
|
||||
timezone: String,
|
||||
#[serde(default)]
|
||||
theme: String,
|
||||
#[serde(default)]
|
||||
role_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
is_admin: bool,
|
||||
|
|
@ -199,16 +219,11 @@ pub(crate) async fn new_post(
|
|||
let Ok(form) = serde_qs::from_bytes::<NewUserFormData>(&raw.0) else {
|
||||
return Err(ErrorPage::BadRequest(Some(request)));
|
||||
};
|
||||
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin);
|
||||
let allow_admin_field = request.extension::<Account>().is_some_and(|a| a.is_admin());
|
||||
// Nunca fiarse sólo de que el campo esté presente en el formulario: sólo se concede si quien
|
||||
// envía la petición ya es administrador, aunque alguien manipulase la petición a mano.
|
||||
let is_admin = form.is_admin && allow_admin_field;
|
||||
|
||||
let role_ids: Vec<i32> = form
|
||||
.role_ids
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let role_ids = parse_role_ids(&form.role_ids);
|
||||
|
||||
let result = user_admin::create_user(user_admin::NewUserData {
|
||||
username: &form.username,
|
||||
|
|
@ -218,6 +233,7 @@ pub(crate) async fn new_post(
|
|||
display_name: util::non_blank(&form.display_name),
|
||||
language: util::non_blank(&form.language),
|
||||
timezone: util::non_blank(&form.timezone),
|
||||
theme: util::non_blank(&form.theme),
|
||||
initial_role_ids: &role_ids,
|
||||
is_admin,
|
||||
})
|
||||
|
|
@ -240,6 +256,7 @@ pub(crate) async fn new_post(
|
|||
.with_display_name(form.display_name)
|
||||
.with_language(form.language)
|
||||
.with_timezone(form.timezone)
|
||||
.with_theme(form.theme)
|
||||
.with_roles(roles)
|
||||
.with_allow_admin_field(allow_admin_field)
|
||||
.with_is_admin(is_admin)
|
||||
|
|
@ -262,6 +279,7 @@ pub(crate) async fn new_post(
|
|||
|
||||
// **< edit_get / edit_post >***********************************************************************
|
||||
|
||||
// Pantalla de edición con el formulario relleno con los datos guardados del usuario.
|
||||
async fn render_user_edit(
|
||||
request: HttpRequest,
|
||||
id: i32,
|
||||
|
|
@ -272,26 +290,54 @@ async fn render_user_edit(
|
|||
Ok(user) => user,
|
||||
Err(_) => return ErrorPage::NotFound(Some(request)).into_response(),
|
||||
};
|
||||
let status = UserStatus::from_i16(user.status);
|
||||
// El botón de conceder/revocar sólo se muestra si quien lo ve ya es administrador y no está
|
||||
// viendo su propio perfil: no es un permiso del catálogo, y nadie puede automodificarse el
|
||||
// flag (ver `set_user_admin`).
|
||||
let can_toggle_admin = request
|
||||
.extension::<Account>()
|
||||
.is_some_and(|a| a.is_admin && a.id != id);
|
||||
let has_assignable_roles = match available_roles(&[]).await {
|
||||
Ok(roles) => !roles.is_empty(),
|
||||
let current = match user_admin::user_role_ids(id).await {
|
||||
Ok(ids) => ids,
|
||||
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
|
||||
};
|
||||
let roles = match available_roles(¤t).await {
|
||||
Ok(roles) => roles,
|
||||
Err(_) => return ErrorPage::InternalError(Some(request)).into_response(),
|
||||
};
|
||||
let status = UserStatus::from_i16(user.status);
|
||||
let is_admin = user.is_admin;
|
||||
let form = UserForm::new()
|
||||
.with_username(user.username)
|
||||
.with_email(user.email)
|
||||
.with_display_name(user.display_name.unwrap_or_default())
|
||||
.with_about(user.about.unwrap_or_default())
|
||||
.with_language(user.language.unwrap_or_default())
|
||||
.with_timezone(user.timezone.unwrap_or_default())
|
||||
.with_theme(user.theme.unwrap_or_default())
|
||||
.with_roles(roles)
|
||||
.with_error(error);
|
||||
render_edit_page(request, id, status, is_admin, form, waypoint).await
|
||||
}
|
||||
|
||||
// Pantalla de edición del usuario `id` con `form` ya relleno, sea con los datos guardados o con los
|
||||
// enviados que no se pudieron guardar. Siempre lleva la botonera de `edit_actions()`, porque en
|
||||
// modo `Edit` el botón "Guardar" está en ella y no dentro del formulario.
|
||||
async fn render_edit_page(
|
||||
request: HttpRequest,
|
||||
id: i32,
|
||||
status: UserStatus,
|
||||
is_admin: bool,
|
||||
form: UserForm,
|
||||
waypoint: Waypoint,
|
||||
) -> Response {
|
||||
// El botón de conceder/revocar sólo se muestra si quien lo ve ya es administrador y no está
|
||||
// viendo su propio perfil: no es un permiso del catálogo, y nadie puede automodificarse el flag
|
||||
// (ver `set_user_admin`).
|
||||
let can_toggle_admin = request
|
||||
.extension::<Account>()
|
||||
.is_some_and(|a| a.is_admin() && a.id() != id);
|
||||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
let title = Lc::t("title-admin-user-edit", &LOCALES_USER);
|
||||
let actions = edit_actions(
|
||||
id,
|
||||
status,
|
||||
user.is_admin,
|
||||
is_admin,
|
||||
can_toggle_admin,
|
||||
has_assignable_roles,
|
||||
&waypoint,
|
||||
page.context(),
|
||||
);
|
||||
|
|
@ -300,15 +346,8 @@ async fn render_user_edit(
|
|||
.with_child(
|
||||
frame(title)
|
||||
.with_child(
|
||||
UserForm::new()
|
||||
.with_mode(UserFormMode::Edit)
|
||||
form.with_mode(UserFormMode::Edit)
|
||||
.with_user_id(Some(id))
|
||||
.with_username(user.username)
|
||||
.with_email(user.email)
|
||||
.with_display_name(user.display_name.unwrap_or_default())
|
||||
.with_language(user.language.unwrap_or_default())
|
||||
.with_timezone(user.timezone.unwrap_or_default())
|
||||
.with_error(error)
|
||||
.with_waypoint(waypoint.clone()),
|
||||
)
|
||||
.with_child(actions)
|
||||
|
|
@ -319,24 +358,23 @@ async fn render_user_edit(
|
|||
.into_response()
|
||||
}
|
||||
|
||||
// Enlaces a las pantallas dedicadas (roles, restablecer contraseña) y botones de bloqueo/activación
|
||||
// y de concesión/revocación de acceso irrestricto (este último sólo si `can_toggle_admin`). El
|
||||
// listado de origen (`waypoint`) se arrastra a todas ellas para que, al volver aquí, esta misma
|
||||
// pantalla siga sabiendo devolver al listado en el estado en que se dejó.
|
||||
// Enlace a la pantalla de restablecer contraseña y botones de bloqueo/activación y de
|
||||
// concesión/revocación de acceso irrestricto (este último sólo si `can_toggle_admin`). El listado
|
||||
// de origen (`waypoint`) se arrastra a todas ellas para que, al volver aquí, esta misma pantalla
|
||||
// siga sabiendo devolver al listado en el estado en que se dejó.
|
||||
//
|
||||
// "Guardar" (envía el `<form>` de `UserForm` vía el atributo `form`, ver `USER_ADMIN_FORM_ID`),
|
||||
// "Gestionar roles" y "Restablecer contraseña" son botones sueltos; bloqueo/activación y
|
||||
// concesión/revocación de admin (este último sólo si `can_toggle_admin`) van cada uno en su propio
|
||||
// `Form`, con un campo `Hidden` para el nuevo valor, para conservar el envío nativo sin JavaScript,
|
||||
// mejorado con `hx-post`/`hx-confirm`. Todos son hijos directos del mismo `Container` con Flex, que
|
||||
// los alinea en fila con espaciado uniforme sin que ninguno tenga que ser forzosamente un `Button`
|
||||
// suelto (ver PAGETOP.md, "Preferir componentes a `html!` en bruto").
|
||||
// "Guardar" (envía el `<form>` de `UserForm` vía el atributo `form`, ver `USER_ADMIN_FORM_ID`) y
|
||||
// "Restablecer contraseña" son botones sueltos; bloqueo/activación y concesión/revocación de admin
|
||||
// (este último sólo si `can_toggle_admin`) van cada uno en su propio `Form`, con un campo `Hidden`
|
||||
// para el nuevo valor, para conservar el envío nativo sin JavaScript, mejorado con
|
||||
// `hx-post`/`hx-confirm`. Todos son hijos directos del mismo `Container` con Flex, que los alinea
|
||||
// en fila con espaciado uniforme sin que ninguno tenga que ser forzosamente un `Button` suelto (ver
|
||||
// PAGETOP.md, "Preferir componentes a `html!` en bruto").
|
||||
fn edit_actions(
|
||||
user_id: i32,
|
||||
status: UserStatus,
|
||||
target_is_admin: bool,
|
||||
can_toggle_admin: bool,
|
||||
has_assignable_roles: bool,
|
||||
waypoint: &Waypoint,
|
||||
cx: &mut Context,
|
||||
) -> Flex {
|
||||
|
|
@ -350,7 +388,6 @@ fn edit_actions(
|
|||
("true", "btn-grant-admin", "confirm-grant-admin")
|
||||
};
|
||||
|
||||
let roles_href = waypoint.append_to(cx.route(user_path(user_id, "roles")));
|
||||
let password_href = waypoint.append_to(cx.route(user_path(user_id, "password")));
|
||||
let status_action = waypoint.append_to(cx.route(user_path(user_id, "status")));
|
||||
let admin_action = waypoint.append_to(cx.route(user_path(user_id, "admin")));
|
||||
|
|
@ -377,11 +414,6 @@ fn edit_actions(
|
|||
.with_style(button::Style::Solid(Intent::Primary))
|
||||
.with_prop(PropsOp::set("form", USER_ADMIN_FORM_ID)),
|
||||
)
|
||||
.with_child(
|
||||
Button::anchor(Lc::t("btn-manage-roles", &LOCALES_USER), roles_href)
|
||||
.with_style(button::Style::Solid(Intent::Neutral))
|
||||
.with_disabled(!has_assignable_roles),
|
||||
)
|
||||
.with_child(
|
||||
Button::anchor(Lc::t("btn-reset-password", &LOCALES_USER), password_href)
|
||||
.with_style(button::Style::Solid(Intent::Neutral)),
|
||||
|
|
@ -389,12 +421,14 @@ fn edit_actions(
|
|||
.with_child(status_form);
|
||||
|
||||
if can_toggle_admin {
|
||||
let admin_button = Button::submit(Lc::t(admin_label_key, &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Severe));
|
||||
let mut admin_form = Form::new()
|
||||
.with_action(admin_action.clone())
|
||||
.with_method(form::Method::Post)
|
||||
.with_prop(PropsOp::set(hx::POST, admin_action.to_string()))
|
||||
.with_child(form::Hidden::field("is_admin", next_is_admin))
|
||||
.with_child(Button::submit(Lc::t(admin_label_key, &LOCALES_USER)));
|
||||
.with_child(admin_button);
|
||||
if let Some(confirm) = Lc::t(admin_confirm_key, &LOCALES_USER).lookup(cx) {
|
||||
admin_form = admin_form.with_prop(PropsOp::set(hx::CONFIRM, confirm));
|
||||
}
|
||||
|
|
@ -411,6 +445,7 @@ pub(crate) async fn edit_get(
|
|||
web::Query(waypoint): web::Query<Waypoint>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
require_manageable(&request, id).await?;
|
||||
Ok(render_user_edit(request, id, None, waypoint).await)
|
||||
}
|
||||
|
||||
|
|
@ -421,31 +456,49 @@ pub(crate) struct EditUserFormData {
|
|||
#[serde(default)]
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
about: String,
|
||||
#[serde(default)]
|
||||
language: String,
|
||||
#[serde(default)]
|
||||
timezone: String,
|
||||
#[serde(default)]
|
||||
theme: String,
|
||||
#[serde(default)]
|
||||
role_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// POST /admin/user/users/{id}/edit - Actualiza los datos de perfil de un usuario.
|
||||
/// POST /admin/user/users/{id}/edit - Actualiza los datos de perfil y los roles de un usuario.
|
||||
///
|
||||
/// Usa `RawForm` + `serde_qs` por los roles, igual que `new_post`.
|
||||
pub(crate) async fn edit_post(
|
||||
request: HttpRequest,
|
||||
web::Path(id): web::Path<i32>,
|
||||
web::Query(waypoint): web::Query<Waypoint>,
|
||||
web::Form(form): web::Form<EditUserFormData>,
|
||||
raw: web::RawForm,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
let user = require_manageable(&request, id).await?;
|
||||
let Ok(form) = serde_qs::from_bytes::<EditUserFormData>(&raw.0) else {
|
||||
return Err(ErrorPage::BadRequest(Some(request)));
|
||||
};
|
||||
let role_ids = parse_role_ids(&form.role_ids);
|
||||
|
||||
let result = user_admin::update_user(
|
||||
let mut result = user_admin::update_user(
|
||||
id,
|
||||
user_admin::UserUpdateData {
|
||||
username: &form.username,
|
||||
email: &form.email,
|
||||
display_name: util::non_blank(&form.display_name),
|
||||
about: util::non_blank(&form.about),
|
||||
language: util::non_blank(&form.language),
|
||||
timezone: util::non_blank(&form.timezone),
|
||||
theme: util::non_blank(&form.theme),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
result = user_admin::set_user_roles(id, &role_ids).await;
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
|
|
@ -454,29 +507,19 @@ pub(crate) async fn edit_post(
|
|||
Ok(Redirect::see_other(target).into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
let form_component = UserForm::new()
|
||||
.with_mode(UserFormMode::Edit)
|
||||
.with_user_id(Some(id))
|
||||
let roles = available_roles(&role_ids).await.unwrap_or_default();
|
||||
let status = UserStatus::from_i16(user.status);
|
||||
let form = UserForm::new()
|
||||
.with_username(form.username)
|
||||
.with_email(form.email)
|
||||
.with_display_name(form.display_name)
|
||||
.with_about(form.about)
|
||||
.with_language(form.language)
|
||||
.with_timezone(form.timezone)
|
||||
.with_error(map_auth_error(&err))
|
||||
.with_waypoint(waypoint);
|
||||
let title = Lc::t("title-admin-user-edit", &LOCALES_USER);
|
||||
Ok(page
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
frame(title)
|
||||
.with_child(form_component)
|
||||
.with_child(back_link(back_href)),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
.into_response())
|
||||
.with_theme(form.theme)
|
||||
.with_roles(roles)
|
||||
.with_error(map_auth_error(&err));
|
||||
Ok(render_edit_page(request, id, status, user.is_admin, form, waypoint).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -505,7 +548,7 @@ pub(crate) async fn view_get(
|
|||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
let details_block = user_view_details(&user, status, page.context()).await;
|
||||
let roles_block = user_view_roles(&roles, page.context()).await;
|
||||
let roles_block = user_view_roles(&roles, user.is_admin, page.context()).await;
|
||||
|
||||
let title = Lc::t("title-admin-user-view", &LOCALES_USER);
|
||||
Ok(page
|
||||
|
|
@ -540,20 +583,40 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
|
|||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-about", &LOCALES_USER))
|
||||
.with_cell(multiline_text(
|
||||
user.about.clone().unwrap_or_else(|| "-".into()),
|
||||
)),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-language", &LOCALES_USER))
|
||||
.with_cell(user.language.as_deref().unwrap_or("-")),
|
||||
.with_cell(language_name(user.language.as_deref())),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
|
||||
.with_cell(user.timezone.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-theme", &LOCALES_USER))
|
||||
.with_cell(theme_name(user.theme.as_deref())),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("col-status", &LOCALES_USER))
|
||||
.with_cell(Lc::t(status_key(status), &LOCALES_USER)),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-member-since", &LOCALES_USER))
|
||||
.with_cell(cx.format_date(
|
||||
user.created_at.with_timezone(&cx.timezone()).date_naive(),
|
||||
DateFormat::Long,
|
||||
)),
|
||||
);
|
||||
|
||||
if user.is_admin {
|
||||
|
|
@ -574,10 +637,13 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
|
|||
|
||||
// Bloque de sólo lectura con los roles asignados al usuario, cada uno enlazado a su propia
|
||||
// pantalla de vista.
|
||||
async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
||||
let mut table = Table::new()
|
||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||
.with_empty(Lc::n("-"));
|
||||
async fn user_view_roles(roles: &[role::Model], is_admin: bool, cx: &mut Context) -> Block {
|
||||
// Un bloque sin hijos no se renderiza.
|
||||
if roles.is_empty() {
|
||||
return Block::new();
|
||||
}
|
||||
|
||||
let mut table = Table::new().with_prop(PropsOp::add_classes("user-admin-table"));
|
||||
|
||||
for r in roles {
|
||||
let system_badge = if r.locked {
|
||||
|
|
@ -602,109 +668,32 @@ async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block {
|
|||
);
|
||||
}
|
||||
|
||||
Block::new()
|
||||
let mut block = Block::new()
|
||||
.with_title(Lc::t("field-roles", &LOCALES_USER))
|
||||
.with_child(table)
|
||||
.with_child(table);
|
||||
if is_admin {
|
||||
block = block.with_child(Html::with(|cx| {
|
||||
html! { p { (Lc::t("help-admin-roles", &LOCALES_USER).using(cx)) } }
|
||||
}));
|
||||
}
|
||||
block
|
||||
}
|
||||
|
||||
// **< roles_get / roles_post >*********************************************************************
|
||||
|
||||
/// GET /admin/user/users/{id}/roles - Formulario de asignación de roles de un usuario.
|
||||
pub(crate) async fn roles_get(
|
||||
request: HttpRequest,
|
||||
web::Path(id): web::Path<i32>,
|
||||
web::Query(waypoint): web::Query<Waypoint>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
require_permission(&request, &UserPermission::AssignRoles)?;
|
||||
|
||||
if user_admin::find_user(id).await.is_err() {
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
}
|
||||
let current = match user_admin::user_role_ids(id).await {
|
||||
Ok(ids) => ids,
|
||||
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||
};
|
||||
let roles = match available_roles(¤t).await {
|
||||
Ok(roles) => roles,
|
||||
Err(_) => return Err(ErrorPage::InternalError(Some(request))),
|
||||
};
|
||||
|
||||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
|
||||
let title = Lc::t("title-admin-user-roles", &LOCALES_USER);
|
||||
Ok(page
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
frame(title)
|
||||
.with_child(
|
||||
UserRolesForm::new()
|
||||
.with_user_id(id)
|
||||
.with_roles(roles)
|
||||
.with_waypoint(waypoint),
|
||||
)
|
||||
.with_child(back_link(back_href)),
|
||||
// Cabecera con los datos básicos del usuario en la pantalla de restablecimiento de contraseña, que
|
||||
// de otro modo no lo identifica.
|
||||
fn user_summary(user: &user::Model) -> Table {
|
||||
Table::new()
|
||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-username-admin", &LOCALES_USER))
|
||||
.with_cell(user.username.as_str()),
|
||||
)
|
||||
.with_row(
|
||||
table::Row::new()
|
||||
.with_cell(Lc::t("field-display-name", &LOCALES_USER))
|
||||
.with_cell(user.display_name.as_deref().unwrap_or("-")),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct UserRolesFormData {
|
||||
#[serde(default)]
|
||||
role_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// POST /admin/user/users/{id}/roles - Reemplaza el conjunto de roles asignados a un usuario.
|
||||
pub(crate) async fn roles_post(
|
||||
request: HttpRequest,
|
||||
web::Path(id): web::Path<i32>,
|
||||
web::Query(waypoint): web::Query<Waypoint>,
|
||||
raw: web::RawForm,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
require_permission(&request, &UserPermission::AssignRoles)?;
|
||||
|
||||
let Ok(form) = serde_qs::from_bytes::<UserRolesFormData>(&raw.0) else {
|
||||
return Err(ErrorPage::BadRequest(Some(request)));
|
||||
};
|
||||
|
||||
let role_ids: Vec<i32> = form
|
||||
.role_ids
|
||||
.iter()
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
|
||||
match user_admin::set_user_roles(id, &role_ids).await {
|
||||
Ok(()) => {
|
||||
let cx = Context::admin(request);
|
||||
let target = waypoint.or(cx.route(ADMIN_USERS_PATH));
|
||||
Ok(Redirect::see_other(target).into_response())
|
||||
}
|
||||
Err(err) => {
|
||||
let roles = available_roles(&role_ids).await.unwrap_or_default();
|
||||
let mut page = Page::admin(request);
|
||||
let back_href = waypoint.or(page.context().route(ADMIN_USERS_PATH));
|
||||
let form_component = UserRolesForm::new()
|
||||
.with_user_id(id)
|
||||
.with_roles(roles)
|
||||
.with_error(map_auth_error(&err))
|
||||
.with_waypoint(waypoint);
|
||||
let title = Lc::t("title-admin-user-roles", &LOCALES_USER);
|
||||
Ok(page
|
||||
.with_title(title.clone())
|
||||
.with_child(
|
||||
frame(title)
|
||||
.with_child(form_component)
|
||||
.with_child(back_link(back_href)),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< status_post >********************************************************************************
|
||||
|
|
@ -723,6 +712,7 @@ pub(crate) async fn status_post(
|
|||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
require_permission(&request, &UserPermission::BlockAccounts)?;
|
||||
require_manageable(&request, id).await?;
|
||||
let Some(account) = request.extension::<Account>().cloned() else {
|
||||
return Err(ErrorPage::AccessDenied(Some(request)));
|
||||
};
|
||||
|
|
@ -736,7 +726,7 @@ pub(crate) async fn status_post(
|
|||
// del propio `<form>`. `HtmxResponse::redirect()` fuerza una navegación real en el cliente.
|
||||
let is_htmx = request.is_htmx();
|
||||
|
||||
match user_admin::set_user_status(id, new_status, account.id).await {
|
||||
match user_admin::set_user_status(id, new_status, account.id()).await {
|
||||
Ok(()) => {
|
||||
let cx = Context::admin(request);
|
||||
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||
|
|
@ -760,7 +750,7 @@ pub(crate) struct AdminFormData {
|
|||
/// POST /admin/user/users/{id}/admin - Concede o revoca el acceso irrestricto (`is_admin`).
|
||||
///
|
||||
/// No pasa por `require_permission`: conceder o revocar este flag no es un permiso del catálogo,
|
||||
/// se comprueba directamente contra `account.is_admin` para que sólo un administrador pueda
|
||||
/// se comprueba directamente contra `account.is_admin()` para que sólo un administrador pueda
|
||||
/// tocarlo (un permiso concedido vía rol nunca basta).
|
||||
pub(crate) async fn admin_post(
|
||||
request: HttpRequest,
|
||||
|
|
@ -771,7 +761,7 @@ pub(crate) async fn admin_post(
|
|||
let Some(account) = request.extension::<Account>().cloned() else {
|
||||
return Err(ErrorPage::AccessDenied(Some(request)));
|
||||
};
|
||||
if !account.is_admin {
|
||||
if !account.is_admin() {
|
||||
return Err(ErrorPage::AccessDenied(Some(request)));
|
||||
}
|
||||
|
||||
|
|
@ -783,7 +773,7 @@ pub(crate) async fn admin_post(
|
|||
// (cabecera `HX-Redirect`) para que navegue de verdad a la URL, en vez de intentar un `swap`.
|
||||
let is_htmx = request.is_htmx();
|
||||
|
||||
match user_admin::set_user_admin(id, new_is_admin, account.id).await {
|
||||
match user_admin::set_user_admin(id, new_is_admin, account.id()).await {
|
||||
Ok(()) => {
|
||||
let cx = Context::admin(request);
|
||||
let edit_href = waypoint.append_to(cx.route(user_path(id, "edit")));
|
||||
|
|
@ -807,9 +797,7 @@ pub(crate) async fn password_get(
|
|||
web::Query(waypoint): web::Query<Waypoint>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
if user_admin::find_user(id).await.is_err() {
|
||||
return Err(ErrorPage::NotFound(Some(request)));
|
||||
}
|
||||
let user = require_manageable(&request, id).await?;
|
||||
let mut page = Page::admin(request);
|
||||
let edit_href = waypoint.append_to(page.context().route(user_path(id, "edit")));
|
||||
let title = Lc::t("title-admin-user-password", &LOCALES_USER);
|
||||
|
|
@ -817,6 +805,7 @@ pub(crate) async fn password_get(
|
|||
.with_title(title.clone())
|
||||
.with_child(
|
||||
frame(title)
|
||||
.with_child(user_summary(&user))
|
||||
.with_child(
|
||||
AdminPasswordForm::new()
|
||||
.with_user_id(id)
|
||||
|
|
@ -844,6 +833,7 @@ pub(crate) async fn password_post(
|
|||
web::Form(form): web::Form<AdminPasswordFormData>,
|
||||
) -> Result<Response, ErrorPage> {
|
||||
require_permission(&request, &UserPermission::AdminUsers)?;
|
||||
let user = require_manageable(&request, id).await?;
|
||||
|
||||
let result = match password::passwords_match(&form.password, &form.confirm_password) {
|
||||
Ok(()) => user_admin::admin_reset_password(id, &form.password).await,
|
||||
|
|
@ -864,6 +854,7 @@ pub(crate) async fn password_post(
|
|||
.with_title(title.clone())
|
||||
.with_child(
|
||||
frame(title)
|
||||
.with_child(user_summary(&user))
|
||||
.with_child(
|
||||
AdminPasswordForm::new()
|
||||
.with_user_id(id)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub use permission::{DeclarePermissions, PermissionRegistry};
|
|||
/// Prelude de `pagetop-user`.
|
||||
pub mod prelude {
|
||||
pub use crate::component::LoginForm;
|
||||
pub use crate::component::UserName;
|
||||
pub use crate::component::{AccountMenu, account_menu};
|
||||
pub use crate::error::AuthError;
|
||||
pub use crate::{Account, DeclarePermissions, UserStatus};
|
||||
|
|
@ -114,6 +115,15 @@ const LOGOUT_PATH: &str = "/user/logout";
|
|||
const REGISTER_PATH: &str = "/user/register";
|
||||
// GET - perfil del usuario autenticado; redirige a LOGIN_PATH si no hay sesión activa.
|
||||
const PROFILE_PATH: &str = "/user";
|
||||
// Con el sufijo `/{id}`: GET - perfil público del usuario (requiere `user:view_profiles`, salvo el
|
||||
// propio).
|
||||
// GET - muestra el formulario de edición del perfil propio (requiere `user:edit_own_profile`).
|
||||
// POST - guarda los cambios.
|
||||
const PROFILE_EDIT_PATH: &str = "/user/edit";
|
||||
// GET - muestra el formulario de cambio de la contraseña propia (requiere
|
||||
// `user:change_own_password`).
|
||||
// POST - aplica la nueva contraseña tras comprobar la actual.
|
||||
const PROFILE_PASSWORD_PATH: &str = "/user/password";
|
||||
// GET - muestra el formulario de solicitud de restablecimiento.
|
||||
// POST - inicia el flujo (envío del token).
|
||||
// Con el sufijo `/{uid}/{token}`: GET - muestra el formulario de nueva contraseña.
|
||||
|
|
@ -132,6 +142,11 @@ const ADMIN_ROLES_PATH: &str = "/admin/user/roles";
|
|||
// Catálogo de permisos registrados, agrupado por extensión (solo lectura).
|
||||
const ADMIN_PERMISSIONS_PATH: &str = "/admin/user/permissions";
|
||||
|
||||
// Ruta del perfil público del usuario `id`: `{PROFILE_PATH}/{id}`.
|
||||
fn profile_path(id: i32) -> String {
|
||||
util::join!(PROFILE_PATH, "/", id.to_string())
|
||||
}
|
||||
|
||||
// Ruta de una acción sobre el usuario `id`: `{ADMIN_USERS_PATH}/{id}/{tail}`.
|
||||
fn user_path(id: i32, tail: &str) -> String {
|
||||
util::join!(ADMIN_USERS_PATH, "/", id.to_string(), "/", tail)
|
||||
|
|
@ -247,6 +262,19 @@ impl Extension for User {
|
|||
web::get(handlers::auth::register_get).post(handlers::auth::register_post),
|
||||
)
|
||||
.route(PROFILE_PATH, web::get(handlers::account::profile_get))
|
||||
.route(
|
||||
&format!("{}/{{id}}", PROFILE_PATH),
|
||||
web::get(handlers::account::public_profile_get),
|
||||
)
|
||||
.route(
|
||||
PROFILE_EDIT_PATH,
|
||||
web::get(handlers::account::profile_edit_get)
|
||||
.post(handlers::account::profile_edit_post),
|
||||
)
|
||||
.route(
|
||||
PROFILE_PASSWORD_PATH,
|
||||
web::get(handlers::account::password_get).post(handlers::account::password_post),
|
||||
)
|
||||
.route(
|
||||
PASSWORD_RESET_PATH,
|
||||
web::get(handlers::auth::password_reset_get)
|
||||
|
|
@ -274,11 +302,6 @@ impl Extension for User {
|
|||
&format!("{}/{{id}}/view", ADMIN_USERS_PATH),
|
||||
web::get(handlers::admin::users::view_get),
|
||||
)
|
||||
.route(
|
||||
&format!("{}/{{id}}/roles", ADMIN_USERS_PATH),
|
||||
web::get(handlers::admin::users::roles_get)
|
||||
.post(handlers::admin::users::roles_post),
|
||||
)
|
||||
.route(
|
||||
&format!("{}/{{id}}/status", ADMIN_USERS_PATH),
|
||||
web::post(handlers::admin::users::status_post),
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ extension_description = User identity, authentication, roles and permissions for
|
|||
|
||||
# **< Page titles >**
|
||||
|
||||
title-login = Sign in
|
||||
title-register = Create account
|
||||
title-password-reset = Reset password
|
||||
title-new-password = Set new password
|
||||
title-profile = My profile
|
||||
title-login = Sign in
|
||||
title-register = Create account
|
||||
title-password-reset = Reset password
|
||||
title-new-password = Set new password
|
||||
title-profile = My profile
|
||||
title-profile-edit = Edit my profile
|
||||
title-profile-password = Change my password
|
||||
|
||||
# **< Field labels >**
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ field-password = Password
|
|||
field-email = Email address
|
||||
field-confirm-password = Confirm password
|
||||
field-new-password = New password
|
||||
field-current-password = Current password
|
||||
field-remember-me = Remember me
|
||||
|
||||
# **< Buttons and links >**
|
||||
|
|
@ -29,6 +32,7 @@ btn-logout = Sign out
|
|||
btn-register = Create account
|
||||
btn-send-reset-link = Send reset link
|
||||
btn-set-password = Change password
|
||||
btn-edit-profile = Edit profile
|
||||
|
||||
link-register = Create an account
|
||||
link-forgot-password = Forgot your password?
|
||||
|
|
@ -56,9 +60,13 @@ error-account-locked = Too many failed attempts. Please try again later.
|
|||
error-password-mismatch = Passwords do not match.
|
||||
error-password-too-short = Password must be at least { $n } characters.
|
||||
error-invalid-timezone = Invalid timezone.
|
||||
error-invalid-language = Invalid language.
|
||||
error-invalid-theme = Invalid theme.
|
||||
error-about-too-long = The "About me" text must be at most { $n } characters.
|
||||
error-username-taken = This username is already taken.
|
||||
error-email-taken = This email address is already registered.
|
||||
error-token-invalid = This link is invalid or has expired.
|
||||
error-current-password = The current password is incorrect.
|
||||
error-internal = An unexpected error occurred. Please try again.
|
||||
|
||||
# **< Account statuses >**
|
||||
|
|
@ -73,7 +81,6 @@ title-admin-users = Users
|
|||
title-admin-user-new = New user
|
||||
title-admin-user-edit = Edit user
|
||||
title-admin-user-view = View user
|
||||
title-admin-user-roles = User roles
|
||||
title-admin-user-password = Reset password
|
||||
title-admin-roles = Roles
|
||||
title-admin-role-new = New role
|
||||
|
|
@ -107,9 +114,12 @@ col-users-count = Users
|
|||
|
||||
field-username-admin = Username
|
||||
field-display-name = Display name
|
||||
field-about = About me
|
||||
field-member-since = Member since
|
||||
field-language = Language
|
||||
field-timezone = Timezone
|
||||
field-timezone-site-default = Use site timezone ({ $tz })
|
||||
field-theme = Theme
|
||||
value-theme-site-default = Site theme: { $theme }
|
||||
field-machine-name = Machine name
|
||||
field-label = Label
|
||||
field-description = Description
|
||||
|
|
@ -121,6 +131,9 @@ field-search-users = Search by username, email or name...
|
|||
help-machine-name-immutable =
|
||||
Lowercase letters, digits and underscores only. Cannot be changed after creation.
|
||||
|
||||
help-admin-roles =
|
||||
As an administrator they have every permission. These roles will apply if they stop being one.
|
||||
|
||||
# **< Admin: buttons and links >**
|
||||
|
||||
btn-save = Save
|
||||
|
|
@ -129,7 +142,6 @@ btn-create-role = New role
|
|||
btn-delete = Delete
|
||||
btn-cancel = Cancel
|
||||
btn-edit = Edit
|
||||
btn-manage-roles = Manage roles
|
||||
btn-manage-permissions = Manage permissions
|
||||
btn-reset-password = Reset password
|
||||
btn-block = Block
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@
|
|||
|
||||
group-users = User management
|
||||
|
||||
perm-login = Sign in
|
||||
perm-register = Register a new account
|
||||
perm-view-profiles = View user profiles
|
||||
perm-edit-own-profile = Edit own profile
|
||||
perm-change-own-username = Change own username
|
||||
perm-change-own-password = Change own password
|
||||
|
||||
# **< Group: Administration >**
|
||||
|
||||
group-administration = Administration
|
||||
|
||||
perm-admin-users = Administer users
|
||||
perm-admin-users = Administer users and assign their roles
|
||||
perm-admin-roles = Administer roles
|
||||
perm-admin-permissions = Administer permissions
|
||||
perm-block-accounts = Block and unblock accounts
|
||||
perm-assign-roles = Assign roles to users
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ extension_description = Identidad de usuario, autenticación, roles y permisos p
|
|||
|
||||
# **< Títulos de página >**
|
||||
|
||||
title-login = Iniciar sesión
|
||||
title-register = Crear cuenta
|
||||
title-password-reset = Recuperar contraseña
|
||||
title-new-password = Establecer nueva contraseña
|
||||
title-profile = Mi perfil
|
||||
title-login = Iniciar sesión
|
||||
title-register = Crear cuenta
|
||||
title-password-reset = Recuperar contraseña
|
||||
title-new-password = Establecer nueva contraseña
|
||||
title-profile = Mi perfil
|
||||
title-profile-edit = Editar mi perfil
|
||||
title-profile-password = Cambiar mi contraseña
|
||||
|
||||
# **< Etiquetas de campos >**
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ field-password = Contraseña
|
|||
field-email = Dirección de email
|
||||
field-confirm-password = Confirmar contraseña
|
||||
field-new-password = Nueva contraseña
|
||||
field-current-password = Contraseña actual
|
||||
field-remember-me = Recuérdame
|
||||
|
||||
# **< Botones y enlaces >**
|
||||
|
|
@ -29,6 +32,7 @@ btn-logout = Cerrar sesión
|
|||
btn-register = Crear cuenta
|
||||
btn-send-reset-link = Enviar enlace
|
||||
btn-set-password = Cambiar contraseña
|
||||
btn-edit-profile = Editar perfil
|
||||
|
||||
link-register = Crear una cuenta
|
||||
link-forgot-password = ¿Olvidaste tu contraseña?
|
||||
|
|
@ -56,9 +60,13 @@ error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo m
|
|||
error-password-mismatch = Las contraseñas no coinciden.
|
||||
error-password-too-short = La contraseña debe tener al menos { $n } caracteres.
|
||||
error-invalid-timezone = Zona horaria no válida.
|
||||
error-invalid-language = Idioma no válido.
|
||||
error-invalid-theme = Tema no válido.
|
||||
error-about-too-long = El texto «Sobre mí» no puede superar { $n } caracteres.
|
||||
error-username-taken = Este nombre de usuario ya está en uso.
|
||||
error-email-taken = Esta dirección de email ya está registrada.
|
||||
error-token-invalid = Este enlace no es válido o ha caducado.
|
||||
error-current-password = La contraseña actual no es correcta.
|
||||
error-internal = Se ha producido un error inesperado. Inténtalo de nuevo.
|
||||
|
||||
# **< Estados de cuenta >**
|
||||
|
|
@ -73,7 +81,6 @@ title-admin-users = Usuarios
|
|||
title-admin-user-new = Nuevo usuario
|
||||
title-admin-user-edit = Editar usuario
|
||||
title-admin-user-view = Ver usuario
|
||||
title-admin-user-roles = Roles del usuario
|
||||
title-admin-user-password = Restablecer contraseña
|
||||
title-admin-roles = Roles
|
||||
title-admin-role-new = Nuevo rol
|
||||
|
|
@ -107,9 +114,12 @@ col-users-count = Usuarios
|
|||
|
||||
field-username-admin = Usuario
|
||||
field-display-name = Nombre visible
|
||||
field-about = Sobre mí
|
||||
field-member-since = Miembro desde
|
||||
field-language = Idioma
|
||||
field-timezone = Zona horaria
|
||||
field-timezone-site-default = Usar la zona horaria del sitio ({ $tz })
|
||||
field-theme = Tema
|
||||
value-theme-site-default = Tema del sitio: { $theme }
|
||||
field-machine-name = Nombre técnico
|
||||
field-label = Etiqueta
|
||||
field-description = Descripción
|
||||
|
|
@ -121,6 +131,9 @@ field-search-users = Buscar por usuario, email o nombre...
|
|||
help-machine-name-immutable =
|
||||
Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo.
|
||||
|
||||
help-admin-roles =
|
||||
Como administrador tiene todos los permisos. Estos roles se aplicarán si deja de serlo.
|
||||
|
||||
# **< Administración: botones y enlaces >**
|
||||
|
||||
btn-save = Guardar
|
||||
|
|
@ -129,7 +142,6 @@ btn-create-role = Nuevo rol
|
|||
btn-delete = Eliminar
|
||||
btn-cancel = Cancelar
|
||||
btn-edit = Editar
|
||||
btn-manage-roles = Gestionar roles
|
||||
btn-manage-permissions = Gestionar permisos
|
||||
btn-reset-password = Restablecer contraseña
|
||||
btn-block = Bloquear
|
||||
|
|
|
|||
|
|
@ -4,18 +4,16 @@
|
|||
|
||||
group-users = Gestión de usuarios
|
||||
|
||||
perm-login = Iniciar sesión
|
||||
perm-register = Registrar una cuenta nueva
|
||||
perm-view-profiles = Ver perfiles de usuarios
|
||||
perm-edit-own-profile = Editar el propio perfil
|
||||
perm-change-own-username = Cambiar el propio nombre de usuario
|
||||
perm-change-own-password = Cambiar la propia contraseña
|
||||
|
||||
# **< Grupo: Administración >**
|
||||
|
||||
group-administration = Administración
|
||||
|
||||
perm-admin-users = Administrar usuarios
|
||||
perm-admin-users = Administrar usuarios y asignarles roles
|
||||
perm-admin-roles = Administrar roles
|
||||
perm-admin-permissions = Administrar permisos
|
||||
perm-block-accounts = Bloquear y desbloquear cuentas
|
||||
perm-assign-roles = Asignar roles a usuarios
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ impl MigrationTrait for Migration {
|
|||
.col(small_integer(Users::Status).default(1))
|
||||
.col(string_len_null(Users::Language, 16))
|
||||
.col(string_len_null(Users::Timezone, 64))
|
||||
.col(string_len_null(Users::Theme, 64))
|
||||
.col(string_len_null(Users::DisplayName, 128))
|
||||
// Texto libre "Sobre mí" que el propio usuario escribe en su perfil.
|
||||
.col(text_null(Users::About))
|
||||
.col(timestamp_null(Users::LastLoginAt))
|
||||
.col(timestamp_null(Users::LastAccessAt))
|
||||
.col(integer(Users::FailedLoginCount).default(0))
|
||||
|
|
@ -47,7 +50,9 @@ pub enum Users {
|
|||
Status,
|
||||
Language,
|
||||
Timezone,
|
||||
Theme,
|
||||
DisplayName,
|
||||
About,
|
||||
LastLoginAt,
|
||||
LastAccessAt,
|
||||
FailedLoginCount,
|
||||
|
|
|
|||
|
|
@ -162,17 +162,22 @@ pub fn registry() -> &'static PermissionRegistry {
|
|||
/// Permisos propios de `pagetop-user`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum UserPermission {
|
||||
/// Iniciar sesión.
|
||||
Login,
|
||||
/// Registrar una cuenta nueva.
|
||||
Register,
|
||||
/// Ver perfiles de otros usuarios.
|
||||
/// Ver el perfil público de otros usuarios (`/user/{id}`): nombre de usuario, nombre visible,
|
||||
/// "Sobre mí" y fecha de alta.
|
||||
ViewProfiles,
|
||||
/// Editar el perfil propio.
|
||||
/// Editar los datos del perfil propio: email, nombre visible, "Sobre mí", idioma, zona horaria
|
||||
/// y tema. Nunca los roles, el estado de la cuenta ni el acceso irrestricto.
|
||||
EditOwnProfile,
|
||||
/// Cambiar el nombre de usuario propio. Complementa a `EditOwnProfile`: el campo está en el
|
||||
/// mismo formulario, así que sin aquel no tiene efecto.
|
||||
ChangeOwnUsername,
|
||||
/// Cambiar la contraseña propia.
|
||||
ChangeOwnPassword,
|
||||
/// Acceder al mantenimiento de usuarios (listado, alta, edición).
|
||||
/// Acceder al mantenimiento de usuarios (listado, alta, edición, asignación de roles,
|
||||
/// restablecimiento de contraseñas).
|
||||
///
|
||||
/// Es un permiso sensible: quien lo tiene puede asignar cualquier rol y hacerse con cualquier
|
||||
/// cuenta que no sea de administrador, así que sólo debe concederse a personas de confianza.
|
||||
AdminUsers,
|
||||
/// Acceder al mantenimiento de roles (listado, alta, edición, borrado).
|
||||
AdminRoles,
|
||||
|
|
@ -180,70 +185,59 @@ pub enum UserPermission {
|
|||
AdminPermissions,
|
||||
/// Bloquear y desbloquear cuentas de usuario.
|
||||
BlockAccounts,
|
||||
/// Asignar roles a usuarios.
|
||||
AssignRoles,
|
||||
}
|
||||
|
||||
impl UserPermission {
|
||||
/// Todas las variantes, usado para registrarlas en el catálogo.
|
||||
pub const ALL: &'static [Self] = &[
|
||||
Self::Login,
|
||||
Self::Register,
|
||||
Self::ViewProfiles,
|
||||
Self::EditOwnProfile,
|
||||
Self::ChangeOwnUsername,
|
||||
Self::ChangeOwnPassword,
|
||||
Self::AdminUsers,
|
||||
Self::AdminRoles,
|
||||
Self::AdminPermissions,
|
||||
Self::BlockAccounts,
|
||||
Self::AssignRoles,
|
||||
];
|
||||
}
|
||||
|
||||
impl Permission for UserPermission {
|
||||
fn key(&self) -> CowStr {
|
||||
match self {
|
||||
Self::Login => "user:login".into(),
|
||||
Self::Register => "user:register".into(),
|
||||
Self::ViewProfiles => "user:view_profiles".into(),
|
||||
Self::EditOwnProfile => "user:edit_own_profile".into(),
|
||||
Self::ChangeOwnUsername => "user:change_own_username".into(),
|
||||
Self::ChangeOwnPassword => "user:change_own_password".into(),
|
||||
Self::AdminUsers => "user:admin_users".into(),
|
||||
Self::AdminRoles => "user:admin_roles".into(),
|
||||
Self::AdminPermissions => "user:admin_permissions".into(),
|
||||
Self::BlockAccounts => "user:block_accounts".into(),
|
||||
Self::AssignRoles => "user:assign_roles".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> Lc {
|
||||
let key = match self {
|
||||
Self::Login => "perm-login",
|
||||
Self::Register => "perm-register",
|
||||
Self::ViewProfiles => "perm-view-profiles",
|
||||
Self::EditOwnProfile => "perm-edit-own-profile",
|
||||
Self::ChangeOwnUsername => "perm-change-own-username",
|
||||
Self::ChangeOwnPassword => "perm-change-own-password",
|
||||
Self::AdminUsers => "perm-admin-users",
|
||||
Self::AdminRoles => "perm-admin-roles",
|
||||
Self::AdminPermissions => "perm-admin-permissions",
|
||||
Self::BlockAccounts => "perm-block-accounts",
|
||||
Self::AssignRoles => "perm-assign-roles",
|
||||
};
|
||||
Lc::t(key, &LOCALES_USER)
|
||||
}
|
||||
|
||||
fn group(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Login
|
||||
| Self::Register
|
||||
| Self::ViewProfiles
|
||||
Self::ViewProfiles
|
||||
| Self::EditOwnProfile
|
||||
| Self::ChangeOwnUsername
|
||||
| Self::ChangeOwnPassword => GROUP_USERS,
|
||||
Self::AdminUsers
|
||||
| Self::AdminRoles
|
||||
| Self::AdminPermissions
|
||||
| Self::BlockAccounts
|
||||
| Self::AssignRoles => GROUP_ADMINISTRATION,
|
||||
Self::AdminUsers | Self::AdminRoles | Self::AdminPermissions | Self::BlockAccounts => {
|
||||
GROUP_ADMINISTRATION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ pub(crate) struct NewUserData<'a> {
|
|||
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,
|
||||
|
|
@ -173,7 +174,9 @@ pub(crate) struct NewUserData<'a> {
|
|||
pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> {
|
||||
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?;
|
||||
|
||||
|
|
@ -187,9 +190,11 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
|
|||
email_verified_at: Set(Some(now)),
|
||||
password_hash: Set(hash),
|
||||
status: Set(UserStatus::Active.as_i16()),
|
||||
language: Set(data.language.map(str::to_owned)),
|
||||
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),
|
||||
|
|
@ -210,16 +215,35 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
|
|||
|
||||
// **< 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 `<textarea>` como `\r\n`, pero `maxlength` puede
|
||||
// contarlos como un único carácter: se normalizan antes de medir para no rechazar un texto que
|
||||
// el formulario sí admitió.
|
||||
let about = data.about.map(|about| about.replace("\r\n", "\n"));
|
||||
let max_about = usize::from(ABOUT_MAX_CHARS);
|
||||
if about
|
||||
.as_deref()
|
||||
.is_some_and(|about| about.chars().count() > max_about)
|
||||
{
|
||||
return Err(AuthError::AboutTooLong(max_about));
|
||||
}
|
||||
ensure_username_available(data.username, Some(user_id)).await?;
|
||||
ensure_email_available(data.email, Some(user_id)).await?;
|
||||
|
||||
|
|
@ -229,8 +253,10 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
|
|||
username: Set(data.username.to_owned()),
|
||||
email: Set(data.email.to_owned()),
|
||||
display_name: Set(data.display_name.map(str::to_owned)),
|
||||
language: Set(data.language.map(str::to_owned)),
|
||||
about: Set(about),
|
||||
language: Set(language.map(str::to_owned)),
|
||||
timezone: Set(timezone.map(str::to_owned)),
|
||||
theme: Set(theme.map(str::to_owned)),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -320,7 +346,7 @@ pub(crate) async fn set_user_status(
|
|||
// **< set_user_admin >*****************************************************************************
|
||||
|
||||
/// Concede o revoca el acceso irrestricto (`is_admin`). No es un permiso del catálogo: sólo un
|
||||
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin`
|
||||
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin()`
|
||||
/// directamente, sin pasar por `require_permission`).
|
||||
///
|
||||
/// Rechaza que un administrador se automodifique el flag. No hace falta proteger aparte al
|
||||
|
|
@ -380,12 +406,44 @@ pub(crate) async fn admin_reset_password(
|
|||
|
||||
// **< HELPERS >************************************************************************************
|
||||
|
||||
// Devuelve la zona sin espacios, tal como debe guardarse. Una zona ausente o en blanco es válida y
|
||||
// devuelve `None`: equivale a usar la predeterminada de la aplicación.
|
||||
// Devuelve el idioma sin espacios, tal como debe guardarse. Ha de ser uno de los identificadores
|
||||
// que ofrece el selector (`Locale::supported_languages()`); uno ausente o en blanco es válido y
|
||||
// devuelve `None`: equivale a usar el predeterminado de la aplicación.
|
||||
fn validate_language(language: Option<&str>) -> Result<Option<&str>, AuthError> {
|
||||
let language = language.and_then(util::non_blank);
|
||||
if let Some(code) = language
|
||||
&& !Locale::supported_languages()
|
||||
.iter()
|
||||
.any(|(langid, _)| langid.to_string() == code)
|
||||
{
|
||||
return Err(AuthError::InvalidLanguage);
|
||||
}
|
||||
Ok(language)
|
||||
}
|
||||
|
||||
// Devuelve el nombre corto del tema tal como debe guardarse, el que declara el propio tema aunque
|
||||
// llegue con otras mayúsculas. Ha de ser uno de los temas habilitados; uno ausente o en blanco es
|
||||
// válido y devuelve `None`: equivale a usar el predeterminado de la aplicación.
|
||||
fn validate_theme(theme: Option<&str>) -> Result<Option<&'static str>, AuthError> {
|
||||
match theme.and_then(util::non_blank) {
|
||||
Some(name) => theme_by_short_name(name)
|
||||
.map(|theme| Some(theme.short_name()))
|
||||
.ok_or(AuthError::InvalidTheme),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// Devuelve la zona sin espacios, tal como debe guardarse. Ha de ser una de las que ofrece el
|
||||
// selector (`Timezone::supported_by_region()`); una ausente o en blanco es válida y devuelve
|
||||
// `None`: equivale a usar la predeterminada de la aplicación.
|
||||
fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError> {
|
||||
let timezone = timezone.and_then(util::non_blank);
|
||||
if let Some(tz) = timezone {
|
||||
tz.parse::<Tz>().map_err(|_| AuthError::InvalidTimezone)?;
|
||||
if let Some(tz) = timezone
|
||||
&& !Timezone::supported_by_region()
|
||||
.iter()
|
||||
.any(|(_, names)| names.contains(&tz))
|
||||
{
|
||||
return Err(AuthError::InvalidTimezone);
|
||||
}
|
||||
Ok(timezone)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ pub fn extract_sid(headers: Option<&web::http::HeaderMap>) -> Option<String> {
|
|||
|
||||
/// Lee la cookie de sesión de las cabeceras y resuelve el par `(CurrentUser, Option<Account>)`.
|
||||
///
|
||||
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::Anonymous, None)`.
|
||||
/// Si no hay cookie o la sesión ha expirado, devuelve `(CurrentUser::anonymous(), None)`.
|
||||
/// Se llama desde el middleware de sesión, que es async.
|
||||
pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Option<Account>) {
|
||||
let Some(sid) = extract_sid(Some(headers)) else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
load_user_from_session(&sid).await
|
||||
}
|
||||
|
|
@ -88,24 +88,25 @@ pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Op
|
|||
|
||||
/// Carga el par `(CurrentUser, Option<Account>)` a partir de un session ID.
|
||||
///
|
||||
/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado.
|
||||
/// Devuelve `(CurrentUser::anonymous(), None)` si la sesión no existe o ha expirado.
|
||||
pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) {
|
||||
let now = Utc::now();
|
||||
|
||||
// Buscar sesión activa y no expirada.
|
||||
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
if sess.expires_at < now {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
}
|
||||
|
||||
// Cargar usuario con estado activo.
|
||||
let Ok(Some(user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
let Ok(Some(mut user_model)) = user::Entity::find_by_id(sess.user_id).one(dbconn()).await
|
||||
else {
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
if UserStatus::from_i16(user_model.status) != UserStatus::Active {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
}
|
||||
|
||||
// Cargar roles explícitos del usuario.
|
||||
|
|
@ -114,7 +115,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
|
|||
.all(dbconn())
|
||||
.await
|
||||
else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
|
||||
let role_ids: Vec<i32> = user_role_rows.iter().map(|ur| ur.role_id).collect();
|
||||
|
|
@ -124,7 +125,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
|
|||
.all(dbconn())
|
||||
.await
|
||||
else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
|
||||
let is_admin = user_model.is_admin;
|
||||
|
|
@ -145,7 +146,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
|
|||
.all(dbconn())
|
||||
.await
|
||||
else {
|
||||
return (CurrentUser::Anonymous, None);
|
||||
return (CurrentUser::anonymous(), None);
|
||||
};
|
||||
PermissionSet::new(perm_rows.into_iter().map(|p| p.permission_key))
|
||||
};
|
||||
|
|
@ -162,31 +163,14 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
|
|||
let _ = active.update(dbconn()).await;
|
||||
}
|
||||
|
||||
let display_name = user_model.display_name.unwrap_or_default();
|
||||
let visible_name = if display_name.is_empty() {
|
||||
user_model.username.clone()
|
||||
} else {
|
||||
display_name.clone()
|
||||
};
|
||||
let account = Account {
|
||||
id: user_model.id,
|
||||
username: user_model.username,
|
||||
email: user_model.email,
|
||||
display_name,
|
||||
status: UserStatus::from_i16(user_model.status),
|
||||
roles: role_names,
|
||||
permissions,
|
||||
is_admin,
|
||||
};
|
||||
let timezone = user_model
|
||||
.timezone
|
||||
.as_deref()
|
||||
.and_then(|tz| tz.parse().ok());
|
||||
let current_user = CurrentUser::Authenticated {
|
||||
id: account.id,
|
||||
display_name: visible_name,
|
||||
timezone,
|
||||
};
|
||||
let language = user_model.language.take();
|
||||
let timezone = user_model.timezone.take();
|
||||
let theme = user_model.theme.take();
|
||||
let account = Account::new(user_model, role_names, permissions);
|
||||
let current_user = CurrentUser::authenticated(account.id(), account.display())
|
||||
.with_language(language.as_deref())
|
||||
.with_timezone(timezone.as_deref())
|
||||
.with_theme(theme.as_deref());
|
||||
|
||||
(current_user, Some(account))
|
||||
}
|
||||
|
|
@ -227,3 +211,14 @@ pub async fn destroy_user_sessions(user_id: i32) -> Result<(), DbErr> {
|
|||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destruye todas las sesiones de un usuario salvo `keep_sid` (p. ej. cuando el propio usuario
|
||||
/// cambia su contraseña, para no cerrarle la sesión desde la que lo hace).
|
||||
pub async fn destroy_other_sessions(user_id: i32, keep_sid: &str) -> Result<(), DbErr> {
|
||||
session::Entity::delete_many()
|
||||
.filter(session::Column::UserId.eq(user_id))
|
||||
.filter(session::Column::Sid.ne(keep_sid))
|
||||
.exec(dbconn())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
38
extensions/pagetop-user/tests/user_name.rs
Normal file
38
extensions/pagetop-user/tests/user_name.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Verifies how `UserName` renders when nobody can view the profile. Rendering the link needs a
|
||||
// request with a session or with `user:view_profiles` granted, so it is not covered here.
|
||||
|
||||
use pagetop_user::prelude::*;
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn without_a_request_the_name_is_plain_text() {
|
||||
let mut cx = Context::default();
|
||||
let html = UserName::of(42, "Ana").render(&mut cx).await.into_string();
|
||||
|
||||
assert_eq!(html, r#"<span class="user-name">Ana</span>"#);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn the_name_is_escaped() {
|
||||
let mut cx = Context::default();
|
||||
let html = UserName::of(42, "<b>Ana</b>")
|
||||
.render(&mut cx)
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("<b>Ana</b>"));
|
||||
assert!(!html.contains("<b>"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn props_are_applied() {
|
||||
let mut cx = Context::default();
|
||||
let html = UserName::of(42, "Ana")
|
||||
.with_prop(PropsOp::add_classes("author"))
|
||||
.render(&mut cx)
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains(r#"class="user-name author""#));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue