✨ Añade pagetop-user, con autenticación y RBAC
Identidad de usuario, sesiones en base de datos, roles y permisos (RBAC) con "authenticated" implícito, contraseñas con Argon2id, tokens de un solo uso para verificación de email y restablecimiento de contraseña, y la UI de administración completa (usuarios, roles, permisos) sobre pagetop-admin. Incluye datos de demostración opcionales tras la feature `demo-data`.
This commit is contained in:
parent
043873a954
commit
def0513246
57 changed files with 7409 additions and 0 deletions
58
extensions/pagetop-user/src/component/admin.rs
Normal file
58
extensions/pagetop-user/src/component/admin.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Componentes de administración: listados y mantenimiento de usuarios, roles y permisos.
|
||||
//!
|
||||
//! Todos son `pub(crate)`: son UI interna de esta extensión, no forman parte del *prelude* público.
|
||||
|
||||
mod admin_password_form;
|
||||
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;
|
||||
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::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
// **< constantes compartidas >**********************************************************************
|
||||
|
||||
/// Identificador del `<form>` de [`UserForm`] en modo [`UserFormMode::Edit`]. En ese modo, el botón
|
||||
/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`): lo añade la pantalla
|
||||
/// de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones, referenciando
|
||||
/// este id mediante el atributo `form` para seguir enviando el formulario aunque esté fuera de él.
|
||||
pub(crate) const USER_ADMIN_FORM_ID: &str = "user-admin-form";
|
||||
|
||||
// **< tipos compartidos >***************************************************************************
|
||||
|
||||
/// Un permiso dentro de un grupo del catálogo: `(clave, etiqueta, concedido)`.
|
||||
pub(crate) type PermissionItem = (CowStr, Lc, bool);
|
||||
|
||||
/// Catálogo de permisos agrupado: `(título del grupo, permisos del grupo)`.
|
||||
pub(crate) type PermissionGroups = Vec<(Lc, Vec<PermissionItem>)>;
|
||||
|
||||
// **< helpers compartidos >*************************************************************************
|
||||
|
||||
// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla
|
||||
// dedicada 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`.
|
||||
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 {
|
||||
field = field.with_item(
|
||||
form::check::Item::new(role_id.to_string(), Lc::n(label.clone()))
|
||||
.with_checked(*checked),
|
||||
);
|
||||
}
|
||||
form::Fieldset::new()
|
||||
.with_legend(Lc::t("field-roles", &LOCALES_USER))
|
||||
.with_child(field)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
//! Formulario de restablecimiento de contraseña por un administrador (sin contraseña actual).
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
use crate::component::{PasswordConfirm, error_banner};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct AdminPasswordForm {
|
||||
user_id: i32,
|
||||
error: Option<Lc>,
|
||||
/// Listado de origen al que volver tras guardar (orden, búsqueda, página).
|
||||
waypoint: Waypoint,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for AdminPasswordForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = format!("{ADMIN_USERS_PATH}/{}/password", self.user_id());
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id("user-admin-password-form")
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(
|
||||
PasswordConfirm::new()
|
||||
.with_password_label(Lc::t("field-new-password", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminPasswordForm {
|
||||
// **< AdminPasswordForm BUILDER >**************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_user_id(mut self, user_id: i32) -> Self {
|
||||
self.user_id = user_id;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
151
extensions/pagetop-user/src/component/admin/role_form.rs
Normal file
151
extensions/pagetop-user/src/component/admin/role_form.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
//! Formulario de alta/edición de rol.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_ROLES_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
use crate::component::error_banner;
|
||||
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum RoleFormMode {
|
||||
#[default]
|
||||
New,
|
||||
Edit,
|
||||
}
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct RoleForm {
|
||||
mode: RoleFormMode,
|
||||
error: Option<Lc>,
|
||||
role_id: Option<i32>,
|
||||
/// Listado de origen al que volver tras guardar (orden).
|
||||
waypoint: Waypoint,
|
||||
machine_name: String,
|
||||
label: String,
|
||||
description: String,
|
||||
weight: i32,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for RoleForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = match self.mode() {
|
||||
RoleFormMode::New => format!("{ADMIN_ROLES_PATH}/new"),
|
||||
RoleFormMode::Edit => {
|
||||
format!(
|
||||
"{ADMIN_ROLES_PATH}/{}/edit",
|
||||
self.role_id().copied().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let machine_name_field = match self.mode() {
|
||||
RoleFormMode::New => form::input::Field::text()
|
||||
.with_name("machine_name")
|
||||
.with_value(self.machine_name())
|
||||
.with_label(Lc::t("field-machine-name", &LOCALES_USER))
|
||||
.with_help_text(Lc::t("help-machine-name-immutable", &LOCALES_USER))
|
||||
.with_required(true)
|
||||
.with_maxlength(Some(64)),
|
||||
RoleFormMode::Edit => form::input::Field::text()
|
||||
.with_name("machine_name")
|
||||
.with_value(self.machine_name())
|
||||
.with_label(Lc::t("field-machine-name", &LOCALES_USER))
|
||||
.with_plaintext(true),
|
||||
};
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id("role-admin-form")
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(machine_name_field)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("label")
|
||||
.with_value(self.label())
|
||||
.with_label(Lc::t("field-label", &LOCALES_USER))
|
||||
.with_required(true)
|
||||
.with_maxlength(Some(128)),
|
||||
)
|
||||
.with_child(
|
||||
form::Textarea::new()
|
||||
.with_name("description")
|
||||
.with_value(self.description())
|
||||
.with_label(Lc::t("field-description", &LOCALES_USER))
|
||||
.with_rows(Some(3)),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("weight")
|
||||
.with_value(self.weight().to_string())
|
||||
.with_label(Lc::t("field-weight", &LOCALES_USER))
|
||||
.with_inputmode(Some(form::input::Mode::Numeric)),
|
||||
);
|
||||
|
||||
form = form.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl RoleForm {
|
||||
// **< RoleForm BUILDER >***********************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_mode(mut self, mode: RoleFormMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_role_id(mut self, role_id: impl Into<Option<i32>>) -> Self {
|
||||
self.role_id = role_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_machine_name(mut self, machine_name: impl Into<String>) -> Self {
|
||||
self.machine_name = machine_name.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_label(mut self, label: impl Into<String>) -> Self {
|
||||
self.label = label.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = description.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_weight(mut self, weight: i32) -> Self {
|
||||
self.weight = weight;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
//! Formulario de asignación de permisos a un rol, agrupados por categoría. Reemplaza siempre el
|
||||
//! conjunto completo. Permitido incluso en roles bloqueados (`locked`): los roles de sistema
|
||||
//! también necesitan permisos gestionables.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_ROLES_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
use crate::component::admin::PermissionGroups;
|
||||
use crate::component::error_banner;
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct RolePermissionsForm {
|
||||
role_id: i32,
|
||||
error: Option<Lc>,
|
||||
groups: PermissionGroups,
|
||||
/// Listado de origen al que volver tras guardar (orden).
|
||||
waypoint: Waypoint,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for RolePermissionsForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = format!("{ADMIN_ROLES_PATH}/{}/permissions", self.role_id());
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id("role-permissions-form")
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()));
|
||||
|
||||
for (idx, (group_label, perms)) in self.groups().iter().enumerate() {
|
||||
let mut field = form::check::Field::new()
|
||||
.with_id(format!("permission-group-{idx}"))
|
||||
.with_name("permission_keys");
|
||||
for (key, label, checked) in perms {
|
||||
let text = label.lookup(cx).unwrap_or_default();
|
||||
field = field.with_item(
|
||||
form::check::Item::new(key, Lc::n(format!("{text} ({key})")))
|
||||
.with_checked(*checked),
|
||||
);
|
||||
}
|
||||
let fieldset = form::Fieldset::new()
|
||||
.with_legend(group_label.clone())
|
||||
.with_child(field);
|
||||
form = form.with_child(fieldset);
|
||||
}
|
||||
|
||||
form = form.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl RolePermissionsForm {
|
||||
// **< RolePermissionsForm BUILDER >************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_role_id(mut self, role_id: i32) -> Self {
|
||||
self.role_id = role_id;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_groups(mut self, groups: PermissionGroups) -> Self {
|
||||
self.groups = groups;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
305
extensions/pagetop-user/src/component/admin/role_table.rs
Normal file
305
extensions/pagetop-user/src/component/admin/role_table.rs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
//! Tabla de roles: cabeceras ordenables y filas con acciones.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use pagetop::base::component::table::{Column, Row};
|
||||
use pagetop_htmx::hx;
|
||||
use pagetop_htmx::hx_table::sort_link;
|
||||
|
||||
use crate::ADMIN_ROLES_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
use crate::service::role_admin::{RoleListItem, RoleSortField};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct RoleTable {
|
||||
props: Props,
|
||||
items: Vec<RoleListItem>,
|
||||
sort: RoleSortField,
|
||||
dir: SortDir,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
total: u64,
|
||||
/// Mensaje de error a mostrar (p. ej. al fallar un borrado). No persiste entre peticiones.
|
||||
message: Option<Lc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for RoleTable {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
self.alter_prop(PropsOp::set_id("role-table-wrapper"));
|
||||
self.alter_prop(PropsOp::prepend_classes("user-admin-table-wrapper"));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let pager = Pager::new()
|
||||
.with_base_path(ADMIN_ROLES_PATH)
|
||||
.with_extra_query("sort", self.sort().as_str())
|
||||
.with_extra_query("dir", *self.dir())
|
||||
.with_current_page(self.page())
|
||||
.with_items_per_page(self.per_page())
|
||||
.with_total_items(self.total())
|
||||
.with_prop(PropsOp::set(hx::BOOST, "true"))
|
||||
.with_prop(PropsOp::set(hx::TARGET, "#role-table-wrapper"))
|
||||
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP))
|
||||
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
let mut table = Table::new()
|
||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||
.with_column(self.sort_column(cx, RoleSortField::MachineName, "col-machine-name"))
|
||||
.with_column(self.sort_column(cx, RoleSortField::Label, "col-label"))
|
||||
.with_column(Lc::t("col-type", &LOCALES_USER))
|
||||
.with_column(Lc::t("col-users-count", &LOCALES_USER))
|
||||
.with_column(Lc::t("col-actions", &LOCALES_USER))
|
||||
.with_empty(Lc::t("empty-roles-list", &LOCALES_USER));
|
||||
|
||||
let confirm_dialog = Dialog::new()
|
||||
.with_id("confirm-delete-role")
|
||||
.with_child(Html::with(|cx| {
|
||||
html! { p { (Lc::t("confirm-delete-role", &LOCALES_USER).using(cx)) } }
|
||||
}))
|
||||
.with_footer(
|
||||
Button::plain(Lc::t("btn-cancel", &LOCALES_USER))
|
||||
.with_prop(PropsOp::set("data-dialog-dismiss", "modal")),
|
||||
)
|
||||
.with_footer(Html::with(
|
||||
|_cx| html! { span id="role-delete-confirm-action" {} },
|
||||
))
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
let waypoint = Waypoint::from(self.list_href(cx));
|
||||
|
||||
for role in self.items() {
|
||||
let system_badge = if role.locked {
|
||||
Some(
|
||||
Badge::labeled(Lc::t("badge-system-role", &LOCALES_USER))
|
||||
.with_prop(PropsOp::add_classes("user-admin-badge-system"))
|
||||
.render(cx)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
table.alter_row(
|
||||
Row::new()
|
||||
.with_cell(role.machine_name.as_str())
|
||||
.with_cell(label_cell(role, &waypoint))
|
||||
.with_cell(Html::with(move |_cx| {
|
||||
html! {
|
||||
@if let Some(badge) = &system_badge { (badge) }
|
||||
}
|
||||
}))
|
||||
.with_cell(role.user_count.to_string())
|
||||
.with_cell(
|
||||
actions_cell(role, &waypoint, *self.sort(), *self.dir(), self.page(), cx)
|
||||
.await,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/new")));
|
||||
|
||||
Ok(html! {
|
||||
div (self.props()) {
|
||||
div.user-admin-actions {
|
||||
a href=(new_href) {
|
||||
(Lc::t("btn-create-role", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
@if let Some(message) = self.message() {
|
||||
div.user-form-error role="alert" { (message.clone().using(cx)) }
|
||||
}
|
||||
(table.render(cx).await)
|
||||
(pager)
|
||||
(confirm_dialog)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RoleTable {
|
||||
// **< RoleTable BUILDER >**********************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_prop(mut self, op: PropsOp) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_items(mut self, items: Vec<RoleListItem>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_message(mut self, message: impl Into<Option<Lc>>) -> Self {
|
||||
self.message = message.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_sort(mut self, sort: RoleSortField) -> Self {
|
||||
self.sort = sort;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_dir(mut self, dir: SortDir) -> Self {
|
||||
self.dir = dir;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_page(mut self, page: u64) -> Self {
|
||||
self.page = page;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_per_page(mut self, per_page: u64) -> Self {
|
||||
self.per_page = per_page;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_total(mut self, total: u64) -> Self {
|
||||
self.total = total;
|
||||
self
|
||||
}
|
||||
|
||||
// URL del listado con el estado actual (orden, página): es el valor que viaja como
|
||||
// `waypoint` en los enlaces de ver/editar/permisos, para poder volver exactamente a este
|
||||
// mismo estado. Se construye con `cx.route()` para que preserve el parámetro `lang` cuando
|
||||
// corresponda.
|
||||
fn list_href(&self, cx: &Context) -> String {
|
||||
cx.route(ADMIN_ROLES_PATH)
|
||||
.alter_param("sort", self.sort().as_str())
|
||||
.alter_param("dir", *self.dir())
|
||||
.alter_param("page", self.page().to_string())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
||||
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
||||
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
||||
fn sort_column(&self, cx: &Context, field: RoleSortField, label_key: &'static str) -> Column {
|
||||
let is_active = *self.sort() == field;
|
||||
let active = is_active.then_some(*self.dir());
|
||||
let next_dir = SortDir::next_for(active);
|
||||
let mut route = cx.route(ADMIN_ROLES_PATH);
|
||||
route
|
||||
.alter_param("sort", field.as_str())
|
||||
.alter_param("dir", next_dir);
|
||||
|
||||
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
||||
route,
|
||||
"#role-table-wrapper",
|
||||
active,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Construye la celda de etiqueta: enlaza a la pantalla de sólo lectura con todos los permisos del
|
||||
// rol. Devuelve un componente `Html` para que el marcado se genere cuando `Table` renderice la
|
||||
// celda, no al construir la fila.
|
||||
fn label_cell(role: &RoleListItem, waypoint: &Waypoint) -> Html {
|
||||
let label = role.label.clone();
|
||||
let id = role.id;
|
||||
let waypoint = waypoint.clone();
|
||||
Html::with(move |cx| {
|
||||
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/view")));
|
||||
html! {
|
||||
a href=(view_href) { (label.as_str()) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Construye la celda de acciones: gestionar permisos siempre, y editar/borrar sólo si el rol no
|
||||
// está bloqueado por el sistema. Devuelve un componente `Html` para que el marcado se genere
|
||||
// cuando `Table` renderice la celda, no al construir la fila.
|
||||
async fn actions_cell(
|
||||
role: &RoleListItem,
|
||||
waypoint: &Waypoint,
|
||||
sort: RoleSortField,
|
||||
dir: SortDir,
|
||||
page: u64,
|
||||
cx: &mut Context,
|
||||
) -> Html {
|
||||
let id = role.id;
|
||||
let locked = role.locked;
|
||||
let waypoint = waypoint.clone();
|
||||
|
||||
let permissions_href =
|
||||
waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/permissions")));
|
||||
|
||||
// Los botones se renderizan aquí, no dentro del `Html::with()` de abajo: necesitan pasar por
|
||||
// su propio ciclo de renderizado (`.render().await`) para que el tema activo los estilice
|
||||
// igual (ver `pagetop-bootsier::theme::bs::button`), incluida la traducción de `data-dialog-*`
|
||||
// que usa el botón de borrado.
|
||||
let permissions_button = Button::anchor(
|
||||
Lc::t("btn-manage-permissions", &LOCALES_USER),
|
||||
permissions_href,
|
||||
)
|
||||
.with_style(button::Style::Solid(Intent::Neutral))
|
||||
.with_size(button::Size::Small)
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
let (edit_button, delete_button) = if locked {
|
||||
(None, None)
|
||||
} else {
|
||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_ROLES_PATH}/{id}/edit")));
|
||||
let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href)
|
||||
.with_style(button::Style::Solid(Intent::Primary))
|
||||
.with_size(button::Size::Small)
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
// Viaja como query string para que, tanto si el borrado falla como si tiene éxito, la
|
||||
// tabla vuelva a mostrarse en la misma página/orden en que estaba, en vez de reiniciarse.
|
||||
let confirm_href = cx
|
||||
.route(format!("{ADMIN_ROLES_PATH}/{id}/delete/confirm"))
|
||||
.alter_param("sort", sort.as_str())
|
||||
.alter_param("dir", dir)
|
||||
.alter_param("page", page.to_string())
|
||||
.to_string();
|
||||
|
||||
let delete_button = Button::plain(Lc::t("btn-delete", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Severe))
|
||||
.with_size(button::Size::Small)
|
||||
.with_prop(PropsOp::set(hx::GET, confirm_href))
|
||||
.with_prop(PropsOp::set(hx::TARGET, "#role-delete-confirm-action"))
|
||||
.with_prop(PropsOp::set("data-dialog-toggle", "modal"))
|
||||
.with_prop(PropsOp::set("data-dialog-target", "#confirm-delete-role"))
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
(Some(edit_button), Some(delete_button))
|
||||
};
|
||||
|
||||
Html::with(move |_cx| {
|
||||
html! {
|
||||
(permissions_button)
|
||||
@if let Some(edit_button) = &edit_button {
|
||||
" "
|
||||
(edit_button)
|
||||
}
|
||||
@if let Some(delete_button) = &delete_button {
|
||||
" "
|
||||
(delete_button)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
198
extensions/pagetop-user/src/component/admin/user_form.rs
Normal file
198
extensions/pagetop-user/src/component/admin/user_form.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! Formulario de alta/edición de usuario.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
use crate::component::{PasswordConfirm, error_banner};
|
||||
|
||||
use super::{USER_ADMIN_FORM_ID, roles_fieldset};
|
||||
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum UserFormMode {
|
||||
#[default]
|
||||
New,
|
||||
Edit,
|
||||
}
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct UserForm {
|
||||
mode: UserFormMode,
|
||||
error: Option<Lc>,
|
||||
user_id: Option<i32>,
|
||||
/// Listado de origen al que volver tras guardar (orden, búsqueda, página).
|
||||
waypoint: Waypoint,
|
||||
username: String,
|
||||
email: String,
|
||||
display_name: String,
|
||||
language: String,
|
||||
timezone: String,
|
||||
/// Roles asignables (excluye "anonymous" y "authenticated"); sólo se renderiza en modo `New`.
|
||||
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,
|
||||
is_admin: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for UserForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = match self.mode() {
|
||||
UserFormMode::New => format!("{ADMIN_USERS_PATH}/new"),
|
||||
UserFormMode::Edit => {
|
||||
format!(
|
||||
"{ADMIN_USERS_PATH}/{}/edit",
|
||||
self.user_id().copied().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
let action = self.waypoint().append_to(cx.route(action));
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id(USER_ADMIN_FORM_ID)
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("username")
|
||||
.with_value(self.username())
|
||||
.with_label(Lc::t("field-username-admin", &LOCALES_USER))
|
||||
.with_required(true)
|
||||
.with_maxlength(Some(64)),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::email()
|
||||
.with_name("email")
|
||||
.with_value(self.email())
|
||||
.with_label(Lc::t("field-email", &LOCALES_USER))
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("display_name")
|
||||
.with_value(self.display_name())
|
||||
.with_label(Lc::t("field-display-name", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("language")
|
||||
.with_value(self.language())
|
||||
.with_label(Lc::t("field-language", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("timezone")
|
||||
.with_value(self.timezone())
|
||||
.with_label(Lc::t("field-timezone", &LOCALES_USER)),
|
||||
);
|
||||
|
||||
if *self.mode() == UserFormMode::New {
|
||||
form = form
|
||||
.with_child(PasswordConfirm::new())
|
||||
.with_child(roles_fieldset(self.roles()));
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
form = form.with_child(
|
||||
Button::submit(Lc::t("btn-save", &LOCALES_USER))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl UserForm {
|
||||
// **< UserForm BUILDER >***********************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_mode(mut self, mode: UserFormMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_user_id(mut self, user_id: impl Into<Option<i32>>) -> Self {
|
||||
self.user_id = user_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_username(mut self, username: impl Into<String>) -> Self {
|
||||
self.username = username.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_email(mut self, email: impl Into<String>) -> Self {
|
||||
self.email = email.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
|
||||
self.display_name = display_name.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_language(mut self, language: impl Into<String>) -> Self {
|
||||
self.language = language.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_timezone(mut self, timezone: impl Into<String>) -> Self {
|
||||
self.timezone = timezone.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self {
|
||||
self.roles = roles;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_allow_admin_field(mut self, allow_admin_field: bool) -> Self {
|
||||
self.allow_admin_field = allow_admin_field;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_is_admin(mut self, is_admin: bool) -> Self {
|
||||
self.is_admin = is_admin;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
//! Formulario de asignación de roles a un usuario. Reemplaza siempre el conjunto completo.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
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 = format!("{ADMIN_USERS_PATH}/{}/roles", self.user_id());
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRolesForm {
|
||||
// **< UserRolesForm BUILDER >******************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_user_id(mut self, user_id: i32) -> Self {
|
||||
self.user_id = user_id;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_roles(mut self, roles: Vec<(i32, String, bool)>) -> Self {
|
||||
self.roles = roles;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
304
extensions/pagetop-user/src/component/admin/user_table.rs
Normal file
304
extensions/pagetop-user/src/component/admin/user_table.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
//! Tabla de usuarios: cabeceras ordenables, filas y paginación embebida.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use pagetop::base::component::table::{Column, Row};
|
||||
use pagetop_htmx::hx;
|
||||
use pagetop_htmx::hx_table::sort_link;
|
||||
|
||||
use crate::ADMIN_USERS_PATH;
|
||||
use crate::LOCALES_USER;
|
||||
use crate::account::UserStatus;
|
||||
use crate::permission::UserPermission;
|
||||
use crate::service::user_admin::{UserListItem, UserSortField};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct UserTable {
|
||||
props: Props,
|
||||
items: Vec<UserListItem>,
|
||||
sort: UserSortField,
|
||||
dir: SortDir,
|
||||
query: Option<String>,
|
||||
page: u64,
|
||||
per_page: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for UserTable {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
self.alter_prop(PropsOp::set_id("user-table-wrapper"));
|
||||
self.alter_prop(PropsOp::prepend_classes("user-admin-table-wrapper"));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let pager = Pager::new()
|
||||
.with_base_path(ADMIN_USERS_PATH)
|
||||
.with_extra_query("q", self.query().cloned().unwrap_or_default())
|
||||
.with_extra_query("sort", self.sort().as_str())
|
||||
.with_extra_query("dir", *self.dir())
|
||||
.with_current_page(self.page())
|
||||
.with_items_per_page(self.per_page())
|
||||
.with_total_items(self.total())
|
||||
.with_prop(PropsOp::set(hx::BOOST, "true"))
|
||||
.with_prop(PropsOp::set(hx::TARGET, "#user-table-wrapper"))
|
||||
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML_SCROLL_TOP))
|
||||
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
let mut table = Table::new()
|
||||
.with_prop(PropsOp::add_classes("user-admin-table"))
|
||||
.with_column(self.sort_column(cx, UserSortField::Username, "col-username"))
|
||||
.with_column(self.sort_column(cx, UserSortField::Email, "col-email"))
|
||||
.with_column(Lc::t("col-display-name", &LOCALES_USER))
|
||||
.with_column(Lc::t("col-roles", &LOCALES_USER))
|
||||
.with_column(Lc::t("col-status", &LOCALES_USER))
|
||||
.with_column(Lc::t("col-actions", &LOCALES_USER))
|
||||
.with_empty(Lc::t("empty-users-list", &LOCALES_USER));
|
||||
|
||||
let waypoint = Waypoint::from(self.list_href(cx));
|
||||
let can_assign_roles = cx
|
||||
.request()
|
||||
.is_some_and(|r| has_permission(r, &UserPermission::AssignRoles));
|
||||
|
||||
for user in self.items() {
|
||||
let status = user.status;
|
||||
|
||||
table.alter_row(
|
||||
Row::new()
|
||||
.with_cell(username_cell(user, &waypoint))
|
||||
.with_cell(user.email.as_str())
|
||||
.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, cx).await),
|
||||
);
|
||||
}
|
||||
|
||||
let new_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/new")));
|
||||
|
||||
Ok(html! {
|
||||
div (self.props()) {
|
||||
div.user-admin-actions {
|
||||
a href=(new_href) {
|
||||
(Lc::t("btn-create-user", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
(table.render(cx).await)
|
||||
(pager)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTable {
|
||||
// **< UserTable BUILDER >**********************************************************************
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_prop(mut self, op: PropsOp) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_items(mut self, items: Vec<UserListItem>) -> Self {
|
||||
self.items = items;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_sort(mut self, sort: UserSortField) -> Self {
|
||||
self.sort = sort;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_dir(mut self, dir: SortDir) -> Self {
|
||||
self.dir = dir;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_query(mut self, query: impl Into<Option<String>>) -> Self {
|
||||
self.query = query.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_page(mut self, page: u64) -> Self {
|
||||
self.page = page;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_per_page(mut self, per_page: u64) -> Self {
|
||||
self.per_page = per_page;
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_total(mut self, total: u64) -> Self {
|
||||
self.total = total;
|
||||
self
|
||||
}
|
||||
|
||||
// URL del listado con el estado actual (búsqueda, orden, página): es el valor que viaja como
|
||||
// `waypoint` en los enlaces de ver/editar, para poder volver exactamente a este mismo estado.
|
||||
// Se construye con `cx.route()` para que preserve el parámetro `lang` cuando corresponda.
|
||||
fn list_href(&self, cx: &Context) -> String {
|
||||
let mut route = cx.route(ADMIN_USERS_PATH);
|
||||
if let Some(q) = self.query().filter(|q| !q.is_empty()) {
|
||||
route.alter_param("q", q);
|
||||
}
|
||||
route
|
||||
.alter_param("sort", self.sort().as_str())
|
||||
.alter_param("dir", *self.dir())
|
||||
.alter_param("page", self.page().to_string())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// Construye la cabecera ordenable de una columna: el enlace ya funciona sin HTMX (navega con
|
||||
// una petición normal); `pagetop_htmx::hx_table::sort_link()` añade aparte los atributos `hx-*`
|
||||
// para que la tabla se actualice sin recargar la página cuando la extensión esté disponible.
|
||||
fn sort_column(&self, cx: &Context, field: UserSortField, label_key: &'static str) -> Column {
|
||||
let is_active = *self.sort() == field;
|
||||
let active = is_active.then_some(*self.dir());
|
||||
let next_dir = SortDir::next_for(active);
|
||||
|
||||
let mut route = cx.route(ADMIN_USERS_PATH);
|
||||
if let Some(q) = self.query().filter(|q| !q.is_empty()) {
|
||||
route.alter_param("q", q);
|
||||
}
|
||||
route
|
||||
.alter_param("sort", field.as_str())
|
||||
.alter_param("dir", next_dir);
|
||||
|
||||
Column::new(Lc::t(label_key, &LOCALES_USER)).with_sort(sort_link(
|
||||
route,
|
||||
"#user-table-wrapper",
|
||||
active,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Construye la celda de usuario: el nombre enlaza a la pantalla de sólo lectura con todos sus
|
||||
// datos y roles. Devuelve un componente `Html` para que el marcado se genere cuando `Table`
|
||||
// renderice la celda, no al construir la fila.
|
||||
fn username_cell(user: &UserListItem, waypoint: &Waypoint) -> Html {
|
||||
let username = user.username.clone();
|
||||
let id = user.id;
|
||||
let waypoint = waypoint.clone();
|
||||
Html::with(move |cx| {
|
||||
let view_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{id}/view")));
|
||||
html! {
|
||||
a href=(view_href) { (username.as_str()) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Construye la celda de acciones: editar siempre, y gestionar roles sólo si el usuario autenticado
|
||||
// tiene permiso para asignarlos. 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,
|
||||
cx: &mut Context,
|
||||
) -> Html {
|
||||
let id = user.id;
|
||||
let edit_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_PATH}/{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
|
||||
// que el resto de acciones (ver `pagetop-bootsier::theme::bs::button`).
|
||||
let edit_button = Button::anchor(Lc::t("btn-edit", &LOCALES_USER), edit_href)
|
||||
.with_style(button::Style::Solid(Intent::Primary))
|
||||
.with_size(button::Size::Small)
|
||||
.render(cx)
|
||||
.await;
|
||||
|
||||
let roles_button = if can_assign_roles {
|
||||
let roles_href = waypoint.append_to(cx.route(format!("{ADMIN_USERS_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)
|
||||
.render(cx)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Html::with(move |_cx| {
|
||||
html! {
|
||||
(edit_button)
|
||||
@if let Some(roles_button) = &roles_button {
|
||||
" "
|
||||
(roles_button)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
async fn roles_cell(user: &UserListItem, cx: &mut Context) -> Html {
|
||||
let admin_badge = if user.is_admin {
|
||||
Some(
|
||||
Badge::labeled(Lc::t("badge-admin", &LOCALES_USER))
|
||||
.with_prop(PropsOp::add_classes("user-admin-badge-admin"))
|
||||
.render(cx)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut role_badges = Vec::with_capacity(user.roles.len());
|
||||
for role in &user.roles {
|
||||
role_badges.push(
|
||||
Badge::labeled(Lc::n(role.clone()))
|
||||
.with_prop(PropsOp::add_classes("user-admin-badge"))
|
||||
.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 { "-" }
|
||||
} @else {
|
||||
@for badge in &role_badges {
|
||||
(badge)
|
||||
" "
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn status_key(status: UserStatus) -> &'static str {
|
||||
match status {
|
||||
UserStatus::Active => "status-active",
|
||||
UserStatus::Blocked => "status-blocked",
|
||||
UserStatus::Pending => "status-pending",
|
||||
}
|
||||
}
|
||||
112
extensions/pagetop-user/src/component/login_form.rs
Normal file
112
extensions/pagetop-user/src/component/login_form.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Componente de formulario de inicio de sesión.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::component::error_banner;
|
||||
use crate::config::SETTINGS;
|
||||
use crate::{LOCALES_USER, LOGIN_PATH, PASSWORD_RESET_PATH, REGISTER_PATH};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct LoginForm {
|
||||
error: Option<Lc>,
|
||||
/// Página a la que volver tras iniciar sesión.
|
||||
waypoint: Waypoint,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for LoginForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let action = self.waypoint().append_to(cx.route(LOGIN_PATH));
|
||||
let strict = SETTINGS.login_strict;
|
||||
|
||||
// En modo estricto se dificulta que el navegador recuerde o autorrellene las
|
||||
// credenciales: nombres de campo neutros y campos `Field::strict_text()`/
|
||||
// `Field::strict_password()`, que ya incluyen el resto de medidas (ver su documentación).
|
||||
let username = if strict {
|
||||
form::input::Field::strict_text()
|
||||
} else {
|
||||
form::input::Field::text().with_autocomplete(Some(form::Autocomplete::username()))
|
||||
}
|
||||
.with_name(if strict { "ident" } else { "username" })
|
||||
.with_label(Lc::t("field-username", &LOCALES_USER))
|
||||
.with_autofocus(true)
|
||||
.with_required(true);
|
||||
|
||||
let password = if strict {
|
||||
form::input::Field::strict_password()
|
||||
} else {
|
||||
form::input::Field::password()
|
||||
.with_autocomplete(Some(form::Autocomplete::current_password()))
|
||||
}
|
||||
.with_name(if strict { "token" } else { "password" })
|
||||
.with_label(Lc::t("field-password", &LOCALES_USER))
|
||||
.with_required(true);
|
||||
|
||||
let mut form = Form::new()
|
||||
.with_id("user-login-form")
|
||||
.with_action(action)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error().cloned()))
|
||||
.with_child(username)
|
||||
.with_child(password)
|
||||
.with_child(
|
||||
form::Checkbox::check()
|
||||
.with_name("remember")
|
||||
.with_label(Lc::t("field-remember-me", &LOCALES_USER)),
|
||||
)
|
||||
.with_child(Button::submit(Lc::t("btn-login", &LOCALES_USER)))
|
||||
.with_child(links(SETTINGS.allow_registration));
|
||||
|
||||
if strict {
|
||||
form = form
|
||||
.with_prop(PropsOp::set("autocomplete", "off"))
|
||||
.with_prop(PropsOp::set("novalidate", "novalidate"));
|
||||
}
|
||||
|
||||
let form = form.render(cx).await;
|
||||
Ok(html! {
|
||||
div.user-login-page {
|
||||
div.user-login-card {
|
||||
(form)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn links(allow_registration: bool) -> Html {
|
||||
Html::with(move |cx| {
|
||||
html! {
|
||||
@if allow_registration {
|
||||
p.user-register-link {
|
||||
a href=(cx.route(REGISTER_PATH)) {
|
||||
(Lc::t("link-register", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
p.user-reset-link {
|
||||
a href=(cx.route(PASSWORD_RESET_PATH)) {
|
||||
(Lc::t("link-forgot-password", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl LoginForm {
|
||||
#[builder_fn]
|
||||
pub fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
pub fn with_waypoint(mut self, waypoint: impl Into<Waypoint>) -> Self {
|
||||
self.waypoint = waypoint.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
67
extensions/pagetop-user/src/component/password_confirm.rs
Normal file
67
extensions/pagetop-user/src/component/password_confirm.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//! Par de campos "contraseña" + "confirmar contraseña", reutilizado en los formularios de
|
||||
//! registro, alta y restablecimiento de contraseña.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
|
||||
/// Campos de contraseña y confirmación, compuestos a partir de
|
||||
/// [`form::input::Field::password()`] del core. No valida en ningún momento que ambos valores
|
||||
/// coincidan -- eso ocurre en el servidor, tras el envío del formulario -- sólo renderiza los dos
|
||||
/// campos, uno junto al otro, sin ningún contenedor propio.
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub(crate) struct PasswordConfirm {
|
||||
/// Devuelve la etiqueta del campo de contraseña.
|
||||
password_label: Lc,
|
||||
/// Devuelve la etiqueta del campo de confirmación.
|
||||
confirm_label: Lc,
|
||||
}
|
||||
|
||||
#[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).
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
password_label: Lc::t("field-password", &LOCALES_USER),
|
||||
confirm_label: Lc::t("field-confirm-password", &LOCALES_USER),
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut password = form::input::Field::password()
|
||||
.with_name("password")
|
||||
.with_label(self.password_label().clone())
|
||||
.with_autocomplete(Some(form::Autocomplete::new_password()))
|
||||
.with_required(true);
|
||||
let mut confirm = form::input::Field::password()
|
||||
.with_name("confirm_password")
|
||||
.with_label(self.confirm_label().clone())
|
||||
.with_autocomplete(Some(form::Autocomplete::new_password()))
|
||||
.with_required(true);
|
||||
|
||||
Ok(html! {
|
||||
(password.render(cx).await)
|
||||
(confirm.render(cx).await)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordConfirm {
|
||||
// **< PasswordConfirm BUILDER >********************************************************************
|
||||
|
||||
/// Establece la etiqueta del campo de contraseña (por defecto, "field-password").
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_password_label(mut self, label: Lc) -> Self {
|
||||
self.password_label = label;
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece la etiqueta del campo de confirmación (por defecto, "field-confirm-password").
|
||||
#[builder_fn]
|
||||
pub(crate) fn with_confirm_label(mut self, label: Lc) -> Self {
|
||||
self.confirm_label = label;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
//! Componente de formulario para introducir la nueva contraseña.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
use crate::component::error_banner;
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct PasswordResetConfirmForm {
|
||||
error: Option<Lc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for PasswordResetConfirmForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
// Sin `with_action()`: se envía a la propia URL, que incluye el `{uid}/{token}` de la
|
||||
// petición actual.
|
||||
let mut form = Form::new()
|
||||
.with_id("user-new-password-form")
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error.clone()))
|
||||
.with_child(
|
||||
form::input::Field::password()
|
||||
.with_name("password")
|
||||
.with_label(Lc::t("field-new-password", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::new_password()))
|
||||
.with_autofocus(true)
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::password()
|
||||
.with_name("confirm_password")
|
||||
.with_label(Lc::t("field-confirm-password", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::new_password()))
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(Button::submit(Lc::t("btn-set-password", &LOCALES_USER)));
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordResetConfirmForm {
|
||||
#[builder_fn]
|
||||
pub fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
58
extensions/pagetop-user/src/component/password_reset_form.rs
Normal file
58
extensions/pagetop-user/src/component/password_reset_form.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Componente de formulario de solicitud de restablecimiento de contraseña.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::component::error_banner;
|
||||
use crate::{LOCALES_USER, LOGIN_PATH, PASSWORD_RESET_PATH};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct PasswordResetForm {
|
||||
error: Option<Lc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for PasswordResetForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut form = Form::new()
|
||||
.with_id("user-password-reset-form")
|
||||
.with_action(PASSWORD_RESET_PATH)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error.clone()))
|
||||
.with_child(
|
||||
form::input::Field::email()
|
||||
.with_name("email")
|
||||
.with_label(Lc::t("field-email", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::email()))
|
||||
.with_autofocus(true)
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(Button::submit(Lc::t("btn-send-reset-link", &LOCALES_USER)))
|
||||
.with_child(back_to_login());
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
fn back_to_login() -> Html {
|
||||
Html::with(|cx| {
|
||||
html! {
|
||||
p {
|
||||
a href=(cx.route(LOGIN_PATH)) {
|
||||
(Lc::t("link-back-to-login", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl PasswordResetForm {
|
||||
#[builder_fn]
|
||||
pub fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
54
extensions/pagetop-user/src/component/register_form.rs
Normal file
54
extensions/pagetop-user/src/component/register_form.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Componente de formulario de registro.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::LOCALES_USER;
|
||||
use crate::REGISTER_PATH;
|
||||
use crate::component::{PasswordConfirm, error_banner};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct RegisterForm {
|
||||
error: Option<Lc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for RegisterForm {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let mut form = Form::new()
|
||||
.with_id("user-register-form")
|
||||
.with_action(REGISTER_PATH)
|
||||
.with_method(form::Method::Post)
|
||||
.with_child(error_banner(self.error.clone()))
|
||||
.with_child(
|
||||
form::input::Field::text()
|
||||
.with_name("username")
|
||||
.with_label(Lc::t("field-username", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::username()))
|
||||
.with_autofocus(true)
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(
|
||||
form::input::Field::email()
|
||||
.with_name("email")
|
||||
.with_label(Lc::t("field-email", &LOCALES_USER))
|
||||
.with_autocomplete(Some(form::Autocomplete::email()))
|
||||
.with_required(true),
|
||||
)
|
||||
.with_child(PasswordConfirm::new())
|
||||
.with_child(Button::submit(Lc::t("btn-register", &LOCALES_USER)));
|
||||
|
||||
Ok(form.render(cx).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl RegisterForm {
|
||||
#[builder_fn]
|
||||
pub fn with_error(mut self, error: impl Into<Option<Lc>>) -> Self {
|
||||
self.error = error.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
48
extensions/pagetop-user/src/component/user_block.rs
Normal file
48
extensions/pagetop-user/src/component/user_block.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
//! Componente de bloque de usuario (login/logout en la cabecera).
|
||||
|
||||
use pagetop::prelude::*;
|
||||
|
||||
use crate::config::SETTINGS;
|
||||
use crate::{LOCALES_USER, LOGIN_PATH, LOGOUT_PATH, REGISTER_PATH};
|
||||
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub struct UserBlock;
|
||||
|
||||
#[async_trait]
|
||||
impl Component for UserBlock {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let user = cx.current_user();
|
||||
Ok(if user.is_authenticated() {
|
||||
let display = user.display_name().unwrap_or("?");
|
||||
html! {
|
||||
nav.user-block {
|
||||
span.user-name { (display) }
|
||||
" · "
|
||||
form.user-logout-inline method="post" action=(cx.route(LOGOUT_PATH)) {
|
||||
button type="submit" {
|
||||
(Lc::t("btn-logout", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
nav.user-block {
|
||||
a href=(cx.route(LOGIN_PATH)) {
|
||||
(Lc::t("btn-login", &LOCALES_USER).using(cx))
|
||||
}
|
||||
@if SETTINGS.allow_registration {
|
||||
" · "
|
||||
a href=(cx.route(REGISTER_PATH)) {
|
||||
(Lc::t("link-register", &LOCALES_USER).using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue