From: Christoph Heiss <c.heiss@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token configuration panel
Date: Fri, 3 Apr 2026 18:53:55 +0200 [thread overview]
Message-ID: <20260403165437.2166551-24-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260403165437.2166551-1-c.heiss@proxmox.com>
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v2 -> v3:
* new patch
ui/src/remotes/auto_installer/mod.rs | 18 +-
.../prepared_answer_add_wizard.rs | 29 +-
.../prepared_answer_edit_window.rs | 34 +-
.../auto_installer/prepared_answer_form.rs | 22 +-
ui/src/remotes/auto_installer/token_panel.rs | 476 ++++++++++++++++++
.../remotes/auto_installer/token_selector.rs | 137 +++++
6 files changed, 701 insertions(+), 15 deletions(-)
create mode 100644 ui/src/remotes/auto_installer/token_panel.rs
create mode 100644 ui/src/remotes/auto_installer/token_selector.rs
diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index 1a85978..447c04f 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -5,6 +5,8 @@ mod prepared_answer_add_wizard;
mod prepared_answer_edit_window;
mod prepared_answer_form;
mod prepared_answers_panel;
+mod token_panel;
+mod token_selector;
use std::rc::Rc;
use yew::virtual_dom::{VComp, VNode};
@@ -50,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()),
)
@@ -67,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/prepared_answer_add_wizard.rs b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
index 5d15a43..dd3869e 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_add_wizard.rs
@@ -7,15 +7,17 @@ use std::{collections::BTreeMap, future::Future, pin::Pin, rc::Rc};
use wasm_bindgen::JsValue;
use yew::{
html::IntoEventCallback,
- virtual_dom::{VComp, VNode},
+ virtual_dom::{Key, VComp, VNode},
};
-use pdm_api_types::auto_installer::{DiskSelectionMode, PreparedInstallationConfig};
+use pdm_api_types::auto_installer::{
+ AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig,
+};
use proxmox_yew_comp::{
LoadableComponent, LoadableComponentContext, LoadableComponentMaster, LoadableComponentState,
Wizard, WizardPageRenderInfo,
};
-use pwt::{prelude::*, widget::TabBarItem};
+use pwt::{prelude::*, state::Store, widget::TabBarItem};
use pwt_macros::builder;
use super::prepared_answer_form::*;
@@ -90,6 +92,7 @@ impl From<AddAnswerWizardProperties> for VNode {
struct AddAnswerWizardComponent {
state: LoadableComponentState<()>,
+ token_store: Store<AnswerAuthToken>,
}
pwt::impl_deref_mut_property!(AddAnswerWizardComponent, state, LoadableComponentState<()>);
@@ -100,8 +103,13 @@ impl LoadableComponent for AddAnswerWizardComponent {
type ViewState = ();
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let store =
+ Store::with_extract_key(|record: &AnswerAuthToken| Key::from(record.id.to_owned()));
+ store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id));
+
Self {
state: LoadableComponentState::new(),
+ token_store: store,
}
}
@@ -109,7 +117,17 @@ impl LoadableComponent for AddAnswerWizardComponent {
&self,
_ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
- Box::pin(async move { Ok(()) })
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_auth_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
@@ -142,7 +160,8 @@ impl LoadableComponent for AddAnswerWizardComponent {
})
.with_page(TabBarItem::new().label(tr!("Authentication")), {
let config = props.config.clone();
- move |_: &WizardPageRenderInfo| render_auth_form(&config)
+ let token_store = self.token_store.clone();
+ move |_: &WizardPageRenderInfo| render_auth_form(&config, token_store.clone())
})
.into()
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
index 3fb9766..71e81c4 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_edit_window.rs
@@ -4,12 +4,12 @@ use anyhow::Result;
use std::{future::Future, pin::Pin, rc::Rc};
use yew::{
html::IntoEventCallback,
- virtual_dom::{VComp, VNode},
+ virtual_dom::{Key, VComp, VNode},
};
use crate::pdm_client;
use pdm_api_types::auto_installer::{
- DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
+ AnswerAuthToken, DeletablePreparedInstallationConfigProperty, PreparedInstallationConfig,
};
use proxmox_yew_comp::{
form::delete_empty_values, percent_encoding::percent_encode_component, EditWindow,
@@ -18,6 +18,7 @@ use proxmox_yew_comp::{
use pwt::{
css::FlexFit,
prelude::*,
+ state::Store,
widget::{form::FormContext, TabBarItem, TabPanel},
};
use pwt_macros::builder;
@@ -52,6 +53,7 @@ impl From<EditAnswerWindowProperties> for VNode {
struct EditAnswerWindowComponent {
state: LoadableComponentState<()>,
+ token_store: Store<AnswerAuthToken>,
}
pwt::impl_deref_mut_property!(EditAnswerWindowComponent, state, LoadableComponentState<()>);
@@ -62,8 +64,13 @@ impl LoadableComponent for EditAnswerWindowComponent {
type ViewState = ();
fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+ let token_store =
+ Store::with_extract_key(|record: &AnswerAuthToken| Key::from(record.id.to_owned()));
+ token_store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id));
+
Self {
state: LoadableComponentState::new(),
+ token_store,
}
}
@@ -71,7 +78,17 @@ impl LoadableComponent for EditAnswerWindowComponent {
&self,
_ctx: &LoadableComponentContext<Self>,
) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
- Box::pin(async move { Ok(()) })
+ let store = self.token_store.clone();
+ Box::pin(async move {
+ let data = pdm_client()
+ .get_autoinst_auth_tokens()
+ .await?
+ .into_iter()
+ .collect();
+
+ store.write().set_data(data);
+ Ok(())
+ })
}
fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> Html {
@@ -83,7 +100,8 @@ impl LoadableComponent for EditAnswerWindowComponent {
.on_done(props.on_done.clone())
.renderer({
let props = props.clone();
- move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props)
+ let token_store = self.token_store.clone();
+ move |form_ctx: &FormContext| render_tabpanel(form_ctx, &props, token_store.clone())
})
.edit(true)
.submit_digest(true)
@@ -133,7 +151,11 @@ async fn submit(id: &str, form_data: serde_json::Value) -> Result<()> {
Ok(())
}
-fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -> yew::Html {
+fn render_tabpanel(
+ form_ctx: &FormContext,
+ props: &EditAnswerWindowProperties,
+ token_store: Store<AnswerAuthToken>,
+) -> yew::Html {
TabPanel::new()
.class(FlexFit)
.force_render_all(true)
@@ -159,7 +181,7 @@ fn render_tabpanel(form_ctx: &FormContext, props: &EditAnswerWindowProperties) -
)
.with_item(
TabBarItem::new().label(tr!("Authentication")),
- render_auth_form(&props.config),
+ render_auth_form(&props.config, token_store),
)
.into()
}
diff --git a/ui/src/remotes/auto_installer/prepared_answer_form.rs b/ui/src/remotes/auto_installer/prepared_answer_form.rs
index 29bc768..f8ade20 100644
--- a/ui/src/remotes/auto_installer/prepared_answer_form.rs
+++ b/ui/src/remotes/auto_installer/prepared_answer_form.rs
@@ -7,7 +7,8 @@ use serde_json::{json, Value};
use std::{collections::BTreeMap, ops::Deref, rc::Rc, sync::LazyLock};
use pdm_api_types::auto_installer::{
- DiskSelectionMode, PreparedInstallationConfig, PREPARED_INSTALL_CONFIG_ID_SCHEMA,
+ AnswerAuthToken, DiskSelectionMode, PreparedInstallationConfig,
+ PREPARED_INSTALL_CONFIG_ID_SCHEMA,
};
use proxmox_installer_types::{
answer::{
@@ -23,12 +24,15 @@ use proxmox_yew_comp::SchemaValidation;
use pwt::{
css::{Flex, FlexFit, Overflow},
prelude::*,
+ state::Store,
widget::{
form::{Checkbox, Combobox, DisplayField, Field, FormContext, InputType, Number, TextArea},
Container, Fa, FieldPosition, InputPanel, KeyValueList,
},
};
+use crate::remotes::auto_installer::token_selector::TokenSelector;
+
pub fn prepare_form_data(mut value: serde_json::Value) -> Result<serde_json::Value> {
let obj = value
.as_object_mut()
@@ -773,11 +777,25 @@ pub fn render_templating_form(config: &PreparedInstallationConfig) -> yew::Html
.into()
}
-pub fn render_auth_form(config: &PreparedInstallationConfig) -> yew::Html {
+pub fn render_auth_form(
+ config: &PreparedInstallationConfig,
+ tokens: Store<AnswerAuthToken>,
+) -> yew::Html {
InputPanel::new()
.class(Flex::Fill)
.class(Overflow::Auto)
.padding(4)
+ .with_custom_child(
+ Container::from_tag("span")
+ .class("pwt-font-title-medium")
+ .with_child(tr!("Authorized tokens")),
+ )
+ .with_large_custom_child(
+ TokenSelector::new(tokens)
+ .selected_keys(config.authorized_tokens.clone())
+ .name("authorized-tokens"),
+ )
+ .with_spacer()
.with_large_custom_child(
Container::from_tag("span")
.class("pwt-mb-2 pwt-mt-2 pwt-d-block pwt-color-primary")
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..18d920a
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_panel.rs
@@ -0,0 +1,476 @@
+//! Implements the UI for the auto-installer authentication authentication token panel.
+
+use anyhow::{bail, 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::{AnswerAuthToken, AnswerAuthTokenUpdater};
+use proxmox_yew_comp::{
+ percent_encoding::percent_encode_component,
+ utils::{copy_text_to_clipboard, render_epoch_short},
+ ConfirmButton, EditWindow, LoadableComponent, LoadableComponentContext,
+ LoadableComponentMaster, LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+ css::ColorScheme,
+ props::{
+ ContainerBuilder, CssBorderBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder,
+ WidgetBuilder,
+ },
+ state::{Selection, Store},
+ tr,
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader},
+ form::{Checkbox, DisplayField, Field, FormContext, InputType},
+ Button, Column, Container, Dialog, Fa, FieldLabel, InputPanel, Row, Toolbar, Tooltip,
+ },
+};
+
+use crate::pdm_client;
+
+#[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(String, String),
+}
+
+#[derive(PartialEq)]
+enum Message {
+ SelectionChange,
+ RemoveEntry,
+ RegenerateSecret,
+}
+
+struct AuthTokenPanelComponent {
+ state: LoadableComponentState<ViewState>,
+ selection: Selection,
+ store: Store<AnswerAuthToken>,
+ columns: Rc<Vec<DataTableHeader<AnswerAuthToken>>>,
+}
+
+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: &AnswerAuthToken| Key::from(record.id.to_string()));
+ store.set_sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| 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_auth_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_auth_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((token, secret)) => {
+ link.change_view(Some(ViewState::DisplaySecret(token.id, secret)))
+ }
+ 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_id, secret) => {
+ self.show_secret_dialog(ctx, token_id.into(), secret.into())
+ }
+ }
+ }
+}
+
+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((token, secret)) => {
+ link.change_view(Some(ViewState::DisplaySecret(token.id, 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 show_secret_dialog(
+ &self,
+ ctx: &LoadableComponentContext<Self>,
+ token_id: String,
+ secret: String,
+ ) -> Option<yew::Html> {
+ let copy_secret_view = Container::new()
+ .class("pwt-form-grid-col4")
+ .with_child(FieldLabel::new(tr!("Secret")))
+ .with_child(
+ Row::new()
+ .class("pwt-fill-grid-row")
+ .gap(2)
+ .with_child(
+ Field::new()
+ .input_type(InputType::Password)
+ .class(pwt::css::FlexFit)
+ .value(secret.clone())
+ .read_only(true),
+ )
+ .with_child(
+ Tooltip::new(
+ Button::new_icon("fa fa-clipboard")
+ .class(ColorScheme::Primary)
+ .on_activate(move |_| copy_text_to_clipboard(&secret)),
+ )
+ .tip(tr!("Copy token secret to clipboard.")),
+ ),
+ );
+
+ let dialog = Dialog::new(tr!("Token Secret"))
+ .with_child(
+ Column::new().with_child(
+ InputPanel::new()
+ .padding(4)
+ .with_large_field(
+ tr!("Token ID"),
+ DisplayField::new().value(token_id).border(true),
+ )
+ .with_large_custom_child(copy_secret_view),
+ ),
+ )
+ .with_child(
+ Container::new()
+ .padding(4)
+ .class(pwt::css::FlexFit)
+ .class(ColorScheme::WarningContainer)
+ .class("pwt-default-colors")
+ .with_child(tr!(
+ "Please record the token secret - it will only be displayed once."
+ )),
+ )
+ .on_close(ctx.link().change_view_callback(|_| None))
+ .into();
+
+ Some(dialog)
+ }
+}
+
+fn columns() -> Vec<DataTableHeader<AnswerAuthToken>> {
+ vec![
+ DataTableColumn::new(tr!("Name"))
+ .width("200px")
+ .render(|item: &AnswerAuthToken| html! { &item.id })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Created by"))
+ .width("150px")
+ .render(|item: &AnswerAuthToken| html! { &item.created_by })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.created_by.cmp(&b.created_by))
+ .into(),
+ DataTableColumn::new(tr!("Enabled"))
+ .width("80px")
+ .render(|item: &AnswerAuthToken| {
+ if item.enabled.unwrap_or(false) {
+ Fa::new("check").into()
+ } else {
+ Fa::new("times").into()
+ }
+ })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.enabled.cmp(&b.enabled))
+ .into(),
+ DataTableColumn::new(tr!("Expire"))
+ .width("200px")
+ .render({
+ move |item: &AnswerAuthToken| {
+ html! {
+ match item.expire_at {
+ Some(epoch) if epoch != 0 => render_epoch_short(epoch),
+ _ => tr!("never"),
+ }
+ }
+ }
+ })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| {
+ 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: &AnswerAuthToken| html! { item.comment.clone().unwrap_or_default() })
+ .into(),
+ ]
+}
+
+fn edit_input_panel(token: &AnswerAuthToken) -> 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<(AnswerAuthToken, String)> {
+ 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);
+
+ let result = pdm_client()
+ .add_autoinst_auth_token(
+ &percent_encode_component(&id),
+ Some(comment),
+ Some(enable),
+ Some(expire),
+ )
+ .await?;
+ Ok(result)
+}
+
+async fn update_token(form_ctx: FormContext, id: &str) -> Result<()> {
+ let updater = AnswerAuthTokenUpdater {
+ 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_auth_token(&percent_encode_component(id), &updater, &[], false)
+ .await?;
+ Ok(())
+}
+
+async fn regenerate_token_secret(id: &str) -> Result<(AnswerAuthToken, String)> {
+ let result = pdm_client()
+ .update_autoinst_auth_token(
+ &percent_encode_component(id),
+ &AnswerAuthTokenUpdater::default(),
+ &[],
+ true,
+ )
+ .await?;
+
+ match result {
+ (token, Some(secret)) => Ok((token, secret)),
+ _ => bail!(tr!("No new secret received")),
+ }
+}
diff --git a/ui/src/remotes/auto_installer/token_selector.rs b/ui/src/remotes/auto_installer/token_selector.rs
new file mode 100644
index 0000000..5b0eaad
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_selector.rs
@@ -0,0 +1,137 @@
+//! A [`GridPicker`]-based selector for access tokens for the automated installer.
+
+use pdm_api_types::auto_installer::AnswerAuthToken;
+use serde_json::Value;
+use std::{collections::HashSet, rc::Rc};
+use yew::{html, virtual_dom::Key, Properties};
+
+use pwt::{
+ css::FlexFit,
+ prelude::*,
+ state::{Selection, Store},
+ widget::{
+ data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode},
+ form::{
+ ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt,
+ ManagedFieldState,
+ },
+ GridPicker,
+ },
+};
+use pwt_macros::{builder, widget};
+
+#[widget(comp = ManagedFieldMaster<TokenSelectorField>, @input)]
+#[derive(Clone, PartialEq, Properties)]
+#[builder]
+pub struct TokenSelector {
+ /// All available tokens to select.
+ store: Store<AnswerAuthToken>,
+
+ #[builder]
+ #[prop_or_default]
+ /// Keys of entries to pre-select.
+ pub selected_keys: Vec<String>,
+}
+
+impl TokenSelector {
+ pub fn new(store: Store<AnswerAuthToken>) -> Self {
+ yew::props!(Self { store })
+ }
+}
+
+pub struct TokenSelectorField {
+ state: ManagedFieldState,
+ store: Store<AnswerAuthToken>,
+ selection: Selection,
+ columns: Rc<Vec<DataTableHeader<AnswerAuthToken>>>,
+}
+
+pwt::impl_deref_mut_property!(TokenSelectorField, state, ManagedFieldState);
+
+pub enum Message {
+ UpdateSelection,
+}
+
+impl TokenSelectorField {
+ fn columns() -> Rc<Vec<DataTableHeader<AnswerAuthToken>>> {
+ Rc::new(vec![
+ DataTableColumn::selection_indicator().into(),
+ DataTableColumn::new(tr!("Token"))
+ .flex(1)
+ .render(|item: &AnswerAuthToken| html! { &item.id })
+ .sorter(|a: &AnswerAuthToken, b: &AnswerAuthToken| a.id.cmp(&b.id))
+ .sort_order(true)
+ .into(),
+ DataTableColumn::new(tr!("Comment"))
+ .flex(1)
+ .render(|item: &AnswerAuthToken| html! { item.comment.as_deref().unwrap_or("") })
+ .into(),
+ ])
+ }
+}
+
+impl ManagedField for TokenSelectorField {
+ type Message = Message;
+ type Properties = TokenSelector;
+ type ValidateClosure = ();
+
+ fn create(ctx: &ManagedFieldContext<Self>) -> Self {
+ let selection = Selection::new()
+ .multiselect(true)
+ .on_select(ctx.link().callback(|_| Message::UpdateSelection));
+
+ let store = ctx.props().store.clone().on_change(ctx.link().callback({
+ let selection = selection.clone();
+ let selected = ctx
+ .props()
+ .selected_keys
+ .iter()
+ .map(|s| Key::from(s.clone()))
+ .collect::<HashSet<Key>>();
+
+ move |_| {
+ selection.bulk_select(selected.clone());
+ Message::UpdateSelection
+ }
+ }));
+
+ Self {
+ state: ManagedFieldState::new(Value::Array(Vec::new()), Value::Array(Vec::new())),
+ store,
+ selection,
+ columns: Self::columns(),
+ }
+ }
+
+ fn validation_args(_props: &Self::Properties) -> Self::ValidateClosure {}
+
+ fn validator(_props: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
+ Ok(value.clone())
+ }
+
+ fn update(&mut self, ctx: &ManagedFieldContext<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Self::Message::UpdateSelection => {
+ ctx.link().update_value(
+ self.selection
+ .selected_keys()
+ .iter()
+ .map(|k| k.to_string())
+ .collect::<Vec<_>>(),
+ );
+ true
+ }
+ }
+ }
+
+ fn view(&self, _ctx: &ManagedFieldContext<Self>) -> Html {
+ GridPicker::new(
+ DataTable::new(self.columns.clone(), self.store.clone())
+ .multiselect_mode(MultiSelectMode::Simple)
+ .border(true)
+ .class(FlexFit),
+ )
+ .selection(self.selection.clone())
+ .into()
+ }
+}
--
2.53.0
next prev parent reply other threads:[~2026-04-03 16:57 UTC|newest]
Thread overview: 39+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-03 16:53 [PATCH proxmox/yew-pwt/datacenter-manager/installer v3 00/38] add auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 01/38] api-macro: allow $ in identifier name Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 02/38] schema: oneOf: allow single string variant Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 03/38] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 04/38] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 05/38] network-types: implement api type for Fqdn Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 06/38] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 07/38] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 08/38] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 09/38] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 10/38] installer-types: add common types used by the installer Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 11/38] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-03 16:53 ` [PATCH proxmox v3 12/38] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-03 16:53 ` [PATCH yew-widget-toolkit v3 13/38] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 14/38] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 15/38] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 16/38] config: add auto-installer configuration module Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 17/38] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 18/38] server: api: add auto-installer integration module Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 19/38] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 20/38] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 21/38] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-03 16:53 ` [PATCH datacenter-manager v3 22/38] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2026-04-03 16:53 ` Christoph Heiss [this message]
2026-04-03 16:53 ` [PATCH datacenter-manager v3 24/38] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 25/38] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 26/38] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-03 16:53 ` [PATCH installer v3 27/38] common: options: move regex construction out of loop Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 28/38] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 29/38] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 30/38] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 31/38] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 32/38] post-hook: switch to types in proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 33/38] auto: sysinfo: switch to types from proxmox-installer-types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 34/38] fetch-answer: " Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 35/38] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 36/38] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 37/38] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-03 16:54 ` [PATCH installer v3 38/38] 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=20260403165437.2166551-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 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.