[pdm-devel] [PATCH yew-comp 2/3] acl: add a view and semi-generic `EditWindow` for acl entries
Shannon Sterz
s.sterz at proxmox.com
Thu Apr 3 16:18:03 CEST 2025
since each product will always have different acl paths, editing them
cannot be made completelly generic. however, this does try to make
displaying them generic based on the common api implementations in
`proxmox-access-control`.
furthermore, a the `AclEditWindow` trait makes it possible for each
product to adapt the editing panels and the number of them that are
displayed in the ACL View configurable. allowing for greater
flexibility while trying to reduce duplicate code accross multiple
products.
Signed-off-by: Shannon Sterz <s.sterz at proxmox.com>
---
src/acl/acl_edit.rs | 92 +++++++++++++++
src/acl/acl_view.rs | 270 ++++++++++++++++++++++++++++++++++++++++++++
src/acl/mod.rs | 5 +
src/lib.rs | 3 +
4 files changed, 370 insertions(+)
create mode 100644 src/acl/acl_edit.rs
create mode 100644 src/acl/acl_view.rs
create mode 100644 src/acl/mod.rs
diff --git a/src/acl/acl_edit.rs b/src/acl/acl_edit.rs
new file mode 100644
index 0000000..b8bbd89
--- /dev/null
+++ b/src/acl/acl_edit.rs
@@ -0,0 +1,92 @@
+use yew::html::IntoPropValue;
+
+use pwt::prelude::*;
+use pwt::widget::form::{Checkbox, FormContext};
+use pwt::widget::{FieldLabel, InputPanel};
+
+use pwt_macros::builder;
+
+use crate::EditWindow;
+use crate::{AuthidSelector, RoleSelector};
+
+pub trait AclEditWindow: Into<EditWindow> {}
+
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct AclEdit {
+ /// Use API Tokens instead of Users.
+ #[prop_or_default]
+ #[builder]
+ use_tokens: bool,
+
+ /// The endpoint which will be used to create new ACL entries via a PUT request.
+ #[prop_or(String::from("/access/acl"))]
+ #[builder(IntoPropValue, into_prop_value)]
+ acl_api_endpoint: String,
+
+ #[prop_or_default]
+ input_panel: InputPanel,
+}
+
+impl AclEdit {
+ /// Create a new `AclEdit` that takes as input a field and its label which are used to select
+ /// the ACL path for a new ACL entry.
+ pub fn new(
+ path_selector_label: impl Into<FieldLabel>,
+ path_selector: impl FieldBuilder,
+ ) -> Self {
+ let path_selector = path_selector.name("path").required(true);
+ let input_panel = InputPanel::new().with_field(path_selector_label, path_selector);
+ yew::props!(Self { input_panel })
+ }
+}
+
+impl From<AclEdit> for EditWindow {
+ fn from(value: AclEdit) -> Self {
+ let field = AuthidSelector::new().name("auth-id").required(true);
+
+ let (title, authid_label, authid_field) = if value.use_tokens {
+ (
+ tr!("API Token Permission"),
+ tr!("API Token"),
+ field.include_users(false),
+ )
+ } else {
+ (
+ tr!("User Permission"),
+ tr!("User"),
+ field.include_tokens(false),
+ )
+ };
+
+ let input_panel = value
+ .input_panel
+ .clone()
+ .padding(4)
+ .with_field(authid_label, authid_field)
+ .with_field(tr!("Role"), RoleSelector::new().name("role").required(true))
+ .with_field(
+ tr!("Propagate"),
+ Checkbox::new().name("propagate").required(true),
+ );
+
+ let url = value.acl_api_endpoint.to_owned();
+
+ let on_submit = {
+ let url = url.clone();
+ move |form_ctx: FormContext| {
+ let url = url.clone();
+ async move {
+ let data = form_ctx.get_submit_data();
+ crate::http_put(url.as_str(), Some(data)).await
+ }
+ }
+ };
+
+ EditWindow::new(title)
+ .renderer(move |_form_ctx: &FormContext| input_panel.clone().into())
+ .on_submit(on_submit)
+ }
+}
+
+impl AclEditWindow for AclEdit {}
diff --git a/src/acl/acl_view.rs b/src/acl/acl_view.rs
new file mode 100644
index 0000000..58da3fd
--- /dev/null
+++ b/src/acl/acl_view.rs
@@ -0,0 +1,270 @@
+use std::borrow::BorrowMut;
+use std::future::Future;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use anyhow::Error;
+use indexmap::IndexMap;
+use serde_json::json;
+
+use yew::html::IntoPropValue;
+use yew::virtual_dom::{Key, VComp, VNode};
+
+use pwt::css;
+use pwt::prelude::*;
+use pwt::state::{Selection, Store};
+use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
+use pwt::widget::menu::{Menu, MenuButton, MenuItem};
+use pwt::widget::Toolbar;
+
+use pwt_macros::builder;
+
+use proxmox_access_control::types::{AclListItem, AclUgidType};
+
+use crate::percent_encoding::percent_encode_component;
+use crate::utils::render_boolean;
+use crate::{
+ ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+};
+
+use super::acl_edit::AclEditWindow;
+
+#[derive(PartialEq, Properties)]
+#[builder]
+pub struct AclView {
+ /// Show the ACL entries for the specified API path and sub-paths only.
+ #[builder(IntoPropValue, into_prop_value)]
+ #[prop_or_default]
+ acl_path: Option<AttrValue>,
+
+ /// Specifies the endpoint from which to fetch the ACL entries from via GET and to update them
+ /// via PUT requests.
+ #[builder(IntoPropValue, into_prop_value)]
+ #[prop_or(String::from("/access/acl"))]
+ acl_api_endpoint: String,
+
+ /// Menu entries for editing the ACL. The key is used as the menu label while the value should
+ /// be a tuple containing icon class and the dialog for editing ACL entries.
+ #[prop_or_default]
+ // using an index map here preserves the insertion order
+ edit_acl_menu: IndexMap<AttrValue, (Classes, EditWindow)>,
+}
+
+impl AclView {
+ pub fn new() -> Self {
+ yew::props!(Self {})
+ }
+
+ pub fn with_acl_edit_menu_entry(
+ mut self,
+ lable: impl Into<AttrValue>,
+ icon: impl Into<Classes>,
+ dialog: impl AclEditWindow,
+ ) -> Self {
+ self.add_acl_edit_menu_entry(lable, icon, dialog);
+ self
+ }
+
+ pub fn add_acl_edit_menu_entry(
+ &mut self,
+ lable: impl Into<AttrValue>,
+ icon: impl Into<Classes>,
+ dialog: impl AclEditWindow,
+ ) {
+ self.edit_acl_menu
+ .borrow_mut()
+ .insert(lable.into(), (icon.into(), dialog.into()));
+ }
+}
+
+impl Default for AclView {
+ fn default() -> Self {
+ AclView::new()
+ }
+}
+
+impl From<AclView> for VNode {
+ fn from(value: AclView) -> Self {
+ VComp::new::<LoadableComponentMaster<ProxmoxAclView>>(Rc::new(value), None).into()
+ }
+}
+
+#[derive(Clone, PartialEq)]
+enum ViewState {
+ AddAcl(AttrValue),
+}
+
+enum Msg {
+ Reload,
+ Remove,
+}
+
+struct ProxmoxAclView {
+ selection: Selection,
+ store: Store<AclListItem>,
+}
+
+impl ProxmoxAclView {
+ fn colmuns() -> Rc<Vec<DataTableHeader<AclListItem>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Path"))
+ .flex(1)
+ .render(|item: &AclListItem| item.path.as_str().into())
+ .sorter(|a: &AclListItem, b: &AclListItem| a.path.cmp(&b.path))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("User/Group/API Token"))
+ .flex(1)
+ .render(|item: &AclListItem| item.ugid.as_str().into())
+ .sorter(|a: &AclListItem, b: &AclListItem| a.ugid.cmp(&b.ugid))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Role"))
+ .flex(1)
+ .render(|item: &AclListItem| item.roleid.as_str().into())
+ .sorter(|a: &AclListItem, b: &AclListItem| a.roleid.cmp(&b.roleid))
+ .into(),
+ DataTableColumn::new(tr!("Propagate"))
+ .render(|item: &AclListItem| render_boolean(item.propagate).as_str().into())
+ .sorter(|a: &AclListItem, b: &AclListItem| a.propagate.cmp(&b.propagate))
+ .into(),
+ ])
+ }
+}
+
+impl LoadableComponent for ProxmoxAclView {
+ type Properties = AclView;
+ type Message = Msg;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let link = ctx.link();
+ link.repeated_load(5000);
+
+ let selection = Selection::new().on_select(link.callback(|_| Msg::Reload));
+
+ let store = Store::with_extract_key(|record: &AclListItem| {
+ let acl_id = format!("{} for {} - {}", record.path, record.ugid, record.roleid);
+ Key::from(acl_id)
+ });
+
+ Self { selection, store }
+ }
+
+ fn load(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ let store = self.store.clone();
+ let url = &ctx.props().acl_api_endpoint;
+
+ let path = if let Some(path) = &ctx.props().acl_path {
+ format!("{url}&path={}", percent_encode_component(path))
+ } else {
+ url.to_owned()
+ };
+
+ Box::pin(async move {
+ let data = crate::http_get(&path, None).await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ let selected_id = self.selection.selected_key().map(|k| k.to_string());
+ let disabled = selected_id.is_none();
+
+ let mut toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class("pwt-overflow-hidden")
+ .border_bottom(true);
+
+ if !ctx.props().edit_acl_menu.is_empty() {
+ let add_menu = ctx.props().edit_acl_menu.iter().fold(
+ Menu::new(),
+ |add_menu, (label, (icon, _))| {
+ let msg = label.to_owned();
+
+ add_menu.with_item(
+ MenuItem::new(label.to_owned())
+ .icon_class(icon.to_owned())
+ .on_select(ctx.link().change_view_callback(move |_| {
+ Some(ViewState::AddAcl(msg.clone()))
+ })),
+ )
+ },
+ );
+
+ toolbar.add_child(MenuButton::new(tr!("Add")).show_arrow(true).menu(add_menu));
+ }
+
+ toolbar.add_child(
+ ConfirmButton::new(tr!("Remove ACL Entry"))
+ .confirm_message(tr!("Are you sure you want to remove this ACL entry?"))
+ .disabled(disabled)
+ .on_activate(ctx.link().callback(|_| Msg::Remove)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::Reload => true,
+ Msg::Remove => {
+ if let Some(key) = self.selection.selected_key() {
+ if let Some(record) = self.store.read().lookup_record(&key).cloned() {
+ let link = ctx.link();
+ let url = ctx.props().acl_api_endpoint.to_owned();
+
+ link.clone().spawn(async move {
+ let data = match record.ugid_type {
+ AclUgidType::User => json!({
+ "delete": true,
+ "path": record.path,
+ "role": record.roleid,
+ "auth-id": record.ugid,
+ }),
+ AclUgidType::Group => json!({
+ "delete": true,
+ "path": record.path,
+ "role": record.roleid,
+ "group": record.ugid,
+ }),
+ };
+
+ match crate::http_put(url, Some(data)).await {
+ Ok(()) => link.send_reload(),
+ Err(err) => link.show_error("Removing ACL failed", err, true),
+ }
+ });
+ }
+ }
+ false
+ }
+ }
+ }
+
+ fn main_view(&self, _ctx: &LoadableComponentContext<Self>) -> Html {
+ DataTable::new(Self::colmuns(), self.store.clone())
+ .class(css::FlexFit)
+ .selection(self.selection.clone())
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ match view_state {
+ ViewState::AddAcl(key) => ctx.props().edit_acl_menu.get(key).map(|(_, dialog)| {
+ dialog
+ .clone()
+ .on_done(ctx.link().change_view_callback(|_| None))
+ .into()
+ }),
+ }
+ }
+}
diff --git a/src/acl/mod.rs b/src/acl/mod.rs
new file mode 100644
index 0000000..cfe6aa6
--- /dev/null
+++ b/src/acl/mod.rs
@@ -0,0 +1,5 @@
+pub(crate) mod acl_edit;
+pub use acl_edit::AclEdit;
+
+pub(crate) mod acl_view;
+pub use acl_view::AclView;
diff --git a/src/lib.rs b/src/lib.rs
index 091cb72..3dacc20 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -25,6 +25,9 @@ pub use auth_edit_ldap::{AuthEditLDAP, ProxmoxAuthEditLDAP};
mod authid_selector;
pub use authid_selector::AuthidSelector;
+mod acl;
+pub use acl::{AclEdit, AclView};
+
mod bandwidth_selector;
pub use bandwidth_selector::{BandwidthSelector, ProxmoxBandwidthSelector};
--
2.39.5
More information about the pdm-devel
mailing list