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