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 v4 23/40] ui: auto-installer: add access token configuration panel
Date: Thu, 30 Apr 2026 14:46:52 +0200	[thread overview]
Message-ID: <20260430124712.1614305-24-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260430124712.1614305-1-c.heiss@proxmox.com>

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
Changes v3 -> v4:
  * use enum newtype struct for token_panel ViewState::DisplaySecret
  * AnswerAuthToken -> AnswerToken rename
  * move show_secret_dialog render to common module
  * fix form dirty check in TokenSelector
  * moved answer form token selection to correct patch

Changes v2 -> v3:
  * new patch

 ui/src/remotes/auto_installer/mod.rs         |  17 +-
 ui/src/remotes/auto_installer/token_panel.rs | 420 +++++++++++++++++++
 2 files changed, 435 insertions(+), 2 deletions(-)
 create mode 100644 ui/src/remotes/auto_installer/token_panel.rs

diff --git a/ui/src/remotes/auto_installer/mod.rs b/ui/src/remotes/auto_installer/mod.rs
index cd1f668..6926e59 100644
--- a/ui/src/remotes/auto_installer/mod.rs
+++ b/ui/src/remotes/auto_installer/mod.rs
@@ -6,6 +6,7 @@ mod prepared_answer_edit_window;
 mod prepared_answer_form;
 mod prepared_answers_panel;
 mod token_selector;
+mod token_panel;
 
 use std::rc::Rc;
 use yew::virtual_dom::{VComp, VNode};
@@ -51,15 +52,22 @@ impl Component for AutoInstallerPanelComponent {
             .with_child(tr!("Prepared Answers"))
             .into();
 
+        let secrets_title: Html = Row::new()
+            .gap(2)
+            .class(AlignItems::Baseline)
+            .with_child(Fa::new("key"))
+            .with_child(tr!("Authentication tokens"))
+            .into();
+
         Container::new()
             .class("pwt-content-spacer")
             .class(Fit)
             .class(css::Display::Grid)
             .style("grid-template-columns", "repeat(2, 1fr)")
-            .style("grid-template-rows", "repeat(1, 1fr)")
+            .style("grid-template-rows", "repeat(2, 1fr)")
             .with_child(
                 Panel::new()
-                    .style("grid-row", "span 2 / span 1")
+                    .style("grid-row", "span 2 / span 2")
                     .title(installations_title)
                     .with_child(installations_panel::InstallationsPanel::default()),
             )
@@ -68,6 +76,11 @@ impl Component for AutoInstallerPanelComponent {
                     .title(answers_title)
                     .with_child(prepared_answers_panel::PreparedAnswersPanel::default()),
             )
+            .with_child(
+                Panel::new()
+                    .title(secrets_title)
+                    .with_child(token_panel::AuthTokenPanel::default()),
+            )
             .into()
     }
 }
