From: Shannon Sterz <s.sterz@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel
Date: Wed, 24 Sep 2025 16:51:34 +0200 [thread overview]
Message-ID: <20250924145137.407070-6-s.sterz@proxmox.com> (raw)
In-Reply-To: <20250924145137.407070-1-s.sterz@proxmox.com>
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 <s.sterz@proxmox.com>
---
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<Vec<ApiToken>, Error> {
+ let url = "/access/users/?include_tokens=1";
+ let users: Vec<UserWithTokens> = crate::http_get(url, None).await?;
+ let mut list: Vec<ApiToken> = Vec::new();
+
+ for user in users.into_iter() {
+ list.extend(user.tokens)
+ }
+
+ Ok(list)
+}
+
+async fn create_token(
+ form_ctx: FormContext,
+ link: LoadableComponentLink<ProxmoxTokenView>,
+) -> 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<ApiResponseData<Value>, 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<Value> = 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<ApiToken>,
+ secret_node_ref: NodeRef,
+ columns: Rc<Vec<DataTableHeader<ApiToken>>>,
+}
+
+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>) -> 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<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), Error>>>> {
+ 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<Self>) -> Option<Html> {
+ 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<Self>, 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<Self>) -> 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<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<Html> {
+ 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<Self>,
+ 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<Self>, 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<Self>) -> 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<Self>, 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<Vec<DataTableHeader<ApiToken>>> {
+ 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<TokenPanel> for VNode {
+ fn from(value: TokenPanel) -> Self {
+ VComp::new::<LoadableComponentMaster<ProxmoxTokenView>>(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
next prev parent reply other threads:[~2025-09-24 14:51 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-09-24 14:51 [pdm-devel] [RFC datacenter-manager/proxmox/yew-comp 0/8] token support for pdm Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 1/3] access-control: refactor api module to be more hirachical Shannon Sterz
2025-09-26 8:26 ` Dominik Csapak
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 2/3] access-control: move `ApiTokenSecret` to types module Shannon Sterz
2025-09-26 9:14 ` Fabian Grünbichler
2025-09-24 14:51 ` [pdm-devel] [PATCH proxmox 3/3] access-control: add api endpoints for handling tokens Shannon Sterz
2025-09-26 9:14 ` Fabian Grünbichler
2025-09-24 14:51 ` [pdm-devel] [PATCH yew-comp 1/2] utils/user_panel: factor out epoch_to_input_value helper Shannon Sterz
2025-09-24 14:51 ` Shannon Sterz [this message]
2025-09-26 8:50 ` [pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel Dominik Csapak
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 1/3] ui: add a token panel and a token acl edit menu in the permissions panel Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 2/3] server: access: use token endpoints from proxmox-access-control Shannon Sterz
2025-09-24 14:51 ` [pdm-devel] [PATCH datacenter-manager 3/3] server: clean up acl tree entries and api tokens when deleting users Shannon Sterz
2025-09-26 9:18 ` Fabian Grünbichler
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20250924145137.407070-6-s.sterz@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.