1use askama::Template;
2use askama_web::WebTemplate;
3use axum::extract::{Query, State};
4use axum::http::StatusCode;
5use axum::response::{ErrorResponse, IntoResponse, Redirect, Response};
6use axum::{Extension, Form};
7use axum_extra::extract::cookie::SameSite;
8use axum_extra::extract::CookieJar;
9use axum_htmx::{
10    HxEvent, HxLocation, HxPushUrl, HxRequest, HxReselect, HxResponseTrigger, HxReswap, HxRetarget,
11    SwapOption,
12};
13use futures_util::TryFutureExt;
14use qrcode::render::svg;
15use qrcode::QrCode;
16use serde::{Deserialize, Serialize};
17use serde_with::skip_serializing_none;
18use std::collections::{BTreeMap, BTreeSet};
19use std::fmt;
20use std::fmt::{Display, Formatter};
21use std::str::FromStr;
22use uuid::Uuid;
23
24pub use sshkey_attest::proto::PublicKey as SshPublicKey;
25pub use sshkeys::KeyType;
26
27use kanidm_proto::internal::{
28    CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
29    CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UiHint,
30    UserAuthToken, COOKIE_CU_SESSION_TOKEN,
31};
32use kanidmd_lib::prelude::ClientAuthInfo;
33
34use super::constants::Urls;
35use super::navbar::NavbarCtx;
36use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation};
37use crate::https::middleware::KOpId;
38use crate::https::views::constants::ProfileMenuItems;
39use crate::https::views::errors::HtmxError;
40use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
41use crate::https::views::{cookies, KanidmHxEventName};
42use crate::https::ServerState;
43
44use super::UnrecoverableErrorView;
45
46#[derive(Template, WebTemplate)]
47#[template(path = "user_settings.html")]
48struct ProfileView {
49    navbar_ctx: NavbarCtx,
50    profile_partial: CredStatusView,
51}
52
53#[derive(Template, WebTemplate)]
54#[template(path = "credentials_reset_form.html")]
55struct ResetCredFormView {
56    domain_info: DomainInfoRead,
57    wrong_code: bool,
58}
59
60#[derive(Template, WebTemplate)]
61#[template(path = "credentials_reset.html")]
62struct CredResetView {
63    domain_info: DomainInfoRead,
64    names: String,
65    credentials_update_partial: CredResetPartialView,
66}
67
68#[derive(Template, WebTemplate)]
69#[template(path = "credentials_status.html")]
70struct CredStatusView {
71    domain_info: DomainInfoRead,
72    menu_active_item: ProfileMenuItems,
73    names: String,
74    credentials_update_partial: CredResetPartialView,
75}
76
77struct SshKey {
78    key_type: KeyType,
79    key: String,
80    comment: Option<String>,
81}
82
83#[derive(Template, WebTemplate)]
84#[template(path = "credentials_update_partial.html")]
85struct CredResetPartialView {
86    ext_cred_portal: CUExtPortal,
87    can_commit: bool,
88    warnings: Vec<CURegWarning>,
89    attested_passkeys_state: CUCredState,
90    passkeys_state: CUCredState,
91    primary_state: CUCredState,
92    attested_passkeys: Vec<PasskeyDetail>,
93    passkeys: Vec<PasskeyDetail>,
94    primary: Option<CredentialDetail>,
95    unixcred_state: CUCredState,
96    unixcred: Option<CredentialDetail>,
97    sshkeys_state: CUCredState,
98    sshkeys: BTreeMap<String, SshKey>,
99}
100
101#[skip_serializing_none]
102#[derive(Serialize, Deserialize, Debug)]
103pub(crate) struct ResetTokenParam {
105    token: Option<String>,
106}
107
108#[derive(Template, WebTemplate)]
109#[template(path = "credential_update_add_password_partial.html")]
110struct AddPasswordPartial {
111    check_res: PwdCheckResult,
112}
113
114#[derive(Template, WebTemplate)]
115#[template(path = "credential_update_set_unixcred_partial.html")]
116struct SetUnixCredPartial {
117    check_res: PwdCheckResult,
118}
119
120#[derive(Template, WebTemplate)]
121#[template(path = "credential_update_add_ssh_publickey_partial.html")]
122struct AddSshPublicKeyPartial {
123    key_title: Option<String>,
124    title_error: Option<String>,
125    key_value: Option<String>,
126    key_error: Option<String>,
127}
128
129#[derive(Serialize, Deserialize, Debug)]
130enum PwdCheckResult {
131    Success,
132    Init,
133    Failure {
134        pwd_equal: bool,
135        warnings: Vec<PasswordFeedback>,
136    },
137}
138
139#[derive(Deserialize, Debug)]
140pub(crate) struct NewPassword {
141    new_password: String,
142    new_password_check: String,
143}
144
145#[derive(Deserialize, Debug)]
146pub(crate) struct NewPublicKey {
147    title: String,
148    key: String,
149}
150
151#[derive(Deserialize, Debug)]
152pub(crate) struct PublicKeyRemoveData {
153    name: String,
154}
155
156#[derive(Deserialize, Debug)]
157pub(crate) struct NewTotp {
158    name: String,
159    #[serde(rename = "checkTOTPCode")]
160    check_totpcode: String,
161    #[serde(rename = "ignoreBrokenApp")]
162    ignore_broken_app: bool,
163}
164
165#[derive(Template, WebTemplate)]
166#[template(path = "credential_update_add_passkey_partial.html")]
167struct AddPasskeyPartial {
168    challenge: String,
170    class: PasskeyClass,
171}
172
173#[derive(Deserialize, Debug)]
174pub(crate) struct PasskeyInitForm {
175    class: PasskeyClass,
176}
177
178#[derive(Deserialize, Debug)]
179pub(crate) struct PasskeyCreateForm {
180    name: String,
181    class: PasskeyClass,
182    #[serde(rename = "creationData")]
183    creation_data: String,
184}
185
186#[derive(Deserialize, Debug)]
187pub(crate) struct PasskeyRemoveData {
188    uuid: Uuid,
189}
190
191#[derive(Deserialize, Debug)]
192pub(crate) struct TOTPRemoveData {
193    name: String,
194}
195
196#[derive(Serialize, Deserialize, Debug)]
197pub(crate) struct TotpInit {
198    secret: String,
199    qr_code_svg: String,
200    steps: u64,
201    digits: u8,
202    algo: TotpAlgo,
203    uri: String,
204}
205
206#[derive(Serialize, Deserialize, Debug, Default)]
207pub(crate) struct TotpCheck {
208    wrong_code: bool,
209    broken_app: bool,
210    bad_name: bool,
211    taken_name: Option<String>,
212}
213
214#[derive(Template, WebTemplate)]
215#[template(path = "credential_update_add_totp_partial.html")]
216struct AddTotpPartial {
217    totp_init: Option<TotpInit>,
218    totp_name: String,
219    totp_value: String,
220    check: TotpCheck,
221}
222
223#[derive(PartialEq, Debug, Serialize, Deserialize)]
224pub enum PasskeyClass {
225    Any,
226    Attested,
227}
228
229impl Display for PasskeyClass {
230    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
231        match self {
232            PasskeyClass::Any => write!(f, "Any"),
233            PasskeyClass::Attested => write!(f, "Attested"),
234        }
235    }
236}
237
238async fn end_session_response(
243    state: ServerState,
244    kopid: KOpId,
245    client_auth_info: ClientAuthInfo,
246    jar: CookieJar,
247) -> axum::response::Result<Response> {
248    let is_logged_in = state
249        .qe_r_ref
250        .handle_auth_valid(client_auth_info, kopid.eventid)
251        .await
252        .is_ok();
253
254    let redirect_location = if is_logged_in {
255        Urls::Profile.as_ref()
256    } else {
257        Urls::Login.as_ref()
258    };
259
260    Ok((jar, HxLocation::from(redirect_location), "").into_response())
261}
262
263pub(crate) async fn commit(
264    State(state): State<ServerState>,
265    Extension(kopid): Extension<KOpId>,
266    HxRequest(_hx_request): HxRequest,
267    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
268    DomainInfo(domain_info): DomainInfo,
269    jar: CookieJar,
270) -> axum::response::Result<Response> {
271    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
272
273    state
274        .qe_w_ref
275        .handle_idmcredentialupdatecommit(cu_session_token, kopid.eventid)
276        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
277        .await?;
278
279    let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
281
282    end_session_response(state, kopid, client_auth_info, jar).await
283}
284
285pub(crate) async fn cancel_cred_update(
286    State(state): State<ServerState>,
287    Extension(kopid): Extension<KOpId>,
288    HxRequest(_hx_request): HxRequest,
289    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
290    DomainInfo(domain_info): DomainInfo,
291    jar: CookieJar,
292) -> axum::response::Result<Response> {
293    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
294
295    state
296        .qe_w_ref
297        .handle_idmcredentialupdatecancel(cu_session_token, kopid.eventid)
298        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
299        .await?;
300
301    let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
303
304    end_session_response(state, kopid, client_auth_info, jar).await
305}
306
307pub(crate) async fn cancel_mfareg(
308    State(state): State<ServerState>,
309    Extension(kopid): Extension<KOpId>,
310    HxRequest(_hx_request): HxRequest,
311    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
312    DomainInfo(domain_info): DomainInfo,
313    jar: CookieJar,
314) -> axum::response::Result<Response> {
315    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
316
317    let cu_status = state
318        .qe_r_ref
319        .handle_idmcredentialupdate(cu_session_token, CURequest::CancelMFAReg, kopid.eventid)
320        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
321        .await?;
322
323    Ok(get_cu_partial_response(cu_status))
324}
325
326pub(crate) async fn remove_alt_creds(
327    State(state): State<ServerState>,
328    Extension(kopid): Extension<KOpId>,
329    HxRequest(_hx_request): HxRequest,
330    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
331    DomainInfo(domain_info): DomainInfo,
332    jar: CookieJar,
333) -> axum::response::Result<Response> {
334    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
335
336    let cu_status = state
337        .qe_r_ref
338        .handle_idmcredentialupdate(cu_session_token, CURequest::PrimaryRemove, kopid.eventid)
339        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
340        .await?;
341
342    Ok(get_cu_partial_response(cu_status))
343}
344
345pub(crate) async fn remove_unixcred(
346    State(state): State<ServerState>,
347    Extension(kopid): Extension<KOpId>,
348    HxRequest(_hx_request): HxRequest,
349    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
350    DomainInfo(domain_info): DomainInfo,
351    jar: CookieJar,
352) -> axum::response::Result<Response> {
353    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
354
355    let cu_status = state
356        .qe_r_ref
357        .handle_idmcredentialupdate(
358            cu_session_token,
359            CURequest::UnixPasswordRemove,
360            kopid.eventid,
361        )
362        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
363        .await?;
364
365    Ok(get_cu_partial_response(cu_status))
366}
367
368pub(crate) async fn remove_ssh_publickey(
369    State(state): State<ServerState>,
370    Extension(kopid): Extension<KOpId>,
371    HxRequest(_hx_request): HxRequest,
372    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
373    DomainInfo(domain_info): DomainInfo,
374    jar: CookieJar,
375    Form(publickey): Form<PublicKeyRemoveData>,
376) -> axum::response::Result<Response> {
377    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
378
379    let cu_status = state
380        .qe_r_ref
381        .handle_idmcredentialupdate(
382            cu_session_token,
383            CURequest::SshPublicKeyRemove(publickey.name),
384            kopid.eventid,
385        )
386        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
387        .await?;
388
389    Ok(get_cu_partial_response(cu_status))
390}
391
392pub(crate) async fn remove_totp(
393    State(state): State<ServerState>,
394    Extension(kopid): Extension<KOpId>,
395    HxRequest(_hx_request): HxRequest,
396    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
397    DomainInfo(domain_info): DomainInfo,
398    jar: CookieJar,
399    Form(totp): Form<TOTPRemoveData>,
400) -> axum::response::Result<Response> {
401    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
402
403    let cu_status = state
404        .qe_r_ref
405        .handle_idmcredentialupdate(
406            cu_session_token,
407            CURequest::TotpRemove(totp.name),
408            kopid.eventid,
409        )
410        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
411        .await?;
412
413    Ok(get_cu_partial_response(cu_status))
414}
415
416pub(crate) async fn remove_passkey(
417    State(state): State<ServerState>,
418    Extension(kopid): Extension<KOpId>,
419    HxRequest(_hx_request): HxRequest,
420    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
421    DomainInfo(domain_info): DomainInfo,
422    jar: CookieJar,
423    Form(passkey): Form<PasskeyRemoveData>,
424) -> axum::response::Result<Response> {
425    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
426
427    let cu_status = state
428        .qe_r_ref
429        .handle_idmcredentialupdate(
430            cu_session_token,
431            CURequest::PasskeyRemove(passkey.uuid),
432            kopid.eventid,
433        )
434        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
435        .await?;
436
437    Ok(get_cu_partial_response(cu_status))
438}
439
440pub(crate) async fn finish_passkey(
441    State(state): State<ServerState>,
442    Extension(kopid): Extension<KOpId>,
443    DomainInfo(domain_info): DomainInfo,
444    jar: CookieJar,
445    Form(passkey_create): Form<PasskeyCreateForm>,
446) -> axum::response::Result<Response> {
447    let cu_session_token = get_cu_session(&jar).await?;
448
449    match serde_json::from_str(passkey_create.creation_data.as_str()) {
450        Ok(creation_data) => {
451            let cu_request = match passkey_create.class {
452                PasskeyClass::Any => CURequest::PasskeyFinish(passkey_create.name, creation_data),
453                PasskeyClass::Attested => {
454                    CURequest::AttestedPasskeyFinish(passkey_create.name, creation_data)
455                }
456            };
457
458            let cu_status = state
459                .qe_r_ref
460                .handle_idmcredentialupdate(cu_session_token, cu_request, kopid.eventid)
461                .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
462                .await?;
463
464            Ok(get_cu_partial_response(cu_status))
465        }
466        Err(e) => {
467            error!("Bad request for passkey creation: {e}");
468            Ok((
469                StatusCode::UNPROCESSABLE_ENTITY,
470                HtmxError::new(&kopid, OperationError::Backend, domain_info).into_response(),
471            )
472                .into_response())
473        }
474    }
475}
476
477pub(crate) async fn view_new_passkey(
478    State(state): State<ServerState>,
479    Extension(kopid): Extension<KOpId>,
480    HxRequest(_hx_request): HxRequest,
481    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
482    DomainInfo(domain_info): DomainInfo,
483    jar: CookieJar,
484    Form(init_form): Form<PasskeyInitForm>,
485) -> axum::response::Result<Response> {
486    let cu_session_token = get_cu_session(&jar).await?;
487    let cu_req = match init_form.class {
488        PasskeyClass::Any => CURequest::PasskeyInit,
489        PasskeyClass::Attested => CURequest::AttestedPasskeyInit,
490    };
491
492    let cu_status: CUStatus = state
493        .qe_r_ref
494        .handle_idmcredentialupdate(cu_session_token, cu_req, kopid.eventid)
495        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
496        .await?;
497
498    let response = match cu_status.mfaregstate {
499        CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => {
500            if let Ok(challenge) = serde_json::to_string(&chal) {
501                AddPasskeyPartial {
502                    challenge,
503                    class: init_form.class,
504                }
505                .into_response()
506            } else {
507                UnrecoverableErrorView {
508                    err_code: OperationError::UI0001ChallengeSerialisation,
509                    operation_id: kopid.eventid,
510                    domain_info,
511                }
512                .into_response()
513            }
514        }
515        _ => UnrecoverableErrorView {
516            err_code: OperationError::UI0002InvalidState,
517            operation_id: kopid.eventid,
518            domain_info,
519        }
520        .into_response(),
521    };
522
523    let passkey_init_trigger =
524        HxResponseTrigger::after_swap([HxEvent::from(KanidmHxEventName::AddPasskeySwapped)]);
525    Ok((
526        passkey_init_trigger,
527        HxPushUrl("/ui/reset/add_passkey".to_string()),
528        response,
529    )
530        .into_response())
531}
532
533pub(crate) async fn view_new_totp(
534    State(state): State<ServerState>,
535    Extension(kopid): Extension<KOpId>,
536    DomainInfo(domain_info): DomainInfo,
537    jar: CookieJar,
538) -> axum::response::Result<Response> {
539    let cu_session_token = get_cu_session(&jar).await?;
540    let push_url = HxPushUrl("/ui/reset/add_totp".to_string());
541
542    let cu_status = state
543        .qe_r_ref
544        .handle_idmcredentialupdate(cu_session_token, CURequest::TotpGenerate, kopid.eventid)
545        .await
546        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
549
550    let partial = if let CURegState::TotpCheck(secret) = cu_status.mfaregstate {
551        let uri = secret.to_uri();
552        let svg = match QrCode::new(uri.as_str()) {
553            Ok(qr) => qr.render::<svg::Color>().build(),
554            Err(qr_err) => {
555                error!("Failed to create TOTP QR code: {qr_err}");
556                "QR Code Generation Failed".to_string()
557            }
558        };
559
560        AddTotpPartial {
561            totp_init: Some(TotpInit {
562                secret: secret.get_secret(),
563                qr_code_svg: svg,
564                steps: secret.step,
565                digits: secret.digits,
566                algo: secret.algo,
567                uri,
568            }),
569            totp_name: Default::default(),
570            totp_value: Default::default(),
571            check: TotpCheck::default(),
572        }
573    } else {
574        return Err(ErrorResponse::from(HtmxError::new(
575            &kopid,
576            OperationError::CannotStartMFADuringOngoingMFASession,
577            domain_info,
578        )));
579    };
580
581    Ok((push_url, partial).into_response())
582}
583
584pub(crate) async fn add_totp(
585    State(state): State<ServerState>,
586    Extension(kopid): Extension<KOpId>,
587    HxRequest(_hx_request): HxRequest,
588    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
589    DomainInfo(domain_info): DomainInfo,
590    jar: CookieJar,
591    new_totp_form: Form<NewTotp>,
592) -> axum::response::Result<Response> {
593    let cu_session_token = get_cu_session(&jar).await?;
594
595    let check_totpcode = u32::from_str(&new_totp_form.check_totpcode).unwrap_or_default();
596    let swapped_handler_trigger =
597        HxResponseTrigger::after_swap([HxEvent::from(KanidmHxEventName::AddTotpSwapped)]);
598
599    if new_totp_form.name.trim().is_empty() {
601        return Ok((
602            swapped_handler_trigger,
603            AddTotpPartial {
604                totp_init: None,
605                totp_name: "".into(),
606                totp_value: new_totp_form.check_totpcode.clone(),
607                check: TotpCheck {
608                    bad_name: true,
609                    ..Default::default()
610                },
611            },
612        )
613            .into_response());
614    }
615
616    let cu_status = if new_totp_form.ignore_broken_app {
617        state.qe_r_ref.handle_idmcredentialupdate(
619            cu_session_token,
620            CURequest::TotpAcceptSha1,
621            kopid.eventid,
622        )
623    } else {
624        state.qe_r_ref.handle_idmcredentialupdate(
626            cu_session_token,
627            CURequest::TotpVerify(check_totpcode, new_totp_form.name.clone()),
628            kopid.eventid,
629        )
630    }
631    .await
632    .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
633
634    let check = match &cu_status.mfaregstate {
635        CURegState::None => return Ok(get_cu_partial_response(cu_status)),
636        CURegState::TotpTryAgain => TotpCheck {
637            wrong_code: true,
638            ..Default::default()
639        },
640        CURegState::TotpNameTryAgain(val) => TotpCheck {
641            taken_name: Some(val.clone()),
642            ..Default::default()
643        },
644        CURegState::TotpInvalidSha1 => TotpCheck {
645            broken_app: true,
646            ..Default::default()
647        },
648        CURegState::TotpCheck(_)
649        | CURegState::BackupCodes(_)
650        | CURegState::Passkey(_)
651        | CURegState::AttestedPasskey(_) => {
652            return Err(ErrorResponse::from(HtmxError::new(
653                &kopid,
654                OperationError::InvalidState,
655                domain_info,
656            )))
657        }
658    };
659
660    let check_totpcode = if check.wrong_code {
661        String::default()
662    } else {
663        new_totp_form.check_totpcode.clone()
664    };
665
666    Ok((
667        swapped_handler_trigger,
668        AddTotpPartial {
669            totp_init: None,
670            totp_name: new_totp_form.name.clone(),
671            totp_value: check_totpcode,
672            check,
673        },
674    )
675        .into_response())
676}
677
678#[axum::debug_handler]
679pub(crate) async fn view_new_pwd(
680    State(state): State<ServerState>,
681    Extension(kopid): Extension<KOpId>,
682    HxRequest(_hx_request): HxRequest,
683    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
684    DomainInfo(domain_info): DomainInfo,
685    jar: CookieJar,
686    Form(opt_form): Form<Option<NewPassword>>,
687) -> axum::response::Result<Response> {
688    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
689    let swapped_handler_trigger =
690        HxResponseTrigger::after_swap([HxEvent::from(KanidmHxEventName::AddPasswordSwapped)]);
691
692    let new_passwords = match opt_form {
693        None => {
694            return Ok((
695                swapped_handler_trigger,
696                AddPasswordPartial {
697                    check_res: PwdCheckResult::Init,
698                },
699            )
700                .into_response());
701        }
702        Some(new_passwords) => new_passwords,
703    };
704
705    let pwd_equal = new_passwords.new_password == new_passwords.new_password_check;
706    let (warnings, status) = if pwd_equal {
707        let res = state
708            .qe_r_ref
709            .handle_idmcredentialupdate(
710                cu_session_token,
711                CURequest::Password(new_passwords.new_password),
712                kopid.eventid,
713            )
714            .await;
715        match res {
716            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
717            Err(OperationError::PasswordQuality(password_feedback)) => {
718                (password_feedback, StatusCode::UNPROCESSABLE_ENTITY)
719            }
720            Err(operr) => {
721                return Err(ErrorResponse::from(HtmxError::new(
722                    &kopid,
723                    operr,
724                    domain_info,
725                )))
726            }
727        }
728    } else {
729        (vec![], StatusCode::UNPROCESSABLE_ENTITY)
730    };
731
732    let check_res = PwdCheckResult::Failure {
733        pwd_equal,
734        warnings,
735    };
736
737    Ok((
738        status,
739        swapped_handler_trigger,
740        HxPushUrl("/ui/reset/change_password".to_string()),
741        AddPasswordPartial { check_res },
742    )
743        .into_response())
744}
745
746pub(crate) async fn view_self_reset_get(
748    State(state): State<ServerState>,
749    Extension(kopid): Extension<KOpId>,
750    HxRequest(_hx_request): HxRequest,
751    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
752    DomainInfo(domain_info): DomainInfo,
753    mut jar: CookieJar,
754) -> axum::response::Result<Response> {
755    let uat: &UserAuthToken = client_auth_info
756        .pre_validated_uat()
757        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
758
759    let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
760    let can_rw = uat.purpose_readwrite_active(time);
761
762    if can_rw {
763        let (cu_session_token, cu_status) = state
764            .qe_w_ref
765            .handle_idmcredentialupdate(
766                client_auth_info.clone(),
767                uat.uuid.to_string(),
768                kopid.eventid,
769            )
770            .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
771            .await?;
772
773        let cu_resp = get_cu_response(&uat.ui_hints, domain_info, cu_status, true);
774
775        jar = add_cu_cookie(jar, &state, cu_session_token);
776        Ok((jar, cu_resp).into_response())
777    } else {
778        let display_ctx = LoginDisplayCtx {
779            domain_info,
780            oauth2: None,
781            reauth: Some(Reauth {
782                username: uat.spn.clone(),
783                purpose: ReauthPurpose::ProfileSettings,
784            }),
785            error: None,
786        };
787
788        Ok(super::login::view_reauth_get(
789            state,
790            client_auth_info,
791            kopid,
792            jar,
793            Urls::UpdateCredentials.as_ref(),
794            display_ctx,
795        )
796        .await)
797    }
798}
799
800fn add_cu_cookie(
802    jar: CookieJar,
803    state: &ServerState,
804    cu_session_token: CUSessionToken,
805) -> CookieJar {
806    let mut token_cookie =
807        cookies::make_unsigned(state, COOKIE_CU_SESSION_TOKEN, cu_session_token.token);
808    token_cookie.set_same_site(SameSite::Strict);
809    jar.add(token_cookie)
810}
811
812pub(crate) async fn view_set_unixcred(
813    State(state): State<ServerState>,
814    Extension(kopid): Extension<KOpId>,
815    HxRequest(_hx_request): HxRequest,
816    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
817    DomainInfo(domain_info): DomainInfo,
818    jar: CookieJar,
819    Form(opt_form): Form<Option<NewPassword>>,
820) -> axum::response::Result<Response> {
821    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
822    let swapped_handler_trigger =
823        HxResponseTrigger::after_swap([HxEvent::from(KanidmHxEventName::AddPasswordSwapped)]);
824
825    let new_passwords = match opt_form {
826        None => {
827            return Ok((
828                swapped_handler_trigger,
829                SetUnixCredPartial {
830                    check_res: PwdCheckResult::Init,
831                },
832            )
833                .into_response());
834        }
835        Some(new_passwords) => new_passwords,
836    };
837
838    let pwd_equal = new_passwords.new_password == new_passwords.new_password_check;
839    let (warnings, status) = if pwd_equal {
840        let res = state
841            .qe_r_ref
842            .handle_idmcredentialupdate(
843                cu_session_token,
844                CURequest::UnixPassword(new_passwords.new_password),
845                kopid.eventid,
846            )
847            .await;
848        match res {
849            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
850            Err(OperationError::PasswordQuality(password_feedback)) => {
851                (password_feedback, StatusCode::UNPROCESSABLE_ENTITY)
852            }
853            Err(operr) => {
854                return Err(ErrorResponse::from(HtmxError::new(
855                    &kopid,
856                    operr,
857                    domain_info,
858                )))
859            }
860        }
861    } else {
862        (vec![], StatusCode::UNPROCESSABLE_ENTITY)
863    };
864
865    let check_res = PwdCheckResult::Failure {
866        pwd_equal,
867        warnings,
868    };
869
870    Ok((
871        status,
872        swapped_handler_trigger,
873        HxPushUrl("/ui/reset/set_unixcred".to_string()),
874        SetUnixCredPartial { check_res },
875    )
876        .into_response())
877}
878
879struct AddSshPublicKeyError {
880    key: Option<String>,
881    title: Option<String>,
882}
883
884pub(crate) async fn view_add_ssh_publickey(
885    State(state): State<ServerState>,
886    Extension(kopid): Extension<KOpId>,
887    HxRequest(_hx_request): HxRequest,
888    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
889    DomainInfo(domain_info): DomainInfo,
890    jar: CookieJar,
891    Form(opt_form): Form<Option<NewPublicKey>>,
892) -> axum::response::Result<Response> {
893    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
894
895    let new_key = match opt_form {
896        None => {
897            return Ok((AddSshPublicKeyPartial {
898                key_title: None,
899                title_error: None,
900                key_value: None,
901                key_error: None,
902            },)
903                .into_response());
904        }
905        Some(new_key) => new_key,
906    };
907
908    let (
909        AddSshPublicKeyError {
910            key: key_error,
911            title: title_error,
912        },
913        status,
914    ) = {
915        let publickey = match SshPublicKey::from_string(&new_key.key) {
916            Err(_) => {
917                return Ok((AddSshPublicKeyPartial {
918                    key_title: Some(new_key.title),
919                    title_error: None,
920                    key_value: Some(new_key.key),
921                    key_error: Some("Key cannot be parsed".to_string()),
922                },)
923                    .into_response());
924            }
925            Ok(publickey) => publickey,
926        };
927        let res = state
928            .qe_r_ref
929            .handle_idmcredentialupdate(
930                cu_session_token,
931                CURequest::SshPublicKey(new_key.title.clone(), publickey),
932                kopid.eventid,
933            )
934            .await;
935        match res {
936            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
937            Err(e @ (OperationError::InvalidLabel | OperationError::DuplicateLabel)) => (
938                AddSshPublicKeyError {
939                    title: Some(e.to_string()),
940                    key: None,
941                },
942                StatusCode::UNPROCESSABLE_ENTITY,
943            ),
944            Err(e @ OperationError::DuplicateKey) => (
945                AddSshPublicKeyError {
946                    key: Some(e.to_string()),
947                    title: None,
948                },
949                StatusCode::UNPROCESSABLE_ENTITY,
950            ),
951            Err(operr) => {
952                return Err(ErrorResponse::from(HtmxError::new(
953                    &kopid,
954                    operr,
955                    domain_info,
956                )))
957            }
958        }
959    };
960
961    Ok((
962        status,
963        HxPushUrl("/ui/reset/add_ssh_publickey".to_string()),
964        AddSshPublicKeyPartial {
965            key_title: Some(new_key.title),
966            title_error,
967            key_error,
968            key_value: Some(new_key.key),
969        },
970    )
971        .into_response())
972}
973
974pub(crate) async fn view_reset_get(
975    State(state): State<ServerState>,
976    Extension(kopid): Extension<KOpId>,
977    HxRequest(_hx_request): HxRequest,
978    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
979    DomainInfo(domain_info): DomainInfo,
980    Query(params): Query<ResetTokenParam>,
981    mut jar: CookieJar,
982) -> axum::response::Result<Response> {
983    let push_url = HxPushUrl(Urls::CredReset.to_string());
984    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
985    let is_logged_in = state
986        .qe_r_ref
987        .handle_auth_valid(client_auth_info.clone(), kopid.eventid)
988        .await
989        .is_ok();
990
991    if let Some(cookie) = cookie {
992        let cu_session_token = cookie.value();
994        let cu_session_token = CUSessionToken {
995            token: cu_session_token.into(),
996        };
997        let cu_status = match state
998            .qe_r_ref
999            .handle_idmcredentialupdatestatus(cu_session_token, kopid.eventid)
1000            .await
1001        {
1002            Ok(cu_status) => cu_status,
1003            Err(
1004                OperationError::SessionExpired
1005                | OperationError::InvalidSessionState
1006                | OperationError::InvalidState,
1007            ) => {
1008                jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
1010
1011                if let Some(token) = params.token {
1012                    let token_uri_string = format!("{}?token={}", Urls::CredReset, token);
1013                    return Ok((jar, Redirect::to(&token_uri_string)).into_response());
1014                }
1015                return Ok((jar, Redirect::to(Urls::CredReset.as_ref())).into_response());
1016            }
1017            Err(op_err) => {
1018                return Ok(HtmxError::new(&kopid, op_err, domain_info.clone()).into_response())
1019            }
1020        };
1021
1022        let cu_resp = get_cu_response(&Default::default(), domain_info, cu_status, is_logged_in);
1024
1025        Ok(cu_resp)
1026    } else if let Some(token) = params.token {
1027        match state
1029            .qe_w_ref
1030            .handle_idmcredentialexchangeintent(token, kopid.eventid)
1031            .await
1032        {
1033            Ok((cu_session_token, cu_status)) => {
1034                let cu_resp =
1035                    get_cu_response(&Default::default(), domain_info, cu_status, is_logged_in);
1036
1037                jar = add_cu_cookie(jar, &state, cu_session_token);
1038                Ok((jar, cu_resp).into_response())
1039            }
1040            Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => {
1041                Ok((
1043                    push_url,
1044                    ResetCredFormView {
1045                        domain_info,
1046                        wrong_code: true,
1047                    },
1048                )
1049                    .into_response())
1050            }
1051            Err(op_err) => Err(ErrorResponse::from(
1052                HtmxError::new(&kopid, op_err, domain_info).into_response(),
1053            )),
1054        }
1055    } else {
1056        Ok((
1058            push_url,
1059            ResetCredFormView {
1060                domain_info,
1061                wrong_code: false,
1062            },
1063        )
1064            .into_response())
1065    }
1066}
1067
1068fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
1069    let CUStatus {
1070        ext_cred_portal,
1071        can_commit,
1072        warnings,
1073        passkeys_state,
1074        attested_passkeys_state,
1075        attested_passkeys,
1076        passkeys,
1077        primary_state,
1078        primary,
1079        unixcred_state,
1080        unixcred,
1081        sshkeys_state,
1082        sshkeys,
1083        ..
1084    } = cu_status;
1085
1086    let sshkeyss: BTreeMap<String, SshKey> = sshkeys
1087        .iter()
1088        .map(|(k, v)| {
1089            (
1090                k.clone(),
1091                SshKey {
1092                    key_type: v.clone().key_type,
1093                    key: v.fingerprint().hash,
1094                    comment: v.comment.clone(),
1095                },
1096            )
1097        })
1098        .collect();
1099
1100    CredResetPartialView {
1101        ext_cred_portal,
1102        can_commit,
1103        warnings,
1104        attested_passkeys_state,
1105        passkeys_state,
1106        attested_passkeys,
1107        passkeys,
1108        primary_state,
1109        primary,
1110        unixcred_state,
1111        unixcred,
1112        sshkeys_state,
1113        sshkeys: sshkeyss,
1114    }
1115}
1116
1117fn get_cu_partial_response(cu_status: CUStatus) -> Response {
1118    let credentials_update_partial = get_cu_partial(cu_status);
1119    (
1120        HxPushUrl(Urls::CredReset.to_string()),
1121        HxRetarget("#credentialUpdateDynamicSection".to_string()),
1122        HxReselect("#credentialUpdateDynamicSection".to_string()),
1123        HxReswap(SwapOption::OuterHtml),
1124        credentials_update_partial,
1125    )
1126        .into_response()
1127}
1128
1129fn get_cu_response(
1130    ui_hints: &BTreeSet<UiHint>,
1131    domain_info: DomainInfoRead,
1132    cu_status: CUStatus,
1133    is_logged_in: bool,
1134) -> Response {
1135    let spn = cu_status.spn.clone();
1136    let displayname = cu_status.displayname.clone();
1137    let (username, _domain) = spn.split_once('@').unwrap_or(("", &spn));
1138    let names = format!("{displayname} ({username})");
1139    let credentials_update_partial = get_cu_partial(cu_status);
1140
1141    if is_logged_in {
1142        let cred_status_view = CredStatusView {
1143            menu_active_item: ProfileMenuItems::Credentials,
1144            domain_info: domain_info.clone(),
1145            names,
1146            credentials_update_partial,
1147        };
1148
1149        (
1150            HxPushUrl(Urls::UpdateCredentials.to_string()),
1151            ProfileView {
1152                navbar_ctx: NavbarCtx::new(domain_info, ui_hints),
1153                profile_partial: cred_status_view,
1154            },
1155        )
1156            .into_response()
1157    } else {
1158        (
1159            HxPushUrl(Urls::CredReset.to_string()),
1160            CredResetView {
1161                domain_info,
1162                names,
1163                credentials_update_partial,
1164            },
1165        )
1166            .into_response()
1167    }
1168}
1169
1170async fn get_cu_session(jar: &CookieJar) -> Result<CUSessionToken, Response> {
1171    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
1172    if let Some(cookie) = cookie {
1173        let cu_session_token = cookie.value();
1174        let cu_session_token = CUSessionToken {
1175            token: cu_session_token.into(),
1176        };
1177        Ok(cu_session_token)
1178    } else {
1179        Err((
1180            StatusCode::FORBIDDEN,
1181            Redirect::to(Urls::CredReset.as_ref()),
1182        )
1183            .into_response())
1184    }
1185}