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 C39361FF183 for ; Wed, 24 Sep 2025 16:51:45 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 277A9B5F2; Wed, 24 Sep 2025 16:52:17 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Date: Wed, 24 Sep 2025 16:51:34 +0200 Message-ID: <20250924145137.407070-6-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20250924145137.407070-1-s.sterz@proxmox.com> References: <20250924145137.407070-1-s.sterz@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1758725485501 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 Subject: [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" 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) +} + +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()), + ) + .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" + )), + ) + .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