diff --git a/ui/src/remotes/auto_installer/token_panel.rs b/ui/src/remotes/auto_installer/token_panel.rs
new file mode 100644
index 0000000..972e3b1
--- /dev/null
+++ b/ui/src/remotes/auto_installer/token_panel.rs
@@ -0,0 +1,420 @@
+//! Implements the UI for the auto-installer authentication authentication token panel.
+
+use anyhow::Result;
+use core::clone::Clone;
+use std::{future::Future, pin::Pin, rc::Rc};
+use yew::{
+    html,
+    virtual_dom::{Key, VComp, VNode},
+    Html, Properties,
+};
+
+use pdm_api_types::auto_installer::{
+    AnswerToken, AnswerTokenCreateResult, AnswerTokenUpdateResult, AnswerTokenUpdater,
+};
+use proxmox_yew_comp::{
+    percent_encoding::percent_encode_component, utils::render_epoch_short, ConfirmButton,
+    EditWindow, LoadableComponent, LoadableComponentContext, LoadableComponentMaster,
+    LoadableComponentScopeExt, LoadableComponentState,
+};
+use pwt::{
+    props::{ContainerBuilder, CssPaddingBuilder, EventSubscriber, FieldBuilder, WidgetBuilder},
+    state::{Selection, Store},
+    tr,
+    widget::{
+        data_table::{DataTable, DataTableColumn, DataTableHeader},
+        form::{Checkbox, Field, FormContext, InputType},
+        Button, Fa, InputPanel, Toolbar,
+    },
+};
+
+use crate::{pdm_client, remotes::auto_installer::prepared_answer_form::render_show_secret_dialog};
+
+#[derive(Default, PartialEq, Properties)]
+pub struct AuthTokenPanel {}
+
+impl From<AuthTokenPanel> for VNode {
+    fn from(value: AuthTokenPanel) -> Self {
+        let comp =
+            VComp::new::<LoadableComponentMaster<AuthTokenPanelComponent>>(Rc::new(value), None);
+        VNode::from(comp)
+    }
+}
+
+#[derive(PartialEq)]
+enum ViewState {
+    Create,
+    Edit,
+    DisplaySecret { token: AnswerToken, secret: String },
+}
+
+#[derive(PartialEq)]
+enum Message {
+    SelectionChange,
+    RemoveEntry,
+    RegenerateSecret,
+}
+
+struct AuthTokenPanelComponent {
+    state: LoadableComponentState<ViewState>,
+    selection: Selection,
+    store: Store<AnswerToken>,
+    columns: Rc<Vec<DataTableHeader<AnswerToken>>>,
+}
+
+pwt::impl_deref_mut_property!(
+    AuthTokenPanelComponent,
+    state,
+    LoadableComponentState<ViewState>
+);
+
+impl LoadableComponent for AuthTokenPanelComponent {
+    type Properties = AuthTokenPanel;
+    type Message = Message;
+    type ViewState = ViewState;
+
+    fn create(ctx: &LoadableComponentContext<Self>) -> Self {
+        let store =
+            Store::with_extract_key(|record: &AnswerToken| Key::from(record.id.to_string()));
+        store.set_sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id));
+
+        Self {
+            state: LoadableComponentState::new(),
+            selection: Selection::new()
+                .on_select(ctx.link().callback(|_| Message::SelectionChange)),
+            store,
+            columns: Rc::new(columns()),
+        }
+    }
+
+    fn load(
+        &self,
+        _ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<()>>>> {
+        let store = self.store.clone();
+        Box::pin(async move {
+            let data = pdm_client().get_autoinst_tokens().await?;
+            store.write().set_data(data);
+            Ok(())
+        })
+    }
+
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Message) -> bool {
+        let link = ctx.link().clone();
+
+        match msg {
+            Message::SelectionChange => true,
+            Message::RemoveEntry => {
+                if let Some(key) = self.selection.selected_key() {
+                    self.spawn(async move {
+                        if let Err(err) = pdm_client()
+                            .delete_autoinst_token(&percent_encode_component(&key.to_string()))
+                            .await
+                        {
+                            link.show_error(tr!("Unable to delete entry"), err, true);
+                        }
+                        link.send_reload();
+                    })
+                }
+                false
+            }
+            Message::RegenerateSecret => {
+                if let Some(key) = self.selection.selected_key() {
+                    self.spawn(async move {
+                        match regenerate_token_secret(&key.to_string()).await {
+                            Ok(AnswerTokenUpdateResult {
+                                token,
+                                secret: Some(secret),
+                            }) => {
+                                link.change_view(Some(ViewState::DisplaySecret { token, secret }))
+                            }
+                            Ok(_) => link.show_error(
+                                tr!("Failed to regenerate secret"),
+                                tr!("Received no new secret"),
+                                true,
+                            ),
+                            Err(err) => {
+                                link.show_error(tr!("Failed to regenerate secret"), err, true)
+                            }
+                        }
+                        link.send_reload();
+                    })
+                }
+                false
+            }
+        }
+    }
+
+    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+        let link = ctx.link().clone();
+
+        let toolbar = Toolbar::new()
+            .class("pwt-w-100")
+            .class(pwt::css::Overflow::Hidden)
+            .class("pwt-border-bottom")
+            .with_child(
+                Button::new(tr!("Add"))
+                    .onclick(link.change_view_callback(|_| Some(ViewState::Create))),
+            )
+            .with_spacer()
+            .with_child(
+                Button::new(tr!("Edit"))
+                    .disabled(self.selection.is_empty())
+                    .onclick(link.change_view_callback(|_| Some(ViewState::Edit))),
+            )
+            .with_child(
+                ConfirmButton::new(tr!("Remove"))
+                    .confirm_message(tr!("Are you sure you want to remove this entry?"))
+                    .disabled(self.selection.is_empty())
+                    .on_activate(link.callback(|_| Message::RemoveEntry)),
+            )
+            .with_spacer()
+            .with_child(
+                ConfirmButton::new(tr!("Regenerate Secret"))
+                    .confirm_message(tr!(
+                        "Do you want to regenerate the secret of the selected token? \
+                        All existing ISOs with this token will lose access!"
+                    ))
+                    .disabled(self.selection.is_empty())
+                    .on_activate(link.callback(|_| Message::RegenerateSecret)),
+            );
+
+        Some(toolbar.into())
+    }
+
+    fn main_view(&self, ctx: &LoadableComponentContext<Self>) -> yew::Html {
+        let link = ctx.link().clone();
+
+        DataTable::new(self.columns.clone(), self.store.clone())
+            .class(pwt::css::FlexFit)
+            .selection(self.selection.clone())
+            .on_row_dblclick(move |_: &mut _| link.change_view(Some(Self::ViewState::Edit)))
+            .into()
+    }
+
+    fn dialog_view(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+        view_state: &Self::ViewState,
+    ) -> Option<yew::Html> {
+        match view_state {
+            Self::ViewState::Create => self.create_add_dialog(ctx),
+            Self::ViewState::Edit => self.create_edit_dialog(ctx),
+            Self::ViewState::DisplaySecret { token, secret } => render_show_secret_dialog(
+                None,
+                token,
+                secret,
+                ctx.link().change_view_callback(|_| None),
+            ),
+        }
+    }
+}
+
+impl AuthTokenPanelComponent {
+    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+        let window = EditWindow::new(tr!("Add") + ": " + &tr!("Token"))
+            .renderer(add_input_panel)
+            .on_submit({
+                let link = ctx.link().clone();
+                move |form_ctx| {
+                    let link = link.clone();
+                    async move {
+                        match create_token(form_ctx).await {
+                            Ok(AnswerTokenCreateResult { token, secret }) => {
+                                link.change_view(Some(ViewState::DisplaySecret { token, secret }));
+                                Ok(())
+                            }
+                            Err(err) => Err(err),
+                        }
+                    }
+                }
+            })
+            .on_close(ctx.link().change_view_callback(|_| None))
+            .into();
+
+        Some(window)
+    }
+
+    fn create_edit_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Option<yew::Html> {
+        let record = self
+            .store
+            .read()
+            .lookup_record(&self.selection.selected_key()?)?
+            .clone();
+
+        let window = EditWindow::new(tr!("Edit") + ": " + &tr!("Token"))
+            .renderer({
+                let record = record.clone();
+                move |_| edit_input_panel(&record)
+            })
+            .submit_text(tr!("Update"))
+            .on_submit({
+                let id = record.id.clone();
+                move |form_ctx| {
+                    let id = id.clone();
+                    async move { update_token(form_ctx, &id).await }
+                }
+            })
+            .on_done(ctx.link().change_view_callback(|_| None))
+            .into();
+
+        Some(window)
+    }
+}
+
+fn columns() -> Vec<DataTableHeader<AnswerToken>> {
+    vec![
+        DataTableColumn::new(tr!("Name"))
+            .width("200px")
+            .render(|item: &AnswerToken| html! { &item.id })
+            .sorter(|a: &AnswerToken, b: &AnswerToken| a.id.cmp(&b.id))
+            .sort_order(true)
+            .into(),
+        DataTableColumn::new(tr!("Created by"))
+            .width("150px")
+            .render(|item: &AnswerToken| html! { &item.created_by })
+            .sorter(|a: &AnswerToken, b: &AnswerToken| a.created_by.cmp(&b.created_by))
+            .into(),
+        DataTableColumn::new(tr!("Enabled"))
+            .width("80px")
+            .render(|item: &AnswerToken| {
+                if item.enabled.unwrap_or(false) {
+                    Fa::new("check").into()
+                } else {
+                    Fa::new("times").into()
+                }
+            })
+            .sorter(|a: &AnswerToken, b: &AnswerToken| a.enabled.cmp(&b.enabled))
+            .into(),
+        DataTableColumn::new(tr!("Expire"))
+            .width("200px")
+            .render({
+                move |item: &AnswerToken| {
+                    html! {
+                        match item.expire_at {
+                            Some(epoch) if epoch != 0 => render_epoch_short(epoch),
+                            _ => tr!("never"),
+                        }
+                    }
+                }
+            })
+            .sorter(|a: &AnswerToken, b: &AnswerToken| {
+                let a = a
+                    .expire_at
+                    .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+                let b = b
+                    .expire_at
+                    .and_then(|exp| if exp == 0 { None } else { Some(exp) });
+
+                a.cmp(&b)
+            })
+            .into(),
+        DataTableColumn::new("Comment")
+            .flex(1)
+            .render(|item: &AnswerToken| html! { item.comment.clone().unwrap_or_default() })
+            .into(),
+    ]
+}
+
+fn edit_input_panel(token: &AnswerToken) -> Html {
+    InputPanel::new()
+        .padding(4)
+        .with_right_field(
+            tr!("Expire"),
+            Field::new()
+                .name("expire-at")
+                .value(
+                    token
+                        .expire_at
+                        .and_then(|exp| proxmox_time::epoch_to_rfc3339(exp).ok()),
+                )
+                .placeholder(tr!("never"))
+                .input_type(InputType::DatetimeLocal),
+        )
+        .with_field(
+            tr!("Token Name"),
+            Field::new()
+                .name("id")
+                .value(token.id.clone())
+                .submit(false)
+                .disabled(true)
+                .required(true),
+        )
+        .with_right_field(
+            tr!("Enabled"),
+            Checkbox::new().name("enabled").checked(token.enabled),
+        )
+        .with_large_field(
+            tr!("Comment"),
+            Field::new()
+                .name("comment")
+                .value(token.comment.clone())
+                .submit_empty(true),
+        )
+        .into()
+}
+
+fn add_input_panel(_form_ctx: &FormContext) -> Html {
+    InputPanel::new()
+        .padding(4)
+        .with_field(
+            tr!("Token Name"),
+            Field::new().name("id").submit(false).required(true),
+        )
+        .with_right_field(
+            tr!("Expire"),
+            Field::new()
+                .name("expire-at")
+                .placeholder(tr!("never"))
+                .input_type(InputType::DatetimeLocal),
+        )
+        .with_right_field(
+            tr!("Enabled"),
+            Checkbox::new().name("enabled").default(true),
+        )
+        .with_large_field(tr!("Comment"), Field::new().name("comment"))
+        .into()
+}
+
+async fn create_token(form_ctx: FormContext) -> Result<AnswerTokenCreateResult> {
+    let id = form_ctx.read().get_field_text("id");
+    let comment = form_ctx.read().get_field_text("comment");
+    let enable = form_ctx.read().get_field_checked("enabled");
+    let expire =
+        proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0);
+
+    Ok(pdm_client()
+        .add_autoinst_token(
+            &percent_encode_component(&id),
+            Some(comment),
+            Some(enable),
+            Some(expire),
+        )
+        .await?)
+}
+
+async fn update_token(form_ctx: FormContext, id: &str) -> Result<()> {
+    let updater = AnswerTokenUpdater {
+        comment: Some(form_ctx.read().get_field_text("comment")),
+        enabled: Some(form_ctx.read().get_field_checked("enabled")),
+        expire_at: Some(
+            proxmox_time::parse_rfc3339(&form_ctx.read().get_field_text("expire-at")).unwrap_or(0),
+        ),
+    };
+
+    pdm_client()
+        .update_autoinst_token(&percent_encode_component(id), &updater, &[], false)
+        .await?;
+    Ok(())
+}
+
+async fn regenerate_token_secret(id: &str) -> Result<AnswerTokenUpdateResult> {
+    Ok(pdm_client()
+        .update_autoinst_token(
+            &percent_encode_component(id),
+            &AnswerTokenUpdater::default(),
+            &[],
+            true,
+        )
+        .await?)
+}
-- 
2.53.0





  parent reply	other threads:[~2026-04-30 12:50 UTC|newest]

Thread overview: 41+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-30 12:46 [PATCH datacenter-manager/installer/proxmox/yew-comp v4 00/40] add auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 01/40] api-macro: allow $ in identifier name Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 02/40] schema: oneOf: allow single string variant Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 03/40] schema: implement UpdaterType for HashMap and BTreeMap Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 04/40] network-types: move `Fqdn` type from proxmox-installer-common Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 05/40] network-types: implement api type for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 06/40] network-types: add api wrapper type for std::net::IpAddr Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 07/40] network-types: cidr: implement generic `IpAddr::new` constructor Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 08/40] network-types: fqdn: implement standard library Error for Fqdn Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 09/40] node-status: make KernelVersionInformation Clone + PartialEq Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 10/40] installer-types: add common types used by the installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 11/40] installer-types: add types used by the auto-installer Christoph Heiss
2026-04-30 12:46 ` [PATCH proxmox v4 12/40] installer-types: implement api type for all externally-used types Christoph Heiss
2026-04-30 12:46 ` [PATCH yew-comp v4 13/40] widget: kvlist: add widget for user-modifiable data tables Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 14/40] api-types, cli: use ReturnType::new() instead of constructing it manually Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 15/40] api-types: add api types for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 16/40] config: add auto-installer configuration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 17/40] acl: wire up new /system/auto-installation acl path Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 18/40] server: api: add auto-installer integration module Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 19/40] server: api: auto-installer: add access token management endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 20/40] client: add bindings for auto-installer endpoints Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 21/40] ui: auto-installer: add installations overview panel Christoph Heiss
2026-04-30 12:46 ` [PATCH datacenter-manager v4 22/40] ui: auto-installer: add prepared answer configuration panel Christoph Heiss
2026-04-30 12:46 ` Christoph Heiss [this message]
2026-04-30 12:46 ` [PATCH datacenter-manager v4 24/40] docs: add documentation for auto-installer integration Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 25/40] install: iso env: use JSON boolean literals for product config Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 26/40] common: http: allow passing custom headers to post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 27/40] common: http: retrieve error message from body on post() Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 28/40] common: options: move regex construction out of loop Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 29/40] assistant: support adding an authorization token for HTTP-based answers Christoph Heiss
2026-04-30 12:46 ` [PATCH installer v4 30/40] post-hook: run cargo fmt Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 31/40] tree-wide: used moved `Fqdn` type to proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 32/40] tree-wide: use `Cidr` type from proxmox-network-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 33/40] tree-wide: switch to filesystem types from proxmox-installer-types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 34/40] auto: sysinfo: switch to " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 35/40] fetch-answer: " Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 36/40] fetch-answer: http: prefer json over toml for answer format Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 37/40] fetch-answer: send auto-installer HTTP authorization token if set Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 38/40] fetch-answer: print full error messages when fetching failed Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 39/40] tree-wide: switch out `Answer` -> `AutoInstallerConfig` types Christoph Heiss
2026-04-30 12:47 ` [PATCH installer v4 40/40] auto: drop now-dead answer file definitions Christoph Heiss

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260430124712.1614305-24-c.heiss@proxmox.com \
    --to=c.heiss@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal