* [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel
@ 2025-10-08 15:19 Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 1/2] login_panel/realm_selector: use default realm provided by api Shannon Sterz
` (3 more replies)
0 siblings, 4 replies; 6+ messages in thread
From: Shannon Sterz @ 2025-10-08 15:19 UTC (permalink / raw)
To: yew-devel
these patches add support for handling the default realm and openid
login flow for the login panel.
note that we already shipped the pve-yew-mobile-gui without support for
openid login flows. so bumping that with this applied would be helpful
for users that want to use the new mobile gui with an openid realm.
Shannon Sterz (2):
login_panel/realm_selector: use default realm provided by api
login_panel/realm_selector: add support for openid realm logins
src/login_panel.rs | 327 +++++++++++++++++++++++++++++++++---------
src/realm_selector.rs | 85 ++++++++++-
2 files changed, 340 insertions(+), 72 deletions(-)
--
2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
* [yew-devel] [PATCH yew-comp 1/2] login_panel/realm_selector: use default realm provided by api
2025-10-08 15:19 [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
@ 2025-10-08 15:19 ` Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins Shannon Sterz
` (2 subsequent siblings)
3 siblings, 0 replies; 6+ messages in thread
From: Shannon Sterz @ 2025-10-08 15:19 UTC (permalink / raw)
To: yew-devel
when a user has not previously safed their username and realm, use the
default realm provided via the api. if no realm has been specified at
all, still fall back to "pam".
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/login_panel.rs | 10 +++----
src/realm_selector.rs | 65 +++++++++++++++++++++++++++++++++++++++----
2 files changed, 64 insertions(+), 11 deletions(-)
diff --git a/src/login_panel.rs b/src/login_panel.rs
index 0b835e7..6c3aaa7 100644
--- a/src/login_panel.rs
+++ b/src/login_panel.rs
@@ -29,9 +29,9 @@ pub struct LoginPanel {
pub on_login: Option<Callback<Authentication>>,
/// Default realm.
- #[prop_or(AttrValue::from("pam"))]
+ #[prop_or_default]
#[builder]
- pub default_realm: AttrValue,
+ pub default_realm: Option<AttrValue>,
/// Mobile Layout
///
@@ -125,16 +125,16 @@ impl ProxmoxLoginPanel {
});
}
- fn get_defaults(&self, props: &LoginPanel) -> (String, String) {
+ fn get_defaults(&self, props: &LoginPanel) -> (String, Option<AttrValue>) {
let mut default_username = String::from("root");
- let mut default_realm = props.default_realm.to_string();
+ let mut default_realm = props.default_realm.clone();
if props.mobile || *self.save_username {
let last_userid: String = (*self.last_username).to_string();
if !last_userid.is_empty() {
if let Some((user, realm)) = last_userid.rsplit_once('@') {
default_username = user.to_owned();
- default_realm = realm.to_owned().into();
+ default_realm = Some(AttrValue::from(realm.to_owned()));
}
}
}
diff --git a/src/realm_selector.rs b/src/realm_selector.rs
index 0c57bf6..b11e924 100644
--- a/src/realm_selector.rs
+++ b/src/realm_selector.rs
@@ -1,4 +1,4 @@
-use anyhow::format_err;
+use anyhow::{format_err, Error};
use std::rc::Rc;
use yew::html::IntoPropValue;
@@ -61,18 +61,36 @@ impl RealmSelector {
}
}
-pub struct ProxmoxRealmSelector {
+struct ProxmoxRealmSelector {
store: Store<BasicRealmInfo>,
validate: ValidateFn<(String, Store<BasicRealmInfo>)>,
picker: RenderFn<SelectorRenderArgs<Store<BasicRealmInfo>>>,
+ loaded_default_realm: Option<AttrValue>,
+}
+
+impl ProxmoxRealmSelector {
+ async fn load_realms(url: AttrValue) -> Msg {
+ let response: Result<_, Error> = crate::http_get_full(url.to_string(), None).await;
+
+ match response {
+ Ok(data) => Msg::LoadComplete(data.data),
+ Err(_) => Msg::LoadFailed,
+ }
+ }
+}
+
+enum Msg {
+ LoadComplete(Vec<BasicRealmInfo>),
+ LoadFailed,
}
impl Component for ProxmoxRealmSelector {
- type Message = ();
+ type Message = Msg;
type Properties = RealmSelector;
fn create(ctx: &Context<Self>) -> Self {
- let store = Store::new().on_change(ctx.link().callback(|_| ())); // trigger redraw
+ let store = Store::new();
+ let url = ctx.props().path.clone();
let validate = ValidateFn::new(|(realm, store): &(String, Store<BasicRealmInfo>)| {
store
@@ -94,23 +112,58 @@ impl Component for ProxmoxRealmSelector {
.into()
});
+ ctx.link().send_future(Self::load_realms(url));
+
Self {
store,
validate,
picker,
+ loaded_default_realm: None,
+ }
+ }
+
+ fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+ match msg {
+ Msg::LoadComplete(mut data) => {
+ data.sort_by(|a, b| a.realm.cmp(&b.realm));
+
+ let realm = ctx
+ .props()
+ .default
+ .as_ref()
+ .and_then(|d| data.iter().find(|r| &r.realm == d))
+ .or_else(|| data.iter().find(|r| r.default.unwrap_or_default()))
+ .or_else(|| data.iter().find(|r| r.ty == "pam"))
+ .map(|r| AttrValue::from(r.realm.clone()));
+
+ self.loaded_default_realm = realm;
+ self.store.set_data(data);
+ true
+ }
+ // not much we can do here, so just don't re-render
+ Msg::LoadFailed => false,
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let props = ctx.props();
+ let store = self.store.clone();
+
+ let default = props
+ .default
+ .clone()
+ .or_else(|| self.loaded_default_realm.clone())
+ .unwrap_or(AttrValue::from("pam"));
Selector::new(self.store.clone(), self.picker.clone())
.with_std_props(&props.std_props)
.with_input_props(&props.input_props)
.required(true)
- .default(props.default.as_deref().unwrap_or("pam").to_string())
- .loader(props.path.clone())
+ .default(&default)
.validate(self.validate.clone())
+ // force re-render of the selector after load; returning `true` in update does not
+ // re-render the selector by itself
+ .key(format!("realm-selector-{default}"))
.into()
}
}
--
2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
* [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins
2025-10-08 15:19 [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 1/2] login_panel/realm_selector: use default realm provided by api Shannon Sterz
@ 2025-10-08 15:19 ` Shannon Sterz
2025-10-09 10:14 ` Shannon Sterz
2025-10-09 9:56 ` [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
2025-10-14 13:32 ` [yew-devel] Superseded: " Shannon Sterz
3 siblings, 1 reply; 6+ messages in thread
From: Shannon Sterz @ 2025-10-08 15:19 UTC (permalink / raw)
To: yew-devel
this commit adds support for the openid login flow. it also modifies
the realm selector so that the currently selected realm can be
communicated to the login panel, which can then only render the fields
necessary for an openid realm.
the future handling the openid login request is intentionally spawned
with `wasm_bindgen_futures::spawn_local` so that it does not get
aborted when the view is re-rendered by the CatalogueLoader. it is
also wrapped in a OnceLock to avoid making the call several times.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/login_panel.rs | 317 ++++++++++++++++++++++++++++++++++--------
src/realm_selector.rs | 26 +++-
2 files changed, 279 insertions(+), 64 deletions(-)
diff --git a/src/login_panel.rs b/src/login_panel.rs
index 6c3aaa7..6464bb5 100644
--- a/src/login_panel.rs
+++ b/src/login_panel.rs
@@ -1,5 +1,10 @@
+use std::collections::HashMap;
use std::rc::Rc;
+use std::sync::OnceLock;
+use percent_encoding::percent_decode;
+use proxmox_login::api::CreateTicketResponse;
+use pwt::css::ColorScheme;
use pwt::props::PwtSpace;
use pwt::state::PersistentState;
use pwt::touch::{SnackBar, SnackBarContextExt};
@@ -11,12 +16,15 @@ use pwt::widget::form::{Checkbox, Field, Form, FormContext, InputType, ResetButt
use pwt::widget::{Column, FieldLabel, InputPanel, LanguageSelector, Mask, Row};
use pwt::{prelude::*, AsyncPool};
-use proxmox_login::{Authentication, SecondFactorChallenge, TicketResult};
+use proxmox_login::{Authentication, SecondFactorChallenge, Ticket, TicketResult};
+use crate::common_api_types::BasicRealmInfo;
use crate::{tfa::TfaDialog, RealmSelector};
use pwt_macros::builder;
+static OPENID_LOGIN: OnceLock<()> = OnceLock::new();
+
/// Proxmox login panel
///
/// Should support all proxmox product and TFA.
@@ -73,6 +81,9 @@ pub enum Msg {
Yubico(String),
RecoveryKey(String),
WebAuthn(String),
+ UpdateRealm(BasicRealmInfo),
+ OpenIDLogin,
+ OpenIDAuthentication(HashMap<String, String>),
}
pub struct ProxmoxLoginPanel {
@@ -83,6 +94,7 @@ pub struct ProxmoxLoginPanel {
save_username: PersistentState<bool>,
last_username: PersistentState<String>,
async_pool: AsyncPool,
+ selected_realm: Option<BasicRealmInfo>,
}
impl ProxmoxLoginPanel {
@@ -125,6 +137,117 @@ impl ProxmoxLoginPanel {
});
}
+ fn openid_redirect(&self, ctx: &Context<Self>) {
+ let link = ctx.link().clone();
+ let Some(realm) = self.selected_realm.as_ref() else {
+ return;
+ };
+ let Ok(location) = gloo_utils::window().location().origin() else {
+ return;
+ };
+
+ let data = serde_json::json!({
+ "realm": realm.realm,
+ "redirect-url": location,
+ });
+
+ self.async_pool.spawn(async move {
+ match crate::http_post::<String>("/access/openid/auth-url", Some(data)).await {
+ Ok(data) => {
+ let _ = gloo_utils::window().location().assign(&data);
+ }
+ Err(err) => {
+ link.send_message(Msg::LoginError(err.to_string()));
+ }
+ }
+ });
+ }
+
+ fn openid_parse_authentication(ctx: &Context<Self>, query_string: String) {
+ let mut auth = HashMap::new();
+ let query_parameters = query_string.split('&');
+
+ for param in query_parameters {
+ let mut key_value = param.split('=');
+
+ match (key_value.next(), key_value.next()) {
+ (Some("?code") | Some("code"), Some(value)) => {
+ auth.insert("code".to_string(), value.to_string());
+ }
+ (Some("?state") | Some("state"), Some(value)) => {
+ if let Ok(decoded) = percent_decode(value.as_bytes()).decode_utf8() {
+ auth.insert("state".to_string(), decoded.to_string());
+ }
+ }
+ _ => continue,
+ };
+ }
+
+ if auth.contains_key("code") && auth.contains_key("state") {
+ ctx.link().send_message(Msg::OpenIDAuthentication(auth));
+ }
+ }
+
+ fn openid_login(&self, ctx: &Context<Self>, mut auth: HashMap<String, String>) {
+ let link = ctx.link().clone();
+ let save_username = ctx.props().mobile || *self.save_username;
+ let Ok(origin) = gloo_utils::window().location().origin() else {
+ return;
+ };
+
+ auth.insert("redirect-url".into(), origin.clone());
+
+ let Ok(auth) = serde_json::to_value(auth) else {
+ return;
+ };
+
+ // run this only once, an openid state is only valid for one round trip. so resending it
+ // here will just fail. also use an unabortable future here for the same reason. otherwise
+ // we could be interrupted by, for example, the catalog loader needing to re-render the
+ // app.
+ OPENID_LOGIN.get_or_init(|| {
+ wasm_bindgen_futures::spawn_local(async move {
+ match crate::http_post::<CreateTicketResponse>("/access/openid/login", Some(auth))
+ .await
+ {
+ Ok(creds) => {
+ let Some(ticket) = creds
+ .ticket
+ .or(creds.ticket_info)
+ .and_then(|t| t.parse::<Ticket>().ok())
+ else {
+ log::error!("neither ticket nor ticket-info in openid login response!");
+ return;
+ };
+
+ let Some(csrfprevention_token) = creds.csrfprevention_token else {
+ log::error!("no CSRF prevention token in the openid login response!");
+ return;
+ };
+
+ let auth = Authentication {
+ api_url: "".to_string(),
+ userid: creds.username,
+ ticket,
+ clustername: None,
+ csrfprevention_token,
+ };
+
+ // update the authentication, set the realm and user for the next login and
+ // reload without the query parameters.
+ crate::http_set_auth(auth.clone());
+ if save_username {
+ PersistentState::<String>::new("ProxmoxLoginPanelUsername")
+ .update(auth.userid.clone());
+ }
+ let _ = gloo_utils::window().location().assign(&origin);
+ }
+ Err(err) => link.send_message(Msg::LoginError(err.to_string())),
+ }
+ });
+ });
+ }
+
fn get_defaults(&self, props: &LoginPanel) -> (String, Option<AttrValue>) {
let mut default_username = String::from("root");
let mut default_realm = props.default_realm.clone();
@@ -161,36 +284,64 @@ impl ProxmoxLoginPanel {
.on_webauthn(ctx.link().callback(Msg::WebAuthn))
});
- let form_panel = Column::new()
+ let mut form_panel = Column::new()
.class(pwt::css::FlexFit)
.padding(2)
- .with_flex_spacer()
- .with_child(
- FieldLabel::new(tr!("User name"))
- .id(username_label_id.clone())
- .padding_bottom(PwtSpace::Em(0.25)),
- )
- .with_child(
- Field::new()
- .name("username")
- .label_id(username_label_id)
- .default(default_username)
- .required(true)
- .autofocus(true),
- )
- .with_child(
- FieldLabel::new(tr!("Password"))
- .id(password_label_id.clone())
- .padding_top(1)
- .padding_bottom(PwtSpace::Em(0.25)),
- )
- .with_child(
- Field::new()
- .name("password")
- .label_id(password_label_id)
- .required(true)
- .input_type(InputType::Password),
- )
+ .with_flex_spacer();
+
+ if self
+ .selected_realm
+ .as_ref()
+ .map(|r| r.ty != "openid")
+ .unwrap_or(true)
+ {
+ form_panel = form_panel
+ .with_child(
+ FieldLabel::new(tr!("User name"))
+ .id(username_label_id.clone())
+ .padding_bottom(PwtSpace::Em(0.25)),
+ )
+ .with_child(
+ Field::new()
+ .name("username")
+ .label_id(username_label_id)
+ .default(default_username)
+ .required(true)
+ .autofocus(true),
+ )
+ .with_child(
+ FieldLabel::new(tr!("Password"))
+ .id(password_label_id.clone())
+ .padding_top(1)
+ .padding_bottom(PwtSpace::Em(0.25)),
+ )
+ .with_child(
+ Field::new()
+ .name("password")
+ .label_id(password_label_id)
+ .input_type(InputType::Password),
+ );
+ }
+
+ let submit_button = SubmitButton::new().class(ColorScheme::Primary).margin_y(4);
+
+ let submit_button = if self
+ .selected_realm
+ .as_ref()
+ .map(|r| r.ty == "openid")
+ .unwrap_or_default()
+ {
+ submit_button
+ .text(tr!("Login (OpenID redirect)"))
+ .check_dirty(false)
+ .on_submit(link.callback(move |_| Msg::OpenIDLogin))
+ } else {
+ submit_button
+ .text(tr!("Login"))
+ .on_submit(link.callback(move |_| Msg::Submit))
+ };
+
+ let form_panel = form_panel
.with_child(
FieldLabel::new(tr!("Realm"))
.id(realm_label_id.clone())
@@ -202,15 +353,13 @@ impl ProxmoxLoginPanel {
.name("realm")
.label_id(realm_label_id)
.path(props.domain_path.clone())
+ .on_change({
+ let link = link.clone();
+ move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r))
+ })
.default(default_realm),
)
- .with_child(
- SubmitButton::new()
- .class("pwt-scheme-primary")
- .margin_y(4)
- .text(tr!("Login"))
- .on_submit(link.callback(move |_| Msg::Submit)),
- )
+ .with_child(submit_button)
.with_optional_child(self.login_error.as_ref().map(|msg| {
let icon_class = classes!("fa-lg", "fa", "fa-align-center", "fa-exclamation-triangle");
let text = tr!("Login failed. Please try again ({0})", msg);
@@ -244,32 +393,46 @@ impl ProxmoxLoginPanel {
let (default_username, default_realm) = self.get_defaults(props);
- let input_panel = InputPanel::new()
+ let mut input_panel = InputPanel::new()
.class(pwt::css::Overflow::Auto)
.width("initial") // don't try to minimize size
- .padding(4)
- .with_field(
- tr!("User name"),
- Field::new()
- .name("username")
- .default(default_username)
- .required(true)
- .autofocus(true),
- )
- .with_field(
- tr!("Password"),
- Field::new()
- .name("password")
- .required(true)
- .input_type(InputType::Password),
- )
- .with_field(
- tr!("Realm"),
- RealmSelector::new()
- .name("realm")
- .path(props.domain_path.clone())
- .default(default_realm),
- );
+ .padding(4);
+
+ if self
+ .selected_realm
+ .as_ref()
+ .map(|r| r.ty != "openid")
+ .unwrap_or(true)
+ {
+ input_panel = input_panel
+ .with_field(
+ tr!("User name"),
+ Field::new()
+ .name("username")
+ .default(default_username)
+ .required(true)
+ .autofocus(true),
+ )
+ .with_field(
+ tr!("Password"),
+ Field::new()
+ .name("password")
+ .required(true)
+ .input_type(InputType::Password),
+ );
+ }
+
+ let input_panel = input_panel.with_field(
+ tr!("Realm"),
+ RealmSelector::new()
+ .name("realm")
+ .path(props.domain_path.clone())
+ .on_change({
+ let link = link.clone();
+ move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r))
+ })
+ .default(default_realm),
+ );
let tfa_dialog = self.challenge.as_ref().map(|challenge| {
TfaDialog::new(challenge.clone())
@@ -304,7 +467,18 @@ impl ProxmoxLoginPanel {
.with_child(
SubmitButton::new()
.class("pwt-scheme-primary")
- .text(tr!("Login"))
+ .text(
+ if self
+ .selected_realm
+ .as_ref()
+ .map(|r| r.ty == "openid")
+ .unwrap_or_default()
+ {
+ tr!("Login (OpenID redirect)")
+ } else {
+ tr!("Login")
+ },
+ )
.on_submit(link.callback(move |_| Msg::Submit)),
);
@@ -342,6 +516,11 @@ impl Component for ProxmoxLoginPanel {
let save_username = PersistentState::<bool>::new("ProxmoxLoginPanelSaveUsername");
let last_username = PersistentState::<String>::new("ProxmoxLoginPanelUsername");
+ let search = gloo_utils::window().location().search();
+ if let Ok(qs) = search {
+ Self::openid_parse_authentication(ctx, qs);
+ }
+
Self {
form_ctx,
loading: false,
@@ -350,6 +529,7 @@ impl Component for ProxmoxLoginPanel {
save_username,
last_username,
async_pool: AsyncPool::new(),
+ selected_realm: None,
}
}
@@ -483,6 +663,21 @@ impl Component for ProxmoxLoginPanel {
}
true
}
+ Msg::UpdateRealm(realm) => {
+ log::info!("realm type: {:?}", realm.ty);
+ self.selected_realm = Some(realm);
+ true
+ }
+ Msg::OpenIDLogin => {
+ self.loading = true;
+ self.openid_redirect(ctx);
+ false
+ }
+ Msg::OpenIDAuthentication(auth) => {
+ self.loading = true;
+ self.openid_login(ctx, auth);
+ false
+ }
}
}
diff --git a/src/realm_selector.rs b/src/realm_selector.rs
index b11e924..3e57f3a 100644
--- a/src/realm_selector.rs
+++ b/src/realm_selector.rs
@@ -1,4 +1,5 @@
use anyhow::{format_err, Error};
+use html::IntoEventCallback;
use std::rc::Rc;
use yew::html::IntoPropValue;
@@ -47,6 +48,11 @@ pub struct RealmSelector {
#[builder(IntoPropValue, into_prop_value)]
#[prop_or("/access/domains".into())]
pub path: AttrValue,
+
+ /// Change callback
+ #[builder_cb(IntoEventCallback, into_event_callback, BasicRealmInfo)]
+ #[prop_or_default]
+ pub on_change: Option<Callback<BasicRealmInfo>>,
}
impl Default for RealmSelector {
@@ -133,10 +139,15 @@ impl Component for ProxmoxRealmSelector {
.as_ref()
.and_then(|d| data.iter().find(|r| &r.realm == d))
.or_else(|| data.iter().find(|r| r.default.unwrap_or_default()))
- .or_else(|| data.iter().find(|r| r.ty == "pam"))
- .map(|r| AttrValue::from(r.realm.clone()));
+ .or_else(|| data.iter().find(|r| r.ty == "pam"));
- self.loaded_default_realm = realm;
+ if let Some(cb) = ctx.props().on_change.as_ref() {
+ if let Some(realm) = realm {
+ cb.emit(realm.clone());
+ }
+ }
+
+ self.loaded_default_realm = realm.map(|r| AttrValue::from(r.realm.clone()));
self.store.set_data(data);
true
}
@@ -155,12 +166,21 @@ impl Component for ProxmoxRealmSelector {
.or_else(|| self.loaded_default_realm.clone())
.unwrap_or(AttrValue::from("pam"));
+ let on_change = props.on_change.clone().map(|c| {
+ Callback::from(move |k| {
+ if let Some(realm) = store.read().lookup_record(&k) {
+ c.emit(realm.clone());
+ }
+ })
+ });
+
Selector::new(self.store.clone(), self.picker.clone())
.with_std_props(&props.std_props)
.with_input_props(&props.input_props)
.required(true)
.default(&default)
.validate(self.validate.clone())
+ .on_change(on_change)
// force re-render of the selector after load; returning `true` in update does not
// re-render the selector by itself
.key(format!("realm-selector-{default}"))
--
2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel
2025-10-08 15:19 [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 1/2] login_panel/realm_selector: use default realm provided by api Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins Shannon Sterz
@ 2025-10-09 9:56 ` Shannon Sterz
2025-10-14 13:32 ` [yew-devel] Superseded: " Shannon Sterz
3 siblings, 0 replies; 6+ messages in thread
From: Shannon Sterz @ 2025-10-09 9:56 UTC (permalink / raw)
To: Shannon Sterz, yew-devel
On Wed Oct 8, 2025 at 5:19 PM CEST, Shannon Sterz wrote:
> these patches add support for handling the default realm and openid
> login flow for the login panel.
>
> note that we already shipped the pve-yew-mobile-gui without support for
> openid login flows. so bumping that with this applied would be helpful
> for users that want to use the new mobile gui with an openid realm.
>
one small note about this: this patch series does not play as nicely
with the consent text implemented in the mobile gui as the desktop ui
does with its implementation. users would have to click the "OK" button
twice. once before starting the openid flow and a second time after
being redirected.
we could expose the helper that parses the search parameters in
`utils.rs`. then yew-mobile-gui could query the search parameters, if
they are there render the LoginPanel instead of showing the consent text
again. so more or less the same approach to the one we use in our
javascript front-ends. this would safe users from having to submit the
consent text twice.
also small note on that helper: while i could have used the url crate
[1] to parse the search parameters there, i already had the manual
approach outlined there implemented. since it saved me the hassle of
dealing with an extra `Result` i stuck to it. i can adapt this in a v2
if desired, though.
would still appreciate some feedback on the general approach used here.
thanks!
[1]: https://docs.rs/url/latest/url/struct.Url.html#method.query_pairs
> Shannon Sterz (2):
> login_panel/realm_selector: use default realm provided by api
> login_panel/realm_selector: add support for openid realm logins
>
> src/login_panel.rs | 327 +++++++++++++++++++++++++++++++++---------
> src/realm_selector.rs | 85 ++++++++++-
> 2 files changed, 340 insertions(+), 72 deletions(-)
>
> --
> 2.47.3
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
* Re: [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins Shannon Sterz
@ 2025-10-09 10:14 ` Shannon Sterz
0 siblings, 0 replies; 6+ messages in thread
From: Shannon Sterz @ 2025-10-09 10:14 UTC (permalink / raw)
To: Shannon Sterz, yew-devel
On Wed Oct 8, 2025 at 5:19 PM CEST, Shannon Sterz wrote:
> this commit adds support for the openid login flow. it also modifies
> the realm selector so that the currently selected realm can be
> communicated to the login panel, which can then only render the fields
> necessary for an openid realm.
>
> the future handling the openid login request is intentionally spawned
> with `wasm_bindgen_futures::spawn_local` so that it does not get
> aborted when the view is re-rendered by the CatalogueLoader. it is
> also wrapped in a OnceLock to avoid making the call several times.
>
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
> src/login_panel.rs | 317 ++++++++++++++++++++++++++++++++++--------
> src/realm_selector.rs | 26 +++-
> 2 files changed, 279 insertions(+), 64 deletions(-)
>
> diff --git a/src/login_panel.rs b/src/login_panel.rs
> index 6c3aaa7..6464bb5 100644
> --- a/src/login_panel.rs
> +++ b/src/login_panel.rs
> @@ -1,5 +1,10 @@
> +use std::collections::HashMap;
> use std::rc::Rc;
> +use std::sync::OnceLock;
>
> +use percent_encoding::percent_decode;
> +use proxmox_login::api::CreateTicketResponse;
> +use pwt::css::ColorScheme;
> use pwt::props::PwtSpace;
> use pwt::state::PersistentState;
> use pwt::touch::{SnackBar, SnackBarContextExt};
> @@ -11,12 +16,15 @@ use pwt::widget::form::{Checkbox, Field, Form, FormContext, InputType, ResetButt
> use pwt::widget::{Column, FieldLabel, InputPanel, LanguageSelector, Mask, Row};
> use pwt::{prelude::*, AsyncPool};
>
> -use proxmox_login::{Authentication, SecondFactorChallenge, TicketResult};
> +use proxmox_login::{Authentication, SecondFactorChallenge, Ticket, TicketResult};
>
> +use crate::common_api_types::BasicRealmInfo;
> use crate::{tfa::TfaDialog, RealmSelector};
>
> use pwt_macros::builder;
>
> +static OPENID_LOGIN: OnceLock<()> = OnceLock::new();
> +
> /// Proxmox login panel
> ///
> /// Should support all proxmox product and TFA.
> @@ -73,6 +81,9 @@ pub enum Msg {
> Yubico(String),
> RecoveryKey(String),
> WebAuthn(String),
> + UpdateRealm(BasicRealmInfo),
> + OpenIDLogin,
> + OpenIDAuthentication(HashMap<String, String>),
> }
>
> pub struct ProxmoxLoginPanel {
> @@ -83,6 +94,7 @@ pub struct ProxmoxLoginPanel {
> save_username: PersistentState<bool>,
> last_username: PersistentState<String>,
> async_pool: AsyncPool,
> + selected_realm: Option<BasicRealmInfo>,
> }
>
> impl ProxmoxLoginPanel {
> @@ -125,6 +137,117 @@ impl ProxmoxLoginPanel {
> });
> }
>
> + fn openid_redirect(&self, ctx: &Context<Self>) {
> + let link = ctx.link().clone();
> + let Some(realm) = self.selected_realm.as_ref() else {
> + return;
> + };
> + let Ok(location) = gloo_utils::window().location().origin() else {
> + return;
> + };
> +
> + let data = serde_json::json!({
> + "realm": realm.realm,
> + "redirect-url": location,
> + });
> +
> + self.async_pool.spawn(async move {
> + match crate::http_post::<String>("/access/openid/auth-url", Some(data)).await {
> + Ok(data) => {
> + let _ = gloo_utils::window().location().assign(&data);
> + }
> + Err(err) => {
> + link.send_message(Msg::LoginError(err.to_string()));
> + }
> + }
> + });
> + }
> +
> + fn openid_parse_authentication(ctx: &Context<Self>, query_string: String) {
> + let mut auth = HashMap::new();
> + let query_parameters = query_string.split('&');
> +
> + for param in query_parameters {
> + let mut key_value = param.split('=');
> +
> + match (key_value.next(), key_value.next()) {
> + (Some("?code") | Some("code"), Some(value)) => {
> + auth.insert("code".to_string(), value.to_string());
> + }
> + (Some("?state") | Some("state"), Some(value)) => {
> + if let Ok(decoded) = percent_decode(value.as_bytes()).decode_utf8() {
> + auth.insert("state".to_string(), decoded.to_string());
> + }
> + }
> + _ => continue,
> + };
> + }
> +
> + if auth.contains_key("code") && auth.contains_key("state") {
> + ctx.link().send_message(Msg::OpenIDAuthentication(auth));
> + }
> + }
> +
> + fn openid_login(&self, ctx: &Context<Self>, mut auth: HashMap<String, String>) {
> + let link = ctx.link().clone();
> + let save_username = ctx.props().mobile || *self.save_username;
> + let Ok(origin) = gloo_utils::window().location().origin() else {
> + return;
> + };
> +
> + auth.insert("redirect-url".into(), origin.clone());
> +
> + let Ok(auth) = serde_json::to_value(auth) else {
> + return;
> + };
> +
> + // run this only once, an openid state is only valid for one round trip. so resending it
> + // here will just fail. also use an unabortable future here for the same reason. otherwise
> + // we could be interrupted by, for example, the catalog loader needing to re-render the
> + // app.
> + OPENID_LOGIN.get_or_init(|| {
> + wasm_bindgen_futures::spawn_local(async move {
> + match crate::http_post::<CreateTicketResponse>("/access/openid/login", Some(auth))
> + .await
> + {
> + Ok(creds) => {
> + let Some(ticket) = creds
> + .ticket
> + .or(creds.ticket_info)
> + .and_then(|t| t.parse::<Ticket>().ok())
> + else {
> + log::error!("neither ticket nor ticket-info in openid login response!");
> + return;
> + };
> +
> + let Some(csrfprevention_token) = creds.csrfprevention_token else {
> + log::error!("no CSRF prevention token in the openid login response!");
> + return;
> + };
> +
> + let auth = Authentication {
> + api_url: "".to_string(),
> + userid: creds.username,
> + ticket,
> + clustername: None,
> + csrfprevention_token,
> + };
> +
> + // update the authentication, set the realm and user for the next login and
> + // reload without the query parameters.
> + crate::http_set_auth(auth.clone());
> + if save_username {
> + PersistentState::<String>::new("ProxmoxLoginPanelUsername")
> + .update(auth.userid.clone());
> + }
> + let _ = gloo_utils::window().location().assign(&origin);
> + }
> + Err(err) => link.send_message(Msg::LoginError(err.to_string())),
> + }
> + });
> + });
> + }
> +
> fn get_defaults(&self, props: &LoginPanel) -> (String, Option<AttrValue>) {
> let mut default_username = String::from("root");
> let mut default_realm = props.default_realm.clone();
> @@ -161,36 +284,64 @@ impl ProxmoxLoginPanel {
> .on_webauthn(ctx.link().callback(Msg::WebAuthn))
> });
>
> - let form_panel = Column::new()
> + let mut form_panel = Column::new()
> .class(pwt::css::FlexFit)
> .padding(2)
> - .with_flex_spacer()
> - .with_child(
> - FieldLabel::new(tr!("User name"))
> - .id(username_label_id.clone())
> - .padding_bottom(PwtSpace::Em(0.25)),
> - )
> - .with_child(
> - Field::new()
> - .name("username")
> - .label_id(username_label_id)
> - .default(default_username)
> - .required(true)
> - .autofocus(true),
> - )
> - .with_child(
> - FieldLabel::new(tr!("Password"))
> - .id(password_label_id.clone())
> - .padding_top(1)
> - .padding_bottom(PwtSpace::Em(0.25)),
> - )
> - .with_child(
> - Field::new()
> - .name("password")
> - .label_id(password_label_id)
> - .required(true)
> - .input_type(InputType::Password),
> - )
> + .with_flex_spacer();
> +
> + if self
> + .selected_realm
> + .as_ref()
> + .map(|r| r.ty != "openid")
> + .unwrap_or(true)
> + {
> + form_panel = form_panel
> + .with_child(
> + FieldLabel::new(tr!("User name"))
> + .id(username_label_id.clone())
> + .padding_bottom(PwtSpace::Em(0.25)),
> + )
> + .with_child(
> + Field::new()
> + .name("username")
> + .label_id(username_label_id)
> + .default(default_username)
> + .required(true)
> + .autofocus(true),
> + )
> + .with_child(
> + FieldLabel::new(tr!("Password"))
> + .id(password_label_id.clone())
> + .padding_top(1)
> + .padding_bottom(PwtSpace::Em(0.25)),
> + )
> + .with_child(
> + Field::new()
> + .name("password")
> + .label_id(password_label_id)
> + .input_type(InputType::Password),
> + );
> + }
> +
> + let submit_button = SubmitButton::new().class(ColorScheme::Primary).margin_y(4);
> +
> + let submit_button = if self
> + .selected_realm
> + .as_ref()
> + .map(|r| r.ty == "openid")
> + .unwrap_or_default()
> + {
> + submit_button
> + .text(tr!("Login (OpenID redirect)"))
> + .check_dirty(false)
> + .on_submit(link.callback(move |_| Msg::OpenIDLogin))
> + } else {
> + submit_button
> + .text(tr!("Login"))
> + .on_submit(link.callback(move |_| Msg::Submit))
> + };
> +
> + let form_panel = form_panel
> .with_child(
> FieldLabel::new(tr!("Realm"))
> .id(realm_label_id.clone())
> @@ -202,15 +353,13 @@ impl ProxmoxLoginPanel {
> .name("realm")
> .label_id(realm_label_id)
> .path(props.domain_path.clone())
> + .on_change({
> + let link = link.clone();
> + move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r))
> + })
> .default(default_realm),
> )
> - .with_child(
> - SubmitButton::new()
> - .class("pwt-scheme-primary")
> - .margin_y(4)
> - .text(tr!("Login"))
> - .on_submit(link.callback(move |_| Msg::Submit)),
> - )
> + .with_child(submit_button)
> .with_optional_child(self.login_error.as_ref().map(|msg| {
> let icon_class = classes!("fa-lg", "fa", "fa-align-center", "fa-exclamation-triangle");
> let text = tr!("Login failed. Please try again ({0})", msg);
> @@ -244,32 +393,46 @@ impl ProxmoxLoginPanel {
>
> let (default_username, default_realm) = self.get_defaults(props);
>
> - let input_panel = InputPanel::new()
> + let mut input_panel = InputPanel::new()
> .class(pwt::css::Overflow::Auto)
> .width("initial") // don't try to minimize size
> - .padding(4)
> - .with_field(
> - tr!("User name"),
> - Field::new()
> - .name("username")
> - .default(default_username)
> - .required(true)
> - .autofocus(true),
> - )
> - .with_field(
> - tr!("Password"),
> - Field::new()
> - .name("password")
> - .required(true)
> - .input_type(InputType::Password),
> - )
> - .with_field(
> - tr!("Realm"),
> - RealmSelector::new()
> - .name("realm")
> - .path(props.domain_path.clone())
> - .default(default_realm),
> - );
> + .padding(4);
> +
> + if self
> + .selected_realm
> + .as_ref()
> + .map(|r| r.ty != "openid")
> + .unwrap_or(true)
> + {
> + input_panel = input_panel
> + .with_field(
> + tr!("User name"),
> + Field::new()
> + .name("username")
> + .default(default_username)
> + .required(true)
> + .autofocus(true),
> + )
> + .with_field(
> + tr!("Password"),
> + Field::new()
> + .name("password")
> + .required(true)
> + .input_type(InputType::Password),
> + );
> + }
> +
> + let input_panel = input_panel.with_field(
> + tr!("Realm"),
> + RealmSelector::new()
> + .name("realm")
> + .path(props.domain_path.clone())
> + .on_change({
> + let link = link.clone();
> + move |r: BasicRealmInfo| link.send_message(Msg::UpdateRealm(r))
> + })
> + .default(default_realm),
> + );
>
> let tfa_dialog = self.challenge.as_ref().map(|challenge| {
> TfaDialog::new(challenge.clone())
> @@ -304,7 +467,18 @@ impl ProxmoxLoginPanel {
> .with_child(
> SubmitButton::new()
> .class("pwt-scheme-primary")
> - .text(tr!("Login"))
> + .text(
> + if self
> + .selected_realm
> + .as_ref()
> + .map(|r| r.ty == "openid")
> + .unwrap_or_default()
> + {
> + tr!("Login (OpenID redirect)")
> + } else {
> + tr!("Login")
> + },
> + )
> .on_submit(link.callback(move |_| Msg::Submit)),
> );
>
> @@ -342,6 +516,11 @@ impl Component for ProxmoxLoginPanel {
> let save_username = PersistentState::<bool>::new("ProxmoxLoginPanelSaveUsername");
> let last_username = PersistentState::<String>::new("ProxmoxLoginPanelUsername");
>
> + let search = gloo_utils::window().location().search();
> + if let Ok(qs) = search {
> + Self::openid_parse_authentication(ctx, qs);
> + }
> +
> Self {
> form_ctx,
> loading: false,
> @@ -350,6 +529,7 @@ impl Component for ProxmoxLoginPanel {
> save_username,
> last_username,
> async_pool: AsyncPool::new(),
> + selected_realm: None,
> }
> }
>
> @@ -483,6 +663,21 @@ impl Component for ProxmoxLoginPanel {
> }
> true
> }
> + Msg::UpdateRealm(realm) => {
> + log::info!("realm type: {:?}", realm.ty);
just noticed that this log statement snuck in. sorry for that, will be
droppedin a v2.
> + self.selected_realm = Some(realm);
> + true
> + }
> + Msg::OpenIDLogin => {
> + self.loading = true;
> + self.openid_redirect(ctx);
> + false
> + }
> + Msg::OpenIDAuthentication(auth) => {
> + self.loading = true;
> + self.openid_login(ctx, auth);
> + false
> + }
> }
> }
>
> diff --git a/src/realm_selector.rs b/src/realm_selector.rs
> index b11e924..3e57f3a 100644
> --- a/src/realm_selector.rs
> +++ b/src/realm_selector.rs
> @@ -1,4 +1,5 @@
> use anyhow::{format_err, Error};
> +use html::IntoEventCallback;
> use std::rc::Rc;
>
> use yew::html::IntoPropValue;
> @@ -47,6 +48,11 @@ pub struct RealmSelector {
> #[builder(IntoPropValue, into_prop_value)]
> #[prop_or("/access/domains".into())]
> pub path: AttrValue,
> +
> + /// Change callback
> + #[builder_cb(IntoEventCallback, into_event_callback, BasicRealmInfo)]
> + #[prop_or_default]
> + pub on_change: Option<Callback<BasicRealmInfo>>,
> }
>
> impl Default for RealmSelector {
> @@ -133,10 +139,15 @@ impl Component for ProxmoxRealmSelector {
> .as_ref()
> .and_then(|d| data.iter().find(|r| &r.realm == d))
> .or_else(|| data.iter().find(|r| r.default.unwrap_or_default()))
> - .or_else(|| data.iter().find(|r| r.ty == "pam"))
> - .map(|r| AttrValue::from(r.realm.clone()));
> + .or_else(|| data.iter().find(|r| r.ty == "pam"));
>
> - self.loaded_default_realm = realm;
> + if let Some(cb) = ctx.props().on_change.as_ref() {
> + if let Some(realm) = realm {
> + cb.emit(realm.clone());
> + }
> + }
> +
> + self.loaded_default_realm = realm.map(|r| AttrValue::from(r.realm.clone()));
> self.store.set_data(data);
> true
> }
> @@ -155,12 +166,21 @@ impl Component for ProxmoxRealmSelector {
> .or_else(|| self.loaded_default_realm.clone())
> .unwrap_or(AttrValue::from("pam"));
>
> + let on_change = props.on_change.clone().map(|c| {
> + Callback::from(move |k| {
> + if let Some(realm) = store.read().lookup_record(&k) {
> + c.emit(realm.clone());
> + }
> + })
> + });
> +
> Selector::new(self.store.clone(), self.picker.clone())
> .with_std_props(&props.std_props)
> .with_input_props(&props.input_props)
> .required(true)
> .default(&default)
> .validate(self.validate.clone())
> + .on_change(on_change)
> // force re-render of the selector after load; returning `true` in update does not
> // re-render the selector by itself
> .key(format!("realm-selector-{default}"))
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
* [yew-devel] Superseded: Re: [PATCH yew-comp 0/2] openid and default realm support in login panel
2025-10-08 15:19 [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
` (2 preceding siblings ...)
2025-10-09 9:56 ` [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
@ 2025-10-14 13:32 ` Shannon Sterz
3 siblings, 0 replies; 6+ messages in thread
From: Shannon Sterz @ 2025-10-14 13:32 UTC (permalink / raw)
To: Shannon Sterz; +Cc: yew-devel
On Wed Oct 8, 2025 at 5:19 PM CEST, Shannon Sterz wrote:
> these patches add support for handling the default realm and openid
> login flow for the login panel.
>
> note that we already shipped the pve-yew-mobile-gui without support for
> openid login flows. so bumping that with this applied would be helpful
> for users that want to use the new mobile gui with an openid realm.
>
> Shannon Sterz (2):
> login_panel/realm_selector: use default realm provided by api
> login_panel/realm_selector: add support for openid realm logins
>
> src/login_panel.rs | 327 +++++++++++++++++++++++++++++++++---------
> src/realm_selector.rs | 85 ++++++++++-
> 2 files changed, 340 insertions(+), 72 deletions(-)
>
> --
> 2.47.3
Superseded-by: https://lore.proxmox.com/all/20251014133044.337162-1-s.sterz@proxmox.com/
_______________________________________________
yew-devel mailing list
yew-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/yew-devel
^ permalink raw reply [flat|nested] 6+ messages in thread
end of thread, other threads:[~2025-10-14 13:31 UTC | newest]
Thread overview: 6+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-10-08 15:19 [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 1/2] login_panel/realm_selector: use default realm provided by api Shannon Sterz
2025-10-08 15:19 ` [yew-devel] [PATCH yew-comp 2/2] login_panel/realm_selector: add support for openid realm logins Shannon Sterz
2025-10-09 10:14 ` Shannon Sterz
2025-10-09 9:56 ` [yew-devel] [PATCH yew-comp 0/2] openid and default realm support in login panel Shannon Sterz
2025-10-14 13:32 ` [yew-devel] Superseded: " Shannon Sterz
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.