[pdm-devel] [PATCH yew-comp 2/2] token_panel: implement a token panel

Dominik Csapak d.csapak at proxmox.com
Fri Sep 26 10:50:16 CEST 2025


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 <s.sterz at 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)

could also be written as

Ok(users.into_iter().map(|user| user.tokens))

> +}
> +
> +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()),

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(&copy_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 at lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
> 
> 





More information about the pdm-devel mailing list