From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 67E6A1FF16B for ; Fri, 26 Sep 2025 10:49:50 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2F01EB41D; Fri, 26 Sep 2025 10:50:23 +0200 (CEST) Message-ID: <5d896cd1-2e37-463b-aafd-a9eb833fd254@proxmox.com> Date: Fri, 26 Sep 2025 10:50:16 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta To: Proxmox Datacenter Manager development discussion , Shannon Sterz References: <20250924145137.407070-1-s.sterz@proxmox.com> <20250924145137.407070-6-s.sterz@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20250924145137.407070-6-s.sterz@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1758876601684 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.026 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [resp.data, proxmox.com, lib.rs] Subject: Re: [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Transfer-Encoding: 7bit Content-Type: text/plain; charset="us-ascii"; Format="flowed" Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" a few nitpicks (inline) but nothing major, and no blocker from my side On 9/24/25 4:52 PM, Shannon Sterz wrote: > this is analogous to the user panel. the token panel allows adding, > editing and remove api tokens. existing tokens can also be > re-generated and their permissions can be displayed. > > Signed-off-by: Shannon Sterz > --- > note that this could probably use several refinments: > > - this could use the Clipboard api once an appropriate web_sys version is > packaged instead of NodeRef > - use a base_url instead of hardcoding everything. > > but i wanted some early feedback for now > > src/lib.rs | 3 + > src/token_panel.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++ > 2 files changed, 572 insertions(+) > create mode 100644 src/token_panel.rs > > diff --git a/src/lib.rs b/src/lib.rs > index 492326a..b6fcd81 100644 > --- a/src/lib.rs > +++ b/src/lib.rs > @@ -203,6 +203,9 @@ pub use wizard::{PwtWizard, Wizard, WizardPageRenderInfo}; > mod user_panel; > pub use user_panel::UserPanel; > > +mod token_panel; > +pub use token_panel::TokenPanel; > + > pub mod utils; > > mod xtermjs; > diff --git a/src/token_panel.rs b/src/token_panel.rs > new file mode 100644 > index 0000000..26e3575 > --- /dev/null > +++ b/src/token_panel.rs > @@ -0,0 +1,569 @@ > +use std::future::Future; > +use std::pin::Pin; > +use std::rc::Rc; > + > +use anyhow::Error; > +use proxmox_access_control::types::{ApiToken, UserWithTokens}; > +use proxmox_auth_api::types::Authid; > +use proxmox_client::ApiResponseData; > +use serde_json::{json, Value}; > + > +use yew::virtual_dom::{Key, VComp, VNode}; > + > +use pwt::prelude::*; > +use pwt::state::{Selection, Store}; > +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; > +use pwt::widget::form::{Checkbox, DisplayField, Field, FormContext, InputType}; > +use pwt::widget::{Button, Column, Container, Dialog, InputPanel, Toolbar}; > + > +use crate::percent_encoding::percent_encode_component; > +use crate::utils::{copy_to_clipboard, epoch_to_input_value, render_boolean, render_epoch_short}; > +use crate::{ > + AuthidSelector, ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext, > + LoadableComponentLink, LoadableComponentMaster, PermissionPanel, > +}; > + > +async fn load_api_tokens() -> Result, Error> { > + let url = "/access/users/?include_tokens=1"; > + let users: Vec = crate::http_get(url, None).await?; > + let mut list: Vec = Vec::new(); > + > + for user in users.into_iter() { > + list.extend(user.tokens) > + } > + > + Ok(list) could also be written as Ok(users.into_iter().map(|user| user.tokens)) > +} > + > +async fn create_token( > + form_ctx: FormContext, > + link: LoadableComponentLink, > +) -> Result<(), Error> { > + let mut data = form_ctx.get_submit_data(); > + > + let userid = form_ctx.read().get_field_text("userid"); > + let tokenname = form_ctx.read().get_field_text("tokenname"); > + > + let url = token_api_url(&userid, &tokenname); > + > + let expire = form_ctx.read().get_field_text("expire"); > + > + if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) { > + data["expire"] = epoch.into(); > + } > + > + let res: Value = crate::http_post(url, Some(data)).await?; > + > + link.change_view(Some(ViewState::DisplayTokenSecret(res))); > + > + Ok(()) > +} > + > +async fn load_token(tokenid: Key) -> Result, Error> { > + let tokenid: Authid = tokenid.parse().unwrap(); > + > + let userid = tokenid.user().to_string(); > + let tokenname = tokenid.tokenname().map(|n| n.as_str().to_owned()).unwrap(); > + > + let url = token_api_url(&userid, &tokenname); > + > + let mut resp: ApiResponseData = crate::http_get_full(&url, None).await?; > + > + if let Value::Number(number) = &resp.data["expire"] { > + if let Some(epoch) = number.as_f64() { > + resp.data["expire"] = Value::String(epoch_to_input_value(epoch as i64)); > + } > + } > + resp.data["userid"] = userid.into(); > + resp.data["tokenname"] = tokenname.into(); > + > + Ok(resp) > +} > + > +async fn update_token(form_ctx: FormContext) -> Result<(), Error> { > + let mut data = form_ctx.get_submit_data(); > + > + let userid = form_ctx.read().get_field_text("userid"); > + let tokenname = form_ctx.read().get_field_text("tokenname"); > + > + let url = token_api_url(&userid, &tokenname); > + > + let expire = form_ctx.read().get_field_text("expire"); > + if let Ok(epoch) = proxmox_time::parse_rfc3339(&expire) { > + data["expire"] = epoch.into(); > + } else { > + data["expire"] = 0.into(); > + } > + > + crate::http_put(url, Some(data)).await > +} > + > +#[derive(PartialEq, Properties)] > +pub struct TokenPanel {} > + > +impl TokenPanel { > + pub fn new() -> Self { > + yew::props!(Self {}) > + } > +} > + > +impl Default for TokenPanel { > + fn default() -> Self { > + Self::new() > + } > +} > + > +#[derive(Clone, PartialEq)] > +enum ViewState { > + AddToken, > + EditToken, > + ShowPermissions, > + DisplayTokenSecret(Value), > +} > + > +enum Msg { > + Refresh, > + Remove, > + Regenerate, > +} > + > +struct ProxmoxTokenView { > + selection: Selection, > + store: Store, > + secret_node_ref: NodeRef, > + columns: Rc>>, > +} > + > +fn token_api_url(user: &str, tokenname: &str) -> String { > + format!( > + "/access/users/{}/token/{}", > + percent_encode_component(user), > + percent_encode_component(tokenname), > + ) > +} > + > +impl LoadableComponent for ProxmoxTokenView { > + type Properties = TokenPanel; > + type Message = Msg; > + type ViewState = ViewState; > + > + fn create(ctx: &LoadableComponentContext) -> Self { > + let link = ctx.link(); > + link.repeated_load(5000); > + > + let selection = Selection::new().on_select(link.callback(|_| Msg::Refresh)); > + let store = > + Store::with_extract_key(|record: &ApiToken| Key::from(record.tokenid.to_string())); > + > + Self { > + selection, > + store, > + secret_node_ref: NodeRef::default(), > + columns: columns(), > + } > + } > + > + fn load( > + &self, > + _ctx: &LoadableComponentContext, > + ) -> Pin>>> { > + let store = self.store.clone(); > + Box::pin(async move { > + let data = load_api_tokens().await?; > + store.write().set_data(data); > + Ok(()) > + }) > + } > + > + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { > + let selected_id = self.selection.selected_key().map(|k| k.to_string()); > + let disabled = selected_id.is_none(); > + let link = ctx.link(); > + > + let toolbar = Toolbar::new() > + .class("pwt-w-100") > + .class("pwt-overflow-hidden") > + .class("pwt-border-bottom") > + .border_top(true) > + .with_child( > + Button::new(tr!("Add")) > + .on_activate(link.change_view_callback(|_| Some(ViewState::AddToken))), > + ) > + .with_spacer() > + .with_child( > + Button::new(tr!("Edit")) > + .disabled(disabled) > + .on_activate(link.change_view_callback(|_| Some(ViewState::EditToken))), > + ) > + .with_child( > + Button::new(tr!("Remove")) > + .disabled(disabled) > + .on_activate(link.callback(|_| Msg::Remove)), > + ) > + .with_spacer() > + .with_child( > + ConfirmButton::new(tr!("Regenerate Secret")) > + .confirm_message(tr!(" > + Regenerate the secret of the selected API token? All current use-sites will loose access!" > + )) > + .disabled(disabled) > + .on_activate(link.callback(|_| Msg::Regenerate)) > + ) > + .with_spacer() > + .with_child( > + Button::new(tr!("Show Permissions")) > + .disabled(disabled) > + .on_activate(link.change_view_callback(|_| Some(ViewState::ShowPermissions))), > + ); > + > + Some(toolbar.into()) > + } > + > + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { > + match msg { > + Msg::Refresh => true, > + Msg::Remove => { > + let record = match self.selection.selected_key() { > + Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(), > + None => None, > + }; > + if let Some(record) = record { > + let user = record.tokenid.user().to_string(); > + let tokenname = match record.tokenid.tokenname() { > + Some(name) => name.as_str().to_owned(), > + None => { > + log::error!( > + "ApiToken '{}' has no name - internal error", > + record.tokenid > + ); > + return true; > + } > + }; > + > + let url = token_api_url(&user, &tokenname); > + let link = ctx.link(); > + link.clone().spawn(async move { > + match crate::http_delete(url, None).await { > + Ok(()) => { > + link.send_reload(); > + } > + Err(err) => { > + link.show_error("Removing API token failed", err, true); > + } > + } > + }); > + } > + false > + } > + Msg::Regenerate => { > + let record = match self.selection.selected_key() { > + Some(selected_key) => self.store.read().lookup_record(&selected_key).cloned(), > + None => None, > + }; > + if let Some(record) = record { > + let user = record.tokenid.user().to_string(); > + let tokenname = match record.tokenid.tokenname() { > + Some(name) => name.as_str().to_owned(), > + None => { > + log::error!( > + "ApiToken '{}' has no name - internal error", > + record.tokenid > + ); > + return true; > + } > + }; > + > + let url = token_api_url(&user, &tokenname); > + let link = ctx.link().clone(); > + ctx.link().spawn(async move { > + match crate::http_put(url, Some(json!({"regenerate": true}))).await { > + Ok(secret) => { > + link.change_view(Some(ViewState::DisplayTokenSecret(secret))); > + } > + Err(err) => { > + link.show_error("Regenerating API token failed", err, true); > + } > + } > + }); > + } > + false > + } > + } > + } > + > + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { > + let link = ctx.link(); > + > + DataTable::new(self.columns.clone(), self.store.clone()) > + .class("pwt-flex-fit") > + .selection(self.selection.clone()) > + .on_row_dblclick(move |_: &mut _| { > + link.change_view(Some(ViewState::EditToken)); > + }) > + .into() > + } > + > + fn dialog_view( > + &self, > + ctx: &LoadableComponentContext, > + view_state: &Self::ViewState, > + ) -> Option { > + match view_state { > + ViewState::AddToken => Some(self.create_add_dialog(ctx)), > + ViewState::EditToken => self > + .selection > + .selected_key() > + .map(|key| self.create_edit_dialog(ctx, key)), > + ViewState::ShowPermissions => self > + .selection > + .selected_key() > + .map(|key| self.create_show_permissions_dialog(ctx, key)), > + ViewState::DisplayTokenSecret(secret) => Some(self.show_secret_dialog(ctx, secret)), > + } > + } > +} > + > +impl ProxmoxTokenView { > + fn create_show_permissions_dialog( > + &self, > + ctx: &LoadableComponentContext, > + key: Key, > + ) -> Html { > + Dialog::new(key.to_string() + " - " + &tr!("Granted Permissions")) > + .resizable(true) > + .width(840) > + .height(600) > + .with_child(PermissionPanel::new().auth_id(key.to_string())) > + .on_close(ctx.link().change_view_callback(|_| None)) > + .into() > + } > + > + fn show_secret_dialog(&self, ctx: &LoadableComponentContext, secret: &Value) -> Html { > + let secret = secret.clone(); > + > + Dialog::new(tr!("Token Secret")) > + .with_child( > + Column::new() > + .with_child( > + InputPanel::new() > + .padding(4) > + .with_large_field( > + tr!("Token ID"), > + DisplayField::new() > + .value(AttrValue::from( > + secret["tokenid"].as_str().unwrap_or("").to_owned(), > + )) > + .border(true), > + ) > + .with_large_field( > + tr!("Secret"), > + DisplayField::new() > + .value(AttrValue::from( > + secret["value"].as_str().unwrap_or("").to_owned(), > + )) > + .border(true), > + ), > + ) > + .with_child( > + Container::new() > + .style("opacity", "0") > + .with_child(AttrValue::from( > + secret["value"].as_str().unwrap_or("").to_owned(), > + )) > + .into_html_with_ref(self.secret_node_ref.clone()), does this actually work as a copy input? AFAICS: copy_to_clipboard wants to cast the noderef to a HtmlInputElement, and i don't think a container qualifies for that? couldn't we simply show the secret in a disabled textfield and use that for copying? then we don't have to add some extra 'hidden' container with the secret? > + ) > + .with_child( > + Container::new() > + .padding(4) > + .class(pwt::css::FlexFit) > + .class("pwt-bg-color-warning-container") > + .class("pwt-color-on-warning-container") > + .with_child(tr!( > + "Please record the API token secret - it will only be displayed now" > + )), > + ) i did not actually look at the result, but wouldn't it align more nicely when adding these fields into the inputpanel? (with_custom_child) > + .with_child( > + Toolbar::new() > + .class("pwt-bg-color-surface") > + .with_flex_spacer() > + .with_child( > + Button::new(tr!("Copy Secret Value")) > + .icon_class("fa fa-clipboard") > + .class("pwt-scheme-primary") > + .on_activate({ > + let copy_ref = self.secret_node_ref.clone(); > + move |_| copy_to_clipboard(©_ref) > + }), > + ), > + ), > + ) > + .on_close(ctx.link().change_view_callback(|_| None)) > + .into() > + } > + > + fn create_add_dialog(&self, ctx: &LoadableComponentContext) -> Html { > + let link = ctx.link().clone(); > + EditWindow::new(tr!("Add") + ": " + &tr!("Token")) > + .renderer(add_input_panel) > + .on_submit(move |form_ctx| { > + let link = link.clone(); > + create_token(form_ctx, link) > + }) > + .on_close(ctx.link().change_view_callback(|_| None)) > + .into() > + } > + > + fn create_edit_dialog(&self, ctx: &LoadableComponentContext, key: Key) -> Html { > + EditWindow::new(tr!("Edit") + ": " + &tr!("Token")) > + .renderer(edit_input_panel) > + .on_submit(update_token) > + .on_done(ctx.link().change_view_callback(|_| None)) > + .loader(move || load_token(key.clone())) > + .into() > + } > +} > + > +fn edit_input_panel(_form_ctx: &FormContext) -> Html { > + InputPanel::new() > + .padding(4) > + .with_field( > + tr!("User"), > + Field::new() > + .name("userid") > + .required(true) > + .disabled(true) > + .submit(false), > + ) > + .with_right_field( > + tr!("Expire"), > + Field::new() > + .name("expire") > + .placeholder(tr!("never")) > + .input_type(InputType::DatetimeLocal), > + ) > + .with_field( > + tr!("Token Name"), > + Field::new() > + .name("tokenname") > + .submit(false) > + .disabled(true) > + .required(true), > + ) > + .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true)) > + .with_large_field( > + tr!("Comment"), > + Field::new().name("comment").submit_empty(true), > + ) > + .into() > +} > + > +fn add_input_panel(_form_ctx: &FormContext) -> Html { > + InputPanel::new() > + .padding(4) > + .with_field( > + tr!("User"), > + AuthidSelector::new() > + .name("userid") > + .required(true) > + .submit(false) > + .include_tokens(false), > + ) > + .with_right_field( > + tr!("Expire"), > + Field::new() > + .name("expire") > + .placeholder(tr!("never")) > + .input_type(InputType::DatetimeLocal), > + ) > + .with_field( > + tr!("Token Name"), > + Field::new().name("tokenname").submit(false).required(true), > + ) > + .with_right_field(tr!("Enabled"), Checkbox::new().name("enable").default(true)) > + .with_large_field(tr!("Comment"), Field::new().name("comment")) > + .into() > +} > + > +fn columns() -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("User")) > + .width("200px") > + .render(|item: &ApiToken| { > + html! {&item.tokenid.user()} > + }) > + .sorter(|a: &ApiToken, b: &ApiToken| a.tokenid.user().cmp(b.tokenid.user())) > + .sort_order(true) > + .into(), > + DataTableColumn::new(tr!("Token name")) > + .width("100px") > + .render(|item: &ApiToken| { > + let name = item > + .tokenid > + .tokenname() > + .map(|name| name.as_str()) > + .unwrap_or(""); > + html! {name} > + }) > + .sorter(|a: &ApiToken, b: &ApiToken| { > + let a = a > + .tokenid > + .tokenname() > + .map(|name| name.as_str()) > + .unwrap_or(""); > + let b = b > + .tokenid > + .tokenname() > + .map(|name| name.as_str()) > + .unwrap_or(""); > + a.cmp(b) > + }) > + .sort_order(true) > + .into(), > + DataTableColumn::new(tr!("Enable")) > + .width("80px") > + .render(|item: &ApiToken| { > + html! {render_boolean(item.enable.unwrap_or(true))} > + }) > + .sorter(|a: &ApiToken, b: &ApiToken| a.enable.cmp(&b.enable)) > + .into(), > + DataTableColumn::new(tr!("Expire")) > + .width("80px") > + .render({ > + let never_text = tr!("never"); > + move |item: &ApiToken| { > + html! { > + { > + match item.expire { > + Some(epoch) if epoch != 0 => render_epoch_short(epoch), > + _ => never_text.clone(), > + } > + } > + } > + } > + }) > + .sorter(|a: &ApiToken, b: &ApiToken| { > + let a = if let Some(0) = a.expire { > + None > + } else { > + a.expire > + }; > + let b = if let Some(0) = b.expire { > + None > + } else { > + b.expire > + }; > + a.cmp(&b) > + }) > + .into(), > + DataTableColumn::new("Comment") > + .flex(1) > + .render(|item: &ApiToken| item.comment.as_deref().unwrap_or_default().into()) > + .into(), > + ]) > +} > + > +impl From for VNode { > + fn from(value: TokenPanel) -> Self { > + VComp::new::>(Rc::new(value), None).into() > + } > +} > -- > 2.47.3 > > > > _______________________________________________ > pdm-devel mailing list > pdm-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel > > _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel