kanidmd_core/https/views/
profile.rs
1use super::constants::{ProfileMenuItems, Urls};
2use super::errors::HtmxError;
3use super::navbar::NavbarCtx;
4use crate::https::errors::WebError;
5use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
6use crate::https::middleware::KOpId;
7use crate::https::ServerState;
8use askama::Template;
9use askama_axum::IntoResponse;
10use axum::extract::{Query, State};
11use axum::http::Uri;
12use axum::response::{Redirect, Response};
13use axum::Extension;
14use axum_extra::extract::Form;
15use axum_htmx::{HxEvent, HxPushUrl, HxResponseTrigger};
16use futures_util::TryFutureExt;
17use kanidm_proto::attribute::Attribute;
18use kanidm_proto::internal::{OperationError, UserAuthToken};
19use kanidm_proto::scim_v1::client::ScimEntryPutKanidm;
20use kanidm_proto::scim_v1::server::{ScimEffectiveAccess, ScimPerson, ScimValueKanidm};
21use kanidm_proto::scim_v1::ScimMail;
22use serde::Deserialize;
23use serde::Serialize;
24use std::collections::BTreeMap;
25use std::fmt;
26use std::fmt::Display;
27use std::fmt::Formatter;
28
29#[derive(Template)]
30#[template(path = "user_settings.html")]
31pub(crate) struct ProfileView {
32 navbar_ctx: NavbarCtx,
33 profile_partial: ProfilePartialView,
34}
35
36#[derive(Template, Clone)]
37#[template(path = "user_settings_profile_partial.html")]
38struct ProfilePartialView {
39 menu_active_item: ProfileMenuItems,
40 can_rw: bool,
41 person: ScimPerson,
42 scim_effective_access: ScimEffectiveAccess,
43}
44
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub(crate) struct SaveProfileQuery {
47 #[serde(rename = "name")]
48 account_name: String,
49 #[serde(rename = "displayname")]
50 display_name: String,
51 #[serde(rename = "email_index")]
52 emails_indexes: Option<Vec<u16>>,
53 #[serde(rename = "emails[]")]
54 emails: Option<Vec<String>>,
55 primary_email_index: Option<u16>,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub(crate) struct CommitSaveProfileQuery {
61 #[serde(rename = "account_name")]
62 account_name: Option<String>,
63 #[serde(rename = "display_name")]
64 display_name: Option<String>,
65 #[serde(rename = "emails[]")]
66 emails: Option<Vec<String>>,
67 #[serde(rename = "new_primary_mail")]
68 new_primary_mail: Option<String>,
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub(crate) struct ProfileAttributes {
73 account_name: String,
74 display_name: String,
75 emails: Vec<ScimMail>,
76}
77
78#[derive(Template, Clone)]
79#[template(path = "user_settings/profile_changes_partial.html")]
80struct ProfileChangesPartialView {
81 menu_active_item: ProfileMenuItems,
82 can_rw: bool,
83 person: ScimPerson,
84 primary_mail: Option<String>,
85 new_attrs: ProfileAttributes,
86 new_primary_mail: Option<String>,
87 emails_are_same: bool,
88}
89
90#[derive(Template, Clone)]
91#[template(path = "user_settings/form_email_entry_partial.html")]
92pub(crate) struct FormEmailEntryListPartial {
93 can_edit: bool,
94 value: String,
95 primary: bool,
96 index: u16,
97}
98
99impl Display for ProfileAttributes {
100 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
101 write!(f, "{self:?}")
102 }
103}
104
105pub(crate) async fn view_profile_get(
106 State(state): State<ServerState>,
107 Extension(kopid): Extension<KOpId>,
108 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
109 DomainInfo(domain_info): DomainInfo,
110) -> Result<Response, WebError> {
111 let uat: &UserAuthToken = client_auth_info.pre_validated_uat()?;
112
113 let (scim_person, scim_effective_access) =
114 crate::https::views::admin::persons::get_person_info(
115 uat.uuid,
116 state,
117 &kopid,
118 client_auth_info.clone(),
119 )
120 .await?;
121
122 let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
123
124 let can_rw = uat.purpose_readwrite_active(time);
125
126 let rehook_email_removal_buttons =
127 HxResponseTrigger::after_swap([HxEvent::new("addEmailSwapped".to_string())]);
128 Ok((
129 rehook_email_removal_buttons,
130 HxPushUrl(Uri::from_static("/ui/profile")),
131 ProfileView {
132 navbar_ctx: NavbarCtx { domain_info },
133 profile_partial: ProfilePartialView {
134 menu_active_item: ProfileMenuItems::UserProfile,
135 can_rw,
136 person: scim_person,
137 scim_effective_access,
138 },
139 },
140 )
141 .into_response())
142}
143
144pub(crate) async fn view_profile_diff_start_save_post(
145 State(state): State<ServerState>,
146 Extension(kopid): Extension<KOpId>,
147 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
148 DomainInfo(domain_info): DomainInfo,
149 Form(query): Form<SaveProfileQuery>,
151) -> axum::response::Result<Response> {
152 let uat: &UserAuthToken = client_auth_info
153 .pre_validated_uat()
154 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
155
156 let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
157 let can_rw = uat.purpose_readwrite_active(time);
158
159 let (scim_person, _) = crate::https::views::admin::persons::get_person_info(
160 uat.uuid,
161 state,
162 &kopid,
163 client_auth_info.clone(),
164 )
165 .await?;
166
167 let (new_emails, emails_are_same) =
168 if let (Some(email_indices), Some(emails)) = (query.emails_indexes, query.emails) {
169 let primary_index = query.primary_email_index.unwrap_or(0);
170
171 let primary_index = email_indices
172 .iter()
173 .position(|ei| ei == &primary_index)
174 .unwrap_or(0);
175 let new_mails = emails
176 .iter()
177 .enumerate()
178 .map(|(ei, email)| ScimMail {
179 primary: ei == primary_index,
180 value: email.to_string(),
181 })
182 .collect();
183
184 let emails_are_same = scim_person.mails == new_mails;
185
186 (new_mails, emails_are_same)
187 } else {
188 (vec![], true)
189 };
190
191 let primary_mail = scim_person
192 .mails
193 .iter()
194 .find(|sm| sm.primary)
195 .map(|sm| sm.value.clone());
196
197 let new_primary_mail = new_emails
198 .iter()
199 .find(|sm| sm.primary)
200 .map(|sm| sm.value.clone());
201
202 let profile_view = ProfileChangesPartialView {
203 menu_active_item: ProfileMenuItems::UserProfile,
204 can_rw,
205 person: scim_person,
206 primary_mail,
207 new_attrs: ProfileAttributes {
208 account_name: query.account_name,
209 display_name: query.display_name,
210 emails: new_emails,
211 },
212 new_primary_mail,
213 emails_are_same,
214 };
215
216 Ok((
217 HxPushUrl(Uri::from_static("/ui/profile/diff")),
218 profile_view,
219 )
220 .into_response())
221}
222
223pub(crate) async fn view_profile_diff_confirm_save_post(
224 State(state): State<ServerState>,
225 Extension(kopid): Extension<KOpId>,
226 VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
227 DomainInfo(domain_info): DomainInfo,
228 Form(query): Form<CommitSaveProfileQuery>,
230) -> axum::response::Result<Response> {
231 let uat: &UserAuthToken = client_auth_info
232 .pre_validated_uat()
233 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
234
235 let mut attrs = BTreeMap::<Attribute, Option<ScimValueKanidm>>::new();
236
237 if let Some(account_name) = query.account_name {
238 attrs.insert(Attribute::Name, Some(ScimValueKanidm::String(account_name)));
239 }
240
241 if let Some(display_name) = query.display_name {
242 attrs.insert(
243 Attribute::DisplayName,
244 Some(ScimValueKanidm::String(display_name)),
245 );
246 }
247
248 if query.emails.is_some() || query.new_primary_mail.is_some() {
249 let mut scim_mails = if let Some(secondary_mails) = query.emails {
250 secondary_mails
251 .into_iter()
252 .map(|secondary_mail| ScimMail {
253 primary: false,
254 value: secondary_mail,
255 })
256 .collect::<Vec<_>>()
257 } else {
258 vec![]
259 };
260
261 if let Some(primary_mail) = query.new_primary_mail {
262 scim_mails.push(ScimMail {
263 primary: true,
264 value: primary_mail,
265 })
266 }
267
268 attrs.insert(
269 Attribute::Mail,
270 if scim_mails.is_empty() {
271 None
272 } else {
273 Some(ScimValueKanidm::Mail(scim_mails))
274 },
275 );
276 }
277
278 let generic = ScimEntryPutKanidm {
279 id: uat.uuid,
280 attrs,
281 }
282 .try_into()
283 .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
284
285 state
287 .qe_w_ref
288 .handle_scim_entry_put(client_auth_info.clone(), kopid.eventid, generic)
289 .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
290 .await?;
291
292 match view_profile_get(
293 State(state),
294 Extension(kopid),
295 VerifiedClientInformation(client_auth_info),
296 DomainInfo(domain_info),
297 )
298 .await
299 {
300 Ok(_) => Ok(Redirect::to(Urls::Profile.as_ref()).into_response()),
301 Err(e) => Ok(e.into_response()),
302 }
303}
304
305#[derive(Deserialize)]
306pub(crate) struct AddEmailQuery {
307 email_index: Option<u16>,
309}
310
311pub(crate) async fn view_new_email_entry_partial(
313 State(_state): State<ServerState>,
314 VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
315 Extension(_kopid): Extension<KOpId>,
316 Query(email_query): Query<AddEmailQuery>,
317) -> axum::response::Result<Response> {
318 let add_email_trigger =
319 HxResponseTrigger::after_swap([HxEvent::new("addEmailSwapped".to_string())]);
320 Ok((
321 add_email_trigger,
322 FormEmailEntryListPartial {
323 can_edit: true,
324 value: "".to_string(),
325 primary: email_query.email_index.is_none(),
326 index: email_query.email_index.map(|i| i + 1).unwrap_or(0),
327 },
328 )
329 .into_response())
330}