[PATCH datacenter-manager v3 23/38] ui: auto-installer: add access token configuration panel

Christoph Heiss
2026-04-03T16:57:13Z

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