From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v4 23/40] ui: auto-installer: add access token configuration panel
Date: Thu, 30 Apr 2026 14:46:52 +0200 [thread overview]
Message-ID: <20260430124712.1614305-24-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com>
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v3 -> v4:
* use enum newtype struct for token_panel ViewState::DisplaySecret
* AnswerAuthToken -> AnswerToken rename
* move show_secret_dialog render to common module
* fix form dirty check in TokenSelector
* moved answer form token selection to correct patch
Changes v2 -> v3:
* new patch
ui/src/remotes/auto_installer/mod.rs | 17 +-
ui/src/remotes/auto_installer/token_panel.rs | 420 +++++++++++++++++++
2 files changed, 435 insertions(+), 2 deletions(-)
create mode 100644 ui/src/remotes/auto_installer/token_panel.rs
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index cd1f668..6926e59 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -6,6 +6,7 @@ mod prepared_answer_edit_window;
mod prepared_answer_form;
mod prepared_answers_panel;
mod token_selector;
+mod token_panel;
use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -51,15 +52,22 @@ impl Component for AutoInstallerPanelComponent {
.with_child(tr!("Prepared Answers"))
.into();
+ let secrets_title: Html = Row::new()
+ .gap(2)
+ .class(AlignItems::Baseline)
+ .with_child(Fa::new("key"))
+ .with_child(tr!("Authentication tokens"))
+ .into();
+
Container::new()
.class("pwt-content-spacer")
.class(Fit)
.class(css::Display::Grid)
.style("grid-template-columns", "repeat(2, 1fr)")
- .style("grid-template-rows", "repeat(1, 1fr)")
+ .style("grid-template-rows", "repeat(2, 1fr)")
.with_child(
Panel::new()
- .style("grid-row", "span 2 / span 1")
+ .style("grid-row", "span 2 / span 2")
.title(installations_title)
.with_child(installations_panel::InstallationsPanel::default()),
)
@@ -68,6 +76,11 @@ impl Component for AutoInstallerPanelComponent {
.title(answers_title)
.with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
)
+ .with_child(
+ Panel::new()
+ .title(secrets_title)
+ .with_child(token_panel::AuthTokenPanel::default()),
+ )
.into()
}
}
diff --git a/ui/src/remotes/auto_installer/token_panel.rs b/ui/src/remotes/auto_installer/token_panel.rs
new file mode 100644
index 0000000..972e3b1
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_panel.rs
@@ -0,0 +1,420 @@
+//! Implements the UI for the auto-installer authentication authentication token panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+ html,
+ virtual_dom::{Key, VComp, VNode},
+ Html, Properties,
+};
+
+use pdm_api_types::auto_installer::{
+ AnswerToken, AnswerTokenCreateResult, AnswerTokenUpdateResult, AnswerTokenUpdater,
+};
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component, utils::render_epoch_short, ConfirmButton,
+ EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+ LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+ props::{ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder},
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ form::{Checkbox, Field, FormContext, InputType},
+ Button, Fa, InputPanel, Toolbar,
+ },
+};
+
+use crate::{pdm_client, remotes::auto_installer::prepared_answer_form::render_show_secret_dialog};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AuthTokenPanel {}
+
+impl From<AuthTokenPanel> for VNode {
+ fn from(value: AuthTokenPanel) -> Self {
+ let comp =
+ VComp::new::<LoadableComponentMaster<AuthTokenPanelComponent>>(Rc::new(value), None);
+ VNode::from(comp)
+ }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+ Create,
+ Edit,
+ DisplaySecret { token: AnswerToken, secret: String },
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+ RegenerateSecret,
+}
+
+struct AuthTokenPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<AnswerToken>,
+ columns: Rc<Vec<DataTableHeader<AnswerToken>>>,
+}
+
+pwt::impl_deref_mut_property!(
+ AuthTokenPanelComponent,
+ state,
+ LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for AuthTokenPanelComponent {
+ type Properties = AuthTokenPanel;
+ type Message = Message;
+ type ViewState = ViewState;
+
+ fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+ let store =
+ Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_string()));
+ store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+ Self {
+ state: LoadableComponentState::new(),
+ selection: Selection::new()
+ .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+ store,
+ columns: Rc::new(columns()),
+ }
+ }
+
+ fn load(
+ &self,
+ _ctx: &LoadableComponentContext<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+ let store = self.store.clone();
+ Box::pin(async move {
+ let data = pdm_client().get_autoinst_tokens().await?;
+ store.write().set_data(data);
+ Ok(())
+ })
+ }
+
+ fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+ let link = ctx.link().clone();
+
+ match msg {
+ Message::SelectionChange => true,
+ Message::RemoveEntry => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ if let Err(err) = pdm_client()
+ .delete_autoinst_token(&percent_encode_component(&key.to_string()))
+ .await
+ {
+ link.show_error(tr!("Unable to delete entry"), err, true);
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ Message::RegenerateSecret => {
+ if let Some(key) = self.selection.selected_key() {
+ self.spawn(async move {
+ match regenerate_token_secret(&key.to_string()).await {
+ Ok(AnswerTokenUpdateResult {
+ token,
+ secret: Some(secret),
+ }) => {
+ link.change_view(Some(ViewState::DisplaySecret { token, secret }))
+ }
+ Ok(_) => link.show_error(
+ tr!("Failed to regenerate secret"),
+ tr!("Received no new secret"),
+ true,
+ ),
+ Err(err) => {
+ link.show_error(tr!("Failed to regenerate secret"), err, true)
+ }
+ }
+ link.send_reload();
+ })
+ }
+ false
+ }
+ }
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let link = ctx.link().clone();
+
+ let toolbar = Toolbar::new()
+ .class("pwt-w-100")
+ .class(pwt::css::Overflow::Hidden)
+ .class("pwt-border-bottom")
+ .with_child(
+ Button::new(tr!("Add"))
+ .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+ )
+ .with_spacer()
+ .with_child(
+ Button::new(tr!("Edit"))
+ .disabled(self.selection.is_empty())
+ .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+ )
+ .with_child(
+ ConfirmButton::new(tr!("Remove"))
+ .confirm_message(tr!("Are you sure you want to remove this entry?"))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RemoveEntry)),
+ )
+ .with_spacer()
+ .with_child(
+ ConfirmButton::new(tr!("Regenerate Secret"))
+ .confirm_message(tr!(
+ "Do you want to regenerate the secret of the selected token? \
+ All existing ISOs with this token will lose access!"
+ ))
+ .disabled(self.selection.is_empty())
+ .on_activate(link.callback(|_| Message::RegenerateSecret)),
+ );
+
+ Some(toolbar.into())
+ }
+
+ fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+ let link = ctx.link().clone();
+
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .class(pwt::css::FlexFit)
+ .selection(self.selection.clone())
+ .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit)))
+ .into()
+ }
+
+ fn dialog_view(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ view_state: &Self::ViewState,
+ ) -> Option<yew::Html> {
+ match view_state {
+ Self::ViewState::Create => self.create_add_dialog(ctx),
+ Self::ViewState::Edit => self.create_edit_dialog(ctx),
+ Self::ViewState::DisplaySecret { token, secret } => render_show_secret_dialog(
+ None,
+ token,
+ secret,
+ ctx.link().change_view_callback(|_| None),
+ ),
+ }
+ }
+}
+
+impl AuthTokenPanelComponent {
+ fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let window = EditWindow::new(tr!("Add") + ": " + &tr!("Token"))
+ .renderer(add_input_panel)
+ .on_submit({
+ let link = ctx.link().clone();
+ move |form_ctx| {
+ let link = link.clone();
+ async move {
+ match create_token(form_ctx).await {
+ Ok(AnswerTokenCreateResult { token, secret }) => {
+ link.change_view(Some(ViewState::DisplaySecret { token, secret }));
+ Ok(())
+ }
+ Err(err) => Err(err),
+ }
+ }
+ }
+ })
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(window)
+ }
+
+ fn create_edit_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+ let record = self
+ .store
+ .read()
+ .lookup_record(&self.selection.selected_key()?)?
+ .clone();
+
+ let window = EditWindow::new(tr!("Edit") + ": " + &tr!("Token"))
+ .renderer({
+ let record = record.clone();
+ move |_| edit_input_panel(&record)
+ })
+ .submit_text(tr!("Update"))
+ .on_submit({
+ let id = record.id.clone();
+ move |form_ctx| {
+ let id = id.clone();
+ async move { update_token(form_ctx, &id).await }
+ }
+ })
+ .on_done(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(window)
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<AnswerToken>> {
+ vec![
+ DataTableColumn::new(tr!("Name"))
+ .width("200px")
+ .render(|item: &AnswerToken| html! { &item.id })
+ .sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Created by"))
+ .width("150px")
+ .render(|item: &AnswerToken| html! { &item.created_by })
+ .sorter(|a: &AnswerToken, b: &AnswerToken| a.created_by.cmp(&b.created_by))
+ .into(),
+ DataTableColumn::new(tr!("Enabled"))
+ .width("80px")
+ .render(|item: &AnswerToken| {
+ if item.enabled.unwrap_or(false) {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .sorter(|a: &AnswerToken, b: &AnswerToken| a.enabled.cmp(&b.enabled))
+ .into(),
+ DataTableColumn::new(tr!("Expire"))
+ .width("200px")
+ .render({
+ move |item: &AnswerToken| {
+ html! {
+ match item.expire_at {
+ Some(epoch) if epoch != 0 => render_epoch_short(epoch),
+ _ => tr!("never"),
+ }
+ }
+ }
+ })
+ .sorter(|a: &AnswerToken, b: &AnswerToken| {
+ let a = a
+ .expire_at
+ .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+ let b = b
+ .expire_at
+ .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+
+ a.cmp(&b)
+ })
+ .into(),
+ DataTableColumn::new("Comment")
+ .flex(1)
+ .render(|item: &AnswerToken| html! { item.comment.clone().unwrap_or_default() })
+ .into(),
+ ]
+}
+
+fn edit_input_panel(token: &AnswerToken) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_right_field(
+ tr!("Expire"),
+ Field::new()
+ .name("expire-at")
+ .value(
+ token
+ .expire_at
+ .and_then(|exp| proxmox_time::epoch_to_rfc3339(exp).ok()),
+ )
+ .placeholder(tr!("never"))
+ .input_type(InputType::DatetimeLocal),
+ )
+ .with_field(
+ tr!("Token Name"),
+ Field::new()
+ .name("id")
+ .value(token.id.clone())
+ .submit(false)
+ .disabled(true)
+ .required(true),
+ )
+ .with_right_field(
+ tr!("Enabled"),
+ Checkbox::new().name("enabled").checked(token.enabled),
+ )
+ .with_large_field(
+ tr!("Comment"),
+ Field::new()
+ .name("comment")
+ .value(token.comment.clone())
+ .submit_empty(true),
+ )
+ .into()
+}
+
+fn add_input_panel(_form_ctx: &FormContext) -> Html {
+ InputPanel::new()
+ .padding(4)
+ .with_field(
+ tr!("Token Name"),
+ Field::new().name("id").submit(false).required(true),
+ )
+ .with_right_field(
+ tr!("Expire"),
+ Field::new()
+ .name("expire-at")
+ .placeholder(tr!("never"))
+ .input_type(InputType::DatetimeLocal),
+ )
+ .with_right_field(
+ tr!("Enabled"),
+ Checkbox::new().name("enabled").default(true),
+ )
+ .with_large_field(tr!("Comment"), Field::new().name("comment"))
+ .into()
+}
+
+async fn create_token(form_ctx: FormContext) -> Result<AnswerTokenCreateResult> {
+ let id = form_ctx.read().get_field_text("id");
+ let comment = form_ctx.read().get_field_text("comment");
+ let enable = form_ctx.read().get_field_checked("enabled");
+ let expire =
+ proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0);
+
+ Ok(pdm_client()
+ .add_autoinst_token(
+ &percent_encode_component(&id),
+ Some(comment),
+ Some(enable),
+ Some(expire),
+ )
+ .await?)
+}
+
+async fn update_token(form_ctx: FormContext, id: &str) -> Result<()> {
+ let updater = AnswerTokenUpdater {
+ comment: Some(form_ctx.read().get_field_text("comment")),
+ enabled: Some(form_ctx.read().get_field_checked("enabled")),
+ expire_at: Some(
+ proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0),
+ ),
+ };
+
+ pdm_client()
+ .update_autoinst_token(&percent_encode_component(id), &updater, &[], false)
+ .await?;
+ Ok(())
+}
+
+async fn regenerate_token_secret(id: &str) -> Result<AnswerTokenUpdateResult> {
+ Ok(pdm_client()
+ .update_autoinst_token(
+ &percent_encode_component(id),
+ &AnswerTokenUpdater::default(),
+ &[],
+ true,
+ )
+ .await?)
+}
--
2.53.0
next prev parent reply other threads:[~2026-04-30 12:50 UTC|newest]
Thread overview: 41+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-30 12:46 [PATCH datacenter-manager/installer/proxmox/yew-comp v4 00/40] add auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 01/40] api-macro: allow $ in identifier name Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 02/40] schema: oneOf: allow single string variant Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 03/40] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 04/40] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 05/40] network-types: implement api type for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 06/40] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 07/40] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 08/40] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 09/40] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 10/40] installer-types: add common types used by the installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 11/40] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 12/40] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-30 12:46 ` [PATCH yew-comp v4 13/40] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 14/40] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 15/40] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 16/40] config: add auto-installer configuration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 17/40] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 18/40] server: api: add auto-installer integration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 19/40] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 20/40] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 21/40] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 22/40] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2026-04-30 12:46 ` Christoph Heiss [this message]
2026-04-30 12:46 ` [PATCH datacenter-manager v4 24/40] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 25/40] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 26/40] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 27/40] common: http: retrieve error message from body on post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 28/40] common: options: move regex construction out of loop Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 29/40] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 30/40] post-hook: run cargo fmt Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 31/40] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 32/40] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 33/40] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 34/40] auto: sysinfo: switch to " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 35/40] fetch-answer: " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 36/40] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 37/40] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 38/40] fetch-answer: print full error messages when fetching failed Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 39/40] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 40/40] auto: drop now-dead answer file definitions Christoph Heiss
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=20260430124712.1614305-24-c.heiss@proxmox.com \
--to=c.heiss@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox