public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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





  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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal