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    // radio buttons are used to pick a primary index, remove causes holes, map back into [emails] using [emails_indexes]
56    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 must be the last parameter because it consumes the request body
150    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 must be the last parameter because it consumes the request body
229    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    // TODO: Use returned KanidmScimPerson below instead of view_profile_get.
286    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    // the last email index is passed so we can return an incremented id
308    email_index: Option<u16>,
309}
310
311// Sends the user a new email input to fill in :)
312pub(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}