* [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 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
* 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
* [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.