From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 74D6F1FF17E for ; Thu, 2 Oct 2025 17:19:33 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 2D39D1403A; Thu, 2 Oct 2025 17:19:42 +0200 (CEST) Mime-Version: 1.0 Date: Thu, 02 Oct 2025 17:19:36 +0200 Message-Id: To: "Dominik Csapak" , "Proxmox Datacenter Manager development discussion" X-Mailer: aerc 0.20.0 References: <20250924145137.407070-1-s.sterz@proxmox.com> <20250924145137.407070-6-s.sterz@proxmox.com> <5d896cd1-2e37-463b-aafd-a9eb833fd254@proxmox.com> In-Reply-To: <5d896cd1-2e37-463b-aafd-a9eb833fd254@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1759418352671 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.057 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 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, lib.rs, proxmox.com] 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-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" On Fri Sep 26, 2025 at 10:50 AM CEST, Dominik Csapak wrote: > 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) imo this looks good with the warning container spanning the whole dialog. moving this container to the input panel means the padding of the input panel now surrounds this container and it for some reason only takes up half the dialogs width. so i'd stick to this as imo this looks good? > >> + .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