Fixup email submission, tested

This commit is contained in:
ToxicMushroom 2025-04-05 17:22:07 +02:00
parent bb0e759134
commit 339a20947a
No known key found for this signature in database
7 changed files with 114 additions and 57 deletions

2
Cargo.lock generated
View file

@ -5144,7 +5144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"indexmap 2.7.1", "indexmap 2.8.0",
"itoa", "itoa",
"ryu", "ryu",
"serde", "serde",

View file

@ -132,7 +132,7 @@ pub fn view_router() -> Router<ServerState> {
.route("/api/cu_commit", post(reset::commit)) .route("/api/cu_commit", post(reset::commit))
.route( .route(
"/api/user_settings/add_email", "/api/user_settings/add_email",
post(profile::view_new_email_entry_partial), get(profile::view_new_email_entry_partial),
) )
.route( .route(
"/api/user_settings/edit_profile", "/api/user_settings/edit_profile",

View file

@ -1,4 +1,3 @@
use kanidm_proto::attribute::Attribute;
use super::constants::{ProfileMenuItems, Urls}; use super::constants::{ProfileMenuItems, Urls};
use super::errors::HtmxError; use super::errors::HtmxError;
use super::login::{LoginDisplayCtx, Reauth, ReauthPurpose}; use super::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
@ -9,7 +8,7 @@ use crate::https::middleware::KOpId;
use crate::https::ServerState; use crate::https::ServerState;
use askama::Template; use askama::Template;
use askama_axum::IntoResponse; use askama_axum::IntoResponse;
use axum::extract::State; use axum::extract::{Query, State};
use axum::http::Uri; use axum::http::Uri;
use axum::response::Response; use axum::response::Response;
use axum::Extension; use axum::Extension;
@ -17,9 +16,11 @@ use axum_extra::extract::cookie::CookieJar;
use axum_extra::extract::Form; use axum_extra::extract::Form;
use axum_htmx::{HxEvent, HxPushUrl, HxResponseTrigger}; use axum_htmx::{HxEvent, HxPushUrl, HxResponseTrigger};
use futures_util::TryFutureExt; use futures_util::TryFutureExt;
use kanidm_proto::attribute::Attribute;
use kanidm_proto::constants::{ATTR_DISPLAYNAME, ATTR_MAIL}; use kanidm_proto::constants::{ATTR_DISPLAYNAME, ATTR_MAIL};
use kanidm_proto::internal::UserAuthToken; use kanidm_proto::internal::UserAuthToken;
use kanidm_proto::scim_v1::server::{ScimEffectiveAccess, ScimPerson}; use kanidm_proto::scim_v1::server::{ScimEffectiveAccess, ScimPerson};
use kanidm_proto::scim_v1::ScimMail;
use kanidmd_lib::filter::{f_id, Filter}; use kanidmd_lib::filter::{f_id, Filter};
use kanidmd_lib::prelude::f_and; use kanidmd_lib::prelude::f_and;
use kanidmd_lib::prelude::FC; use kanidmd_lib::prelude::FC;
@ -46,17 +47,26 @@ struct ProfilePartialView {
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ProfileAttributes { pub(crate) struct SaveProfileQuery {
#[serde(rename = "name")] #[serde(rename = "name")]
account_name: String, account_name: String,
#[serde(rename = "displayname")] #[serde(rename = "displayname")]
display_name: String, display_name: String,
#[serde(rename = "email_index")]
emails_indexes: Vec<u16>,
#[serde(rename = "emails[]")] #[serde(rename = "emails[]")]
emails: Vec<String>, emails: Vec<String>,
// radio buttons are used to pick a primary index // radio buttons are used to pick a primary index, remove causes holes, map back into [emails] using [emails_indexes]
primary_email_index: u16, primary_email_index: u16,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ProfileAttributes {
account_name: String,
display_name: String,
emails: Vec<ScimMail>,
}
#[derive(Template, Clone)] #[derive(Template, Clone)]
#[template(path = "user_settings/profile_changes_partial.html")] #[template(path = "user_settings/profile_changes_partial.html")]
struct ProfileChangesPartialView { struct ProfileChangesPartialView {
@ -67,14 +77,12 @@ struct ProfileChangesPartialView {
} }
#[derive(Template, Clone)] #[derive(Template, Clone)]
#[template(path = "user_settings/form_modifiable_entry_modifiable_list_partial.html")] #[template(path = "user_settings/form_email_entry_partial.html")]
// Modifiable entry in a modifiable list partial pub(crate) struct FormEmailEntryListPartial {
pub(crate) struct FormModEntryModListPartial { can_edit: bool,
can_rw: bool,
r#type: String,
name: String,
value: String, value: String,
invalid_feedback: String, primary: bool,
index: u16,
} }
impl Display for ProfileAttributes { impl Display for ProfileAttributes {
@ -94,12 +102,14 @@ pub(crate) async fn view_profile_get(
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid) .handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
.await?; .await?;
let (scim_person, scim_effective_access) = crate::https::views::admin::persons::get_person_info( let (scim_person, scim_effective_access) =
uat.uuid, crate::https::views::admin::persons::get_person_info(
state, uat.uuid,
&kopid, state,
client_auth_info.clone()).await?; &kopid,
client_auth_info.clone(),
)
.await?;
let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0); let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
@ -112,7 +122,7 @@ pub(crate) async fn view_profile_get(
menu_active_item: ProfileMenuItems::UserProfile, menu_active_item: ProfileMenuItems::UserProfile,
can_rw, can_rw,
person: scim_person, person: scim_person,
scim_effective_access scim_effective_access,
}, },
}) })
} }
@ -123,7 +133,7 @@ pub(crate) async fn view_profile_diff_start_save_post(
VerifiedClientInformation(client_auth_info): VerifiedClientInformation, VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo, DomainInfo(domain_info): DomainInfo,
// Form must be the last parameter because it consumes the request body // Form must be the last parameter because it consumes the request body
Form(new_attrs): Form<ProfileAttributes>, Form(query): Form<SaveProfileQuery>,
) -> axum::response::Result<Response> { ) -> axum::response::Result<Response> {
let uat: UserAuthToken = state let uat: UserAuthToken = state
.qe_r_ref .qe_r_ref
@ -138,13 +148,34 @@ pub(crate) async fn view_profile_diff_start_save_post(
uat.uuid, uat.uuid,
state, state,
&kopid, &kopid,
client_auth_info.clone()).await?; client_auth_info.clone(),
)
.await?;
let primary_index = query
.emails_indexes
.iter()
.position(|ei| ei == &query.primary_email_index)
.unwrap_or(0);
let new_mails = query
.emails
.iter()
.enumerate()
.map(|(ei, email)| ScimMail {
primary: ei == primary_index,
value: email.to_string(),
})
.collect();
let profile_view = ProfileChangesPartialView { let profile_view = ProfileChangesPartialView {
menu_active_item: ProfileMenuItems::UserProfile, menu_active_item: ProfileMenuItems::UserProfile,
can_rw, can_rw,
person: scim_person, person: scim_person,
new_attrs new_attrs: ProfileAttributes {
account_name: query.account_name,
display_name: query.display_name,
emails: new_mails,
},
}; };
Ok(( Ok((
@ -160,7 +191,7 @@ pub(crate) async fn view_profile_diff_confirm_save_post(
VerifiedClientInformation(client_auth_info): VerifiedClientInformation, VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
DomainInfo(domain_info): DomainInfo, DomainInfo(domain_info): DomainInfo,
// Form must be the last parameter because it consumes the request body // Form must be the last parameter because it consumes the request body
Form(new_attrs): Form<ProfileAttributes>, Form(mut new_attrs): Form<ProfileAttributes>,
) -> axum::response::Result<Response> { ) -> axum::response::Result<Response> {
let uat: UserAuthToken = state let uat: UserAuthToken = state
.qe_r_ref .qe_r_ref
@ -184,13 +215,17 @@ pub(crate) async fn view_profile_diff_confirm_save_post(
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone())) .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
.await?; .await?;
new_attrs
.emails
.sort_by_key(|sm| if sm.primary { 0 } else { 1 });
let email_addresses = new_attrs.emails.into_iter().map(|sm| sm.value).collect();
state state
.qe_w_ref .qe_w_ref
.handle_setattribute( .handle_setattribute(
client_auth_info.clone(), client_auth_info.clone(),
uat.uuid.to_string(), uat.uuid.to_string(),
ATTR_MAIL.to_string(), ATTR_MAIL.to_string(),
new_attrs.emails, email_addresses,
filter.clone(), filter.clone(),
kopid.eventid, kopid.eventid,
) )
@ -229,29 +264,37 @@ pub(crate) async fn view_profile_diff_confirm_save_post(
State(state), State(state),
Extension(kopid), Extension(kopid),
VerifiedClientInformation(client_auth_info), VerifiedClientInformation(client_auth_info),
DomainInfo(domain_info) DomainInfo(domain_info),
).await { )
.await
{
Ok(pv) => Ok(pv.into_response()), Ok(pv) => Ok(pv.into_response()),
Err(e) => Ok(e.into_response()), Err(e) => Ok(e.into_response()),
} }
} }
#[derive(Deserialize)]
pub(crate) struct AddEmailQuery {
// the last email index is passed so we can return an incremented id
email_index: Option<u16>,
}
// Sends the user a new email input to fill in :) // Sends the user a new email input to fill in :)
pub(crate) async fn view_new_email_entry_partial( pub(crate) async fn view_new_email_entry_partial(
State(_state): State<ServerState>, State(_state): State<ServerState>,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation, VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
Extension(_kopid): Extension<KOpId>, Extension(_kopid): Extension<KOpId>,
Query(email_query): Query<AddEmailQuery>,
) -> axum::response::Result<Response> { ) -> axum::response::Result<Response> {
let add_email_trigger = let add_email_trigger =
HxResponseTrigger::after_swap([HxEvent::new("addEmailSwapped".to_string())]); HxResponseTrigger::after_swap([HxEvent::new("addEmailSwapped".to_string())]);
Ok(( Ok((
add_email_trigger, add_email_trigger,
FormModEntryModListPartial { FormEmailEntryListPartial {
can_rw: true, can_edit: true,
r#type: "email".to_string(),
name: "emails[]".to_string(),
value: "".to_string(), value: "".to_string(),
invalid_feedback: "Please enter a valid email address.".to_string(), primary: email_query.email_index.is_none(),
index: email_query.email_index.map(|i| i + 1).unwrap_or(0),
}, },
) )
.into_response()) .into_response())

View file

@ -6,9 +6,9 @@ function rehook_string_list_removers() {
if (button.getAttribute("kanidm_hooked") !== null) continue if (button.getAttribute("kanidm_hooked") !== null) continue
button.addEventListener("click", (e) => { button.addEventListener("click", (e) => {
// Expected html nesting: li > div.input-group > button.kanidm-remove-list-entry // Expected html nesting: div.email-entry > div.input-group > button.kanidm-remove-list-entry
let li = button.parentElement?.parentElement; let li = button.parentElement?.parentElement;
if (li && li.tagName === "LI") { if (li && li.classList.contains("email-entry")) {
li.remove(); li.remove();
} }
}) })

View file

@ -0,0 +1,17 @@
<div class="email-entry">
<input hidden class="email-index-state" type="text" name="email_index" value="((index))">
<div class="input-group mb-1">
<div class="input-group-text">
<input class="form-check-input mt-0" name="primary_email_index" type="radio" value="((index))" aria-label="Primary email radio button for following text input" (% if primary %) checked (% endif %)(% if !can_edit %) disabled (% endif %)>
</div>
(% if can_edit %)
<input type="email" aria-label="Email address input ((index))" class="form-control" name="emails[]" value="(( value ))" hx-validate="true" required>
<button type="button" class="btn btn-secondary kanidm-remove-list-entry">Remove</button>
(% else %)
<input type="email" aria-label="Email address input ((index))" class="form-control" name="emails[]" value="(( value ))" hx-validate="true" required disabled>
(% endif %)
</div>
<div class="invalid-feedback">Please enter a valid email address.</div>
</div>

View file

@ -13,7 +13,7 @@ Profile Difference
<input type="hidden" name="display_name" value="(( new_attrs.display_name ))"/> <input type="hidden" name="display_name" value="(( new_attrs.display_name ))"/>
<!-- <input type="hidden" name="legal_name" value=" new_attrs.legal_name "/>--> <!-- <input type="hidden" name="legal_name" value=" new_attrs.legal_name "/>-->
(% for email in new_attrs.emails %) (% for email in new_attrs.emails %)
<input type="hidden" name="emails[]" value="(( email ))"/> <input type="hidden" name="emails[]" value="(( email.value ))"/>
(% endfor %) (% endfor %)
<table class="table table-bordered table-responsive"> <table class="table table-bordered table-responsive">
@ -54,7 +54,7 @@ Profile Difference
<td class="text-break"> <td class="text-break">
<ul> <ul>
(% for email in new_attrs.emails %) (% for email in new_attrs.emails %)
<li>(( email ))</li> <li>(( email.value ))</li>
(% endfor %) (% endfor %)
</ul> </ul>
</td> </td>

View file

@ -21,17 +21,26 @@ Profile
(% block settings_window %) (% block settings_window %)
<form id="user_settings_container" class="needs-validation" hx-post="/ui/api/user_settings/edit_profile" <form id="user_settings_container" class="needs-validation"
hx-target="#user_settings_container" hx-swap="outerHTML" hx-validate="true" hx-ext="bs-validation" novalidate> hx-post="/ui/api/user_settings/edit_profile"
hx-target="#user_settings_container"
hx-swap="outerHTML"
hx-validate="true"
hx-ext="bs-validation"
novalidate>
(% call string_attr("Name", "name", person.name, true, Attribute::Name) %) (% call string_attr("Name", "name", person.name, true, Attribute::Name) %)
(% call string_attr("Displayname", "displayname", person.displayname, true, Attribute::DisplayName) %) (% call string_attr("Displayname", "displayname", person.displayname, true, Attribute::DisplayName) %)
<div class="mb-2"> <div class="mb-2">
<div class="mt-3 mb-2 col-12 col-md-11 col-lg-8"> <div class="mt-3 mb-2 col-12 col-md-11 col-lg-8">
<label for="profileEmail" class="fw-bold">Email addresses (select primary)</label> <label for="profileEmail" class="fw-bold">Email addresses (% if can_rw %)(selected => primary)(% else %)(select primary)(% endif %)</label>
(% if can_rw %) (% if can_rw %)
<a class="cursor-pointer float-end" hx-boost="true" hx-post="/ui/api/user_settings/add_email" hx-target="#emailAddresses" <a class="cursor-pointer float-end"
hx-boost="true"
hx-get="/ui/api/user_settings/add_email"
hx-target="#emailAddresses"
hx-include="#emailAddresses :last-child .email-index-state"
hx-swap="beforeend"> hx-swap="beforeend">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-plus-square" <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-plus-square"
viewBox="0 0 16 16" width="20" height="20"> viewBox="0 0 16 16" width="20" height="20">
@ -39,29 +48,17 @@ Profile
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"></path> <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"></path>
</svg> </svg>
</a> </a>
(% endif %)
</div> </div>
(% endif %)
<div> <div>
<div class="row g-0"> <div class="row g-0">
<div class="col-12 col-md-11 col-lg-8" id="emailAddresses"> <div class="col-12 col-md-11 col-lg-8" id="emailAddresses">
(% for (i, email) in person.mails.iter().enumerate() %) (% for (index, email) in person.mails.iter().enumerate() %)
(% let type = "email" %)
(% let name = "emails[]" %)
(% let value = email.value.clone() %) (% let value = email.value.clone() %)
(% let invalid_feedback = "Please enter a valid email address." %) (% let primary = email.primary %)
(% let can_edit = scim_effective_access.modify_present.check(Attribute::Mail|as_ref) %)
<div class="input-group mb-1"> (% include "user_settings/form_email_entry_partial.html" %)
<div class="input-group-text">
<input class="form-check-input mt-0" name="primary_email_index" type="radio" value="((i))" aria-label="Primary email radio button for following text input">
</div>
<input type="(( type ))" class="form-control" name="(( name ))" value="(( value ))" hx-validate="true" required>
(% if can_rw %)
<button type="button" class="btn btn-secondary kanidm-remove-list-entry">Remove</button>
(% endif %)
</div>
<div class="invalid-feedback">(( invalid_feedback ))</div>
(% endfor %) (% endfor %)
</div> </div>