From bbe9ad1a06762368545ca1400efe4964cc16948d Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Wed, 23 Oct 2024 16:04:38 +1000 Subject: [PATCH] yale's rabbit-hole-chasing-htmx-fixing-megapatch (#3135) --- Cargo.lock | 12 ++ Cargo.toml | 7 +- Makefile | 5 + server/core/Cargo.toml | 1 + server/core/src/https/views/apps.rs | 11 +- server/core/src/https/views/constants.rs | 42 +++- server/core/src/https/views/errors.rs | 6 +- server/core/src/https/views/login.rs | 193 +++++++++++------- server/core/src/https/views/mod.rs | 33 +-- server/core/src/https/views/oauth2.rs | 29 +-- server/core/src/https/views/profile.rs | 63 +++--- server/core/src/https/views/reset.rs | 114 ++++++----- ...redential_update_add_password_partial.html | 2 +- .../templates/credentials_reset_form.html | 2 +- server/core/templates/credentials_status.html | 4 +- .../templates/credentials_update_partial.html | 18 +- server/core/templates/login.html | 7 + server/core/templates/login_base.html | 4 +- server/core/templates/login_denied.html | 2 +- server/core/templates/navbar.html | 10 +- .../templates/user_settings_partial_base.html | 10 +- .../user_settings_posix_partial.html | 4 +- .../user_settings_profile_partial.html | 23 +-- 23 files changed, 340 insertions(+), 262 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06dfc1a44..d519921a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,17 @@ dependencies = [ "serde", ] +[[package]] +name = "askama_axum" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163" +dependencies = [ + "askama", + "axum-core 0.4.5", + "http 1.1.0", +] + [[package]] name = "askama_derive" version = "0.12.5" @@ -3434,6 +3445,7 @@ name = "kanidmd_core" version = "1.4.0-dev" dependencies = [ "askama", + "askama_axum", "async-trait", "axum 0.7.7", "axum-auth", diff --git a/Cargo.toml b/Cargo.toml index 8ade1ff87..569b09c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,7 +141,8 @@ sketching = { path = "./libs/sketching", version = "=1.4.0-dev" } anyhow = { version = "1.0.90" } argon2 = { version = "0.5.3", features = ["alloc"] } -askama = { version = "0.12.1", features = ["serde"] } +askama = { version = "0.12.1", features = ["serde", "with-axum"] } +askama_axum = { version = "0.4.0" } async-trait = "^0.1.83" axum = { version = "0.7.7", features = [ "form", @@ -248,7 +249,9 @@ reqwest = { version = "0.12.8", default-features = false, features = [ ] } rpassword = "^7.3.1" rusqlite = { version = "^0.28.0", features = ["array", "bundled"] } -rustls = { version = "0.23.13", default-features = false, features = ["aws_lc_rs"] } +rustls = { version = "0.23.13", default-features = false, features = [ + "aws_lc_rs", +] } sd-notify = "^0.4.3" selinux = "^0.4.6" diff --git a/Makefile b/Makefile index 7b099daa0..d728daf94 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,11 @@ run: ## Run the test/dev server run: cd server/daemon && ./run_insecure_dev_server.sh +.PHONY: run_htmx +run_htmx: ## Run in HTMX mode +run_htmx: + cd server/daemon && KANI_CARGO_OPTS="--features kanidmd_core/ui_htmx" ./run_insecure_dev_server.sh + .PHONY: buildx/kanidmd buildx/kanidmd: ## Build multiarch kanidm server images and push to docker hub buildx/kanidmd: diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index 591b50e76..614378473 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -21,6 +21,7 @@ ui_htmx = [] [dependencies] async-trait = { workspace = true } askama = { workspace = true } +askama_axum = { workspace = true } axum = { workspace = true } axum-htmx = { workspace = true } axum-auth = "0.7.0" diff --git a/server/core/src/https/views/apps.rs b/server/core/src/https/views/apps.rs index ab867d3d7..dd57777dd 100644 --- a/server/core/src/https/views/apps.rs +++ b/server/core/src/https/views/apps.rs @@ -9,7 +9,7 @@ use axum_htmx::HxPushUrl; use kanidm_proto::internal::AppLink; -use super::HtmlTemplate; +use super::constants::Urls; use crate::https::views::errors::HtmxError; use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; @@ -40,13 +40,12 @@ pub(crate) async fn view_apps_get( .await .map_err(|old| HtmxError::new(&kopid, old))?; - let apps_partial = AppsPartialView { apps: app_links }; - Ok({ - let apps_view = AppsView { apps_partial }; ( - HxPushUrl(Uri::from_static("/ui/apps")), - HtmlTemplate(apps_view).into_response(), + HxPushUrl(Uri::from_static(Urls::Apps.as_ref())), + AppsView { + apps_partial: AppsPartialView { apps: app_links }, + }, ) .into_response() }) diff --git a/server/core/src/https/views/constants.rs b/server/core/src/https/views/constants.rs index 3d24658e8..450d763d5 100644 --- a/server/core/src/https/views/constants.rs +++ b/server/core/src/https/views/constants.rs @@ -4,7 +4,47 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "snake_case")] pub(crate) enum ProfileMenuItems { UserProfile, - SshKeys, Credentials, UnixPassword, } + +pub(crate) enum UiMessage { + UnlockEdit, +} + +impl std::fmt::Display for UiMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UiMessage::UnlockEdit => write!(f, "Unlock Edit 🔒"), + } + } +} + +#[allow(dead_code)] +pub(crate) enum Urls { + Apps, + CredReset, + Profile, + UpdateCredentials, + Oauth2Resume, + Login, +} + +impl AsRef for Urls { + fn as_ref(&self) -> &str { + match self { + Self::Apps => "/ui/apps", + Self::CredReset => "/ui/reset", + Self::Profile => "/ui/profile", + Self::UpdateCredentials => "/ui/update_credentials", + Self::Oauth2Resume => "/ui/oauth2/resume", + Self::Login => "/ui/login", + } + } +} + +impl std::fmt::Display for Urls { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_ref()) + } +} diff --git a/server/core/src/https/views/errors.rs b/server/core/src/https/views/errors.rs index cc49cdafd..dd1494623 100644 --- a/server/core/src/https/views/errors.rs +++ b/server/core/src/https/views/errors.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use kanidm_proto::internal::OperationError; use crate::https::middleware::KOpId; -use crate::https::views::{HtmlTemplate, UnrecoverableErrorView}; +use crate::https::views::UnrecoverableErrorView; // #[derive(Template)] // #[template(path = "recoverable_error_partial.html")] // struct ErrorPartialView { @@ -55,10 +55,10 @@ impl IntoResponse for HtmxError { StatusCode::INTERNAL_SERVER_ERROR, HxRetarget("body".to_string()), HxReswap(SwapOption::OuterHtml), - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: inner, operation_id: kopid, - }), + }, ) .into_response(), } diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index 4cbc75a53..d891a72eb 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -1,4 +1,5 @@ -use super::{cookies, empty_string_as_none, HtmlTemplate, UnrecoverableErrorView}; +use super::constants::Urls; +use super::{cookies, empty_string_as_none, UnrecoverableErrorView}; use crate::https::views::errors::HtmxError; use crate::https::{ extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}, @@ -53,7 +54,19 @@ pub enum ReauthPurpose { impl fmt::Display for ReauthPurpose { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ReauthPurpose::ProfileSettings => write!(f, "Profile and Settings"), + Self::ProfileSettings => write!(f, "Profile and Settings"), + } + } +} + +pub enum LoginError { + InvalidUsername, +} + +impl fmt::Display for LoginError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidUsername => write!(f, "Invalid username"), } } } @@ -67,6 +80,7 @@ pub struct LoginDisplayCtx { pub domain_info: DomainInfoRead, // We only need this on the first re-auth screen to indicate what we are doing pub reauth: Option, + pub error: Option, } #[derive(Template)] @@ -146,13 +160,13 @@ pub async fn view_logout_get( .handle_logout(client_auth_info, kopid.eventid) .await { - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response() } else { - let response = Redirect::to("/ui/login").into_response(); + let response = Redirect::to(Urls::Login.as_ref()).into_response(); jar = cookies::destroy(jar, COOKIE_BEARER_TOKEN); @@ -167,13 +181,13 @@ pub async fn view_reauth_get( jar: CookieJar, return_location: &str, display_ctx: LoginDisplayCtx, -) -> axum::response::Result { +) -> Response { let session_valid_result = state .qe_r_ref .handle_auth_valid(client_auth_info.clone(), kopid.eventid) .await; - let res = match session_valid_result { + match session_valid_result { Ok(()) => { let inter = state .qe_r_ref @@ -209,18 +223,18 @@ pub async fn view_reauth_get( { Ok(r) => r, // Okay, these errors are actually REALLY bad. - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } // Probably needs to be way nicer on login, especially something like no matching users ... - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } @@ -233,21 +247,19 @@ pub async fn view_reauth_get( let remember_me = !username.is_empty(); - HtmlTemplate(LoginView { + LoginView { display_ctx, username, remember_me, - }) + } .into_response() } - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), - }; - - Ok(res) + } } pub async fn view_index_get( @@ -266,7 +278,7 @@ pub async fn view_index_get( match session_valid_result { Ok(()) => { // Send the user to the landing. - Redirect::to("/ui/apps").into_response() + Redirect::to(Urls::Apps.as_ref()).into_response() } Err(OperationError::NotAuthenticated) | Err(OperationError::SessionExpired) => { // cookie jar with remember me. @@ -280,19 +292,20 @@ pub async fn view_index_get( let display_ctx = LoginDisplayCtx { domain_info, reauth: None, + error: None, }; - HtmlTemplate(LoginView { + LoginView { display_ctx, username, remember_me, - }) + } .into_response() } - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } @@ -346,16 +359,17 @@ pub async fn view_login_begin_post( let session_context = SessionContext { id: None, - username, + username: username.clone(), password, totp, remember_me, after_auth_loc: None, }; - let display_ctx = LoginDisplayCtx { + let mut display_ctx = LoginDisplayCtx { domain_info, reauth: None, + error: None, }; // Now process the response if ok. @@ -374,19 +388,30 @@ pub async fn view_login_begin_post( { Ok(r) => r, // Okay, these errors are actually REALLY bad. - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } // Probably needs to be way nicer on login, especially something like no matching users ... - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { - err_code, - operation_id: kopid.eventid, - }) - .into_response(), + Err(err_code) => match err_code { + OperationError::NoMatchingEntries => { + display_ctx.error = Some(LoginError::InvalidUsername); + LoginView { + display_ctx, + username, + remember_me, + } + .into_response() + } + _ => UnrecoverableErrorView { + err_code, + operation_id: kopid.eventid, + } + .into_response(), + }, } } @@ -426,6 +451,7 @@ pub async fn view_login_mech_choose_post( let display_ctx = LoginDisplayCtx { domain_info, reauth: None, + error: None, }; // Now process the response if ok. @@ -444,18 +470,18 @@ pub async fn view_login_mech_choose_post( { Ok(r) => r, // Okay, these errors are actually REALLY bad. - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } // Probably needs to be way nicer on login, especially something like no matching users ... - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } @@ -474,19 +500,22 @@ pub async fn view_login_totp_post( Form(login_totp_form): Form, ) -> Response { // trim leading and trailing white space. - let Ok(totp) = u32::from_str(login_totp_form.totp.trim()) else { - let display_ctx = LoginDisplayCtx { - domain_info, - reauth: None, - }; - - // If not an int, we need to re-render with an error - return HtmlTemplate(LoginTotpView { - display_ctx, - totp: String::default(), - errors: LoginTotpError::Syntax, - }) - .into_response(); + let totp = match u32::from_str(login_totp_form.totp.trim()) { + Ok(val) => val, + Err(_) => { + let display_ctx = LoginDisplayCtx { + domain_info, + reauth: None, + error: None, + }; + // If not an int, we need to re-render with an error + return LoginTotpView { + display_ctx, + totp: String::default(), + errors: LoginTotpError::Syntax, + } + .into_response(); + } }; let auth_cred = AuthCredential::Totp(totp); @@ -582,6 +611,7 @@ async fn credential_step( let display_ctx = LoginDisplayCtx { domain_info, reauth: None, + error: None, }; let inter = state // This may change in the future ... @@ -612,18 +642,18 @@ async fn credential_step( { Ok(r) => r, // Okay, these errors are actually REALLY bad. - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } // Probably needs to be way nicer on login, especially something like no matching users ... - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } @@ -668,10 +698,10 @@ async fn view_login_step( // Should never happen. 0 => { error!("auth state choose allowed mechs is empty"); - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - }) + } .into_response() } 1 => { @@ -705,7 +735,7 @@ async fn view_login_step( name: m, }) .collect(); - HtmlTemplate(LoginMechView { display_ctx, mechs }).into_response() + LoginMechView { display_ctx, mechs }.into_response() } }; // break acts as return in a loop. @@ -721,48 +751,48 @@ async fn view_login_step( // Shouldn't be possible. 0 => { error!("auth state continued allowed mechs is empty"); - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - }) + } .into_response() } 1 => { let auth_allowed = allowed[0].clone(); match auth_allowed { - AuthAllowed::Totp => HtmlTemplate(LoginTotpView { + AuthAllowed::Totp => LoginTotpView { display_ctx, totp: session_context.totp.clone().unwrap_or_default(), errors: LoginTotpError::default(), - }) + } .into_response(), - AuthAllowed::Password => HtmlTemplate(LoginPasswordView { + AuthAllowed::Password => LoginPasswordView { display_ctx, password: session_context.password.clone().unwrap_or_default(), - }) + } .into_response(), AuthAllowed::BackupCode => { - HtmlTemplate(LoginBackupCodeView { display_ctx }).into_response() + LoginBackupCodeView { display_ctx }.into_response() } AuthAllowed::SecurityKey(chal) => { let chal_json = serde_json::to_string(&chal) .map_err(|_| OperationError::SerdeJsonError)?; - HtmlTemplate(LoginWebauthnView { + LoginWebauthnView { display_ctx, passkey: false, chal: chal_json, - }) + } .into_response() } AuthAllowed::Passkey(chal) => { let chal_json = serde_json::to_string(&chal) .map_err(|_| OperationError::SerdeJsonError)?; - HtmlTemplate(LoginWebauthnView { + LoginWebauthnView { display_ctx, passkey: true, chal: chal_json, - }) + } .into_response() } _ => return Err(OperationError::InvalidState), @@ -808,7 +838,7 @@ async fn view_login_step( &state, COOKIE_USERNAME, session_context.username.clone(), - "/ui/login", + Urls::Login.as_ref(), ); jar.add(username_cookie) } else { @@ -821,11 +851,11 @@ async fn view_login_step( // Now, we need to decided where to go. let res = if jar.get(COOKIE_OAUTH2_REQ).is_some() { - Redirect::to("/ui/oauth2/resume").into_response() + Redirect::to(Urls::Oauth2Resume.as_ref()).into_response() } else if let Some(auth_loc) = session_context.after_auth_loc { Redirect::to(auth_loc.as_str()).into_response() } else { - Redirect::to("/ui/apps").into_response() + Redirect::to(Urls::Apps.as_ref()).into_response() }; break res; @@ -836,11 +866,11 @@ async fn view_login_step( debug!("🧩 -> AuthState::Denied"); jar = jar.remove(Cookie::from(COOKIE_AUTH_SESSION_ID)); - break HtmlTemplate(LoginDeniedView { + break LoginDeniedView { display_ctx, reason, operation_id: kopid.eventid, - }) + } .into_response(); } } @@ -854,11 +884,16 @@ fn add_session_cookie( jar: CookieJar, session_context: &SessionContext, ) -> Result { - cookies::make_signed(state, COOKIE_AUTH_SESSION_ID, session_context, "/ui/login") - .map(|mut cookie| { - // Not needed when redirecting into this site - cookie.set_same_site(SameSite::Strict); - jar.add(cookie) - }) - .ok_or(OperationError::InvalidSessionState) + cookies::make_signed( + state, + COOKIE_AUTH_SESSION_ID, + session_context, + Urls::Login.as_ref(), + ) + .map(|mut cookie| { + // Not needed when redirecting into this site + cookie.set_same_site(SameSite::Strict); + jar.add(cookie) + }) + .ok_or(OperationError::InvalidSessionState) } diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index 603564309..9c6058a6c 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -1,14 +1,14 @@ use askama::Template; use axum::{ - http::StatusCode, - response::{Html, IntoResponse, Redirect, Response}, + response::Redirect, routing::{get, post}, Router, }; use axum_htmx::HxRequestGuardLayer; +use constants::Urls; use kanidmd_lib::prelude::{OperationError, Uuid}; use crate::https::{ @@ -34,7 +34,10 @@ struct UnrecoverableErrorView { pub fn view_router() -> Router { let unguarded_router = Router::new() - .route("/", get(|| async { Redirect::permanent("/ui/login") })) + .route( + "/", + get(|| async { Redirect::permanent(Urls::Login.as_ref()) }), + ) .route("/apps", get(apps::view_apps_get)) .route("/reset", get(reset::view_reset_get)) .route("/update_credentials", get(reset::view_self_reset_get)) @@ -90,35 +93,13 @@ pub fn view_router() -> Router { .route("/api/remove_passkey", post(reset::remove_passkey)) .route("/api/finish_passkey", post(reset::finish_passkey)) .route("/api/cancel_mfareg", post(reset::cancel_mfareg)) - .route("/api/cu_cancel", post(reset::cancel)) + .route("/api/cu_cancel", post(reset::cancel_cred_update)) .route("/api/cu_commit", post(reset::commit)) .layer(HxRequestGuardLayer::new("/ui")); Router::new().merge(unguarded_router).merge(guarded_router) } -struct HtmlTemplate(T); - -/// Allows us to convert Askama HTML templates into valid HTML for axum to serve in the response. -impl IntoResponse for HtmlTemplate -where - T: askama::Template, -{ - fn into_response(self) -> Response { - // Attempt to render the template with askama - match self.0.render() { - // If we're able to successfully parse and aggregate the template, serve it - Ok(html) => Html(html).into_response(), - // If we're not, return an error or some bit of fallback HTML - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to render template. Error: {}", err), - ) - .into_response(), - } - } -} - /// Serde deserialization decorator to map empty Strings to None, fn empty_string_as_none<'de, D, T>(de: D) -> Result, D::Error> where diff --git a/server/core/src/https/views/oauth2.rs b/server/core/src/https/views/oauth2.rs index 86511a74b..cc2168dd3 100644 --- a/server/core/src/https/views/oauth2.rs +++ b/server/core/src/https/views/oauth2.rs @@ -21,7 +21,8 @@ use axum_extra::extract::cookie::{CookieJar, SameSite}; use axum_htmx::HX_REDIRECT; use serde::Deserialize; -use super::{cookies, HtmlTemplate, UnrecoverableErrorView}; +use super::constants::Urls; +use super::{cookies, UnrecoverableErrorView}; #[derive(Template)] #[template(path = "oauth2_consent_request.html")] @@ -61,10 +62,10 @@ pub async fn view_resume_get( oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await } else { error!("unable to resume session, no auth_req was found in the cookie"); - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - }) + } .into_response() } } @@ -122,12 +123,12 @@ async fn oauth2_auth_req( consent_token, }) => { // We can just render the form now, the consent token has everything we need. - HtmlTemplate(ConsentRequestView { + ConsentRequestView { client_name, // scopes, pii_scopes, consent_token, - }) + } .into_response() } Err(Oauth2Error::AuthenticationRequired) => { @@ -140,19 +141,19 @@ async fn oauth2_auth_req( .ok_or(OperationError::InvalidSessionState); match maybe_jar { - Ok(jar) => (jar, Redirect::to("/ui/login")).into_response(), - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + Ok(jar) => (jar, Redirect::to(Urls::Login.as_ref())).into_response(), + Err(err_code) => UnrecoverableErrorView { err_code, operation_id: kopid.eventid, - }) + } .into_response(), } } Err(Oauth2Error::AccessDenied) => { // If scopes are not available for this account. - HtmlTemplate(AccessDeniedView { + AccessDeniedView { operation_id: kopid.eventid, - }) + } .into_response() } /* @@ -172,10 +173,10 @@ async fn oauth2_auth_req( &err_code.to_string() ); - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - }) + } .into_response() } } @@ -231,10 +232,10 @@ pub async fn view_consent_post( &err_code.to_string() ); - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::InvalidState, operation_id: kopid.eventid, - }) + } .into_response() } } diff --git a/server/core/src/https/views/profile.rs b/server/core/src/https/views/profile.rs index b93fd10c0..7a02bfde0 100644 --- a/server/core/src/https/views/profile.rs +++ b/server/core/src/https/views/profile.rs @@ -1,24 +1,21 @@ +use crate::https::errors::WebError; use crate::https::extractors::{DomainInfo, VerifiedClientInformation}; use crate::https::middleware::KOpId; -use crate::https::views::errors::HtmxError; -use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose}; -use crate::https::views::HtmlTemplate; use crate::https::ServerState; use askama::Template; use axum::extract::State; -use axum::http::Uri; -use axum::response::{IntoResponse, Response}; +use axum::response::Response; use axum::Extension; use axum_extra::extract::cookie::CookieJar; -use axum_htmx::{HxPushUrl, HxRequest}; -use futures_util::TryFutureExt; use kanidm_proto::internal::UserAuthToken; -use super::constants::ProfileMenuItems; +use super::constants::{ProfileMenuItems, UiMessage, Urls}; +use super::errors::HtmxError; +use super::login::{LoginDisplayCtx, Reauth, ReauthPurpose}; #[derive(Template)] #[template(path = "user_settings.html")] -struct ProfileView { +pub(crate) struct ProfileView { profile_partial: ProfilePartialView, } @@ -29,7 +26,6 @@ struct ProfilePartialView { can_rw: bool, account_name: String, display_name: String, - legal_name: String, email: Option, posix_enabled: bool, } @@ -37,40 +33,26 @@ struct ProfilePartialView { pub(crate) async fn view_profile_get( State(state): State, Extension(kopid): Extension, - HxRequest(hx_request): HxRequest, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, -) -> axum::response::Result { +) -> Result { let uat: UserAuthToken = state .qe_r_ref .handle_whoami_uat(client_auth_info, kopid.eventid) - .map_err(|op_err| HtmxError::new(&kopid, op_err)) .await?; let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0); let can_rw = uat.purpose_readwrite_active(time); - let profile_partial_view = ProfilePartialView { - menu_active_item: ProfileMenuItems::UserProfile, - can_rw, - account_name: uat.name().to_string(), - display_name: uat.displayname.clone(), - legal_name: uat.name().to_string(), - email: uat.mail_primary.clone(), - posix_enabled: false, - }; - let profile_view = ProfileView { - profile_partial: profile_partial_view.clone(), - }; - - Ok(if hx_request { - ( - HxPushUrl(Uri::from_static("/ui/profile")), - HtmlTemplate(profile_partial_view), - ) - .into_response() - } else { - HtmlTemplate(profile_view).into_response() + Ok(ProfileView { + profile_partial: ProfilePartialView { + menu_active_item: ProfileMenuItems::UserProfile, + can_rw, + account_name: uat.name().to_string(), + display_name: uat.displayname.clone(), + email: uat.mail_primary.clone(), + posix_enabled: false, + }, }) } @@ -81,12 +63,12 @@ pub(crate) async fn view_profile_unlock_get( DomainInfo(domain_info): DomainInfo, Extension(kopid): Extension, jar: CookieJar, -) -> axum::response::Result { +) -> Result { let uat: UserAuthToken = state .qe_r_ref .handle_whoami_uat(client_auth_info.clone(), kopid.eventid) - .map_err(|op_err| HtmxError::new(&kopid, op_err)) - .await?; + .await + .map_err(|op_err| HtmxError::new(&kopid, op_err))?; let display_ctx = LoginDisplayCtx { domain_info, @@ -94,15 +76,16 @@ pub(crate) async fn view_profile_unlock_get( username: uat.spn, purpose: ReauthPurpose::ProfileSettings, }), + error: None, }; - super::login::view_reauth_get( + Ok(super::login::view_reauth_get( state, client_auth_info, kopid, jar, - "/ui/profile", + Urls::Profile.as_ref(), display_ctx, ) - .await + .await) } diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs index d41e64866..3ff7f0f8d 100644 --- a/server/core/src/https/views/reset.rs +++ b/server/core/src/https/views/reset.rs @@ -24,6 +24,7 @@ use kanidm_proto::internal::{ COOKIE_CU_SESSION_TOKEN, }; +use super::constants::Urls; use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}; use crate::https::middleware::KOpId; use crate::https::views::constants::ProfileMenuItems; @@ -31,7 +32,7 @@ use crate::https::views::errors::HtmxError; use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose}; use crate::https::ServerState; -use super::{HtmlTemplate, UnrecoverableErrorView}; +use super::UnrecoverableErrorView; #[derive(Template)] #[template(path = "user_settings.html")] @@ -226,7 +227,7 @@ pub(crate) async fn commit( Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response()) } -pub(crate) async fn cancel( +pub(crate) async fn cancel_cred_update( State(state): State, Extension(kopid): Extension, HxRequest(_hx_request): HxRequest, @@ -241,7 +242,11 @@ pub(crate) async fn cancel( .map_err(|op_err| HtmxError::new(&kopid, op_err)) .await?; - Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response()) + Ok(( + HxLocation::from(Uri::from_static(Urls::Profile.as_ref())), + "", + ) + .into_response()) } pub(crate) async fn cancel_mfareg( @@ -387,23 +392,23 @@ pub(crate) async fn view_new_passkey( let response = match cu_status.mfaregstate { CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => { if let Ok(challenge) = serde_json::to_string(&chal) { - HtmlTemplate(AddPasskeyPartial { + AddPasskeyPartial { challenge, class: init_form.class, - }) + } .into_response() } else { - HtmlTemplate(UnrecoverableErrorView { + UnrecoverableErrorView { err_code: OperationError::UI0001ChallengeSerialisation, operation_id: kopid.eventid, - }) + } .into_response() } } - _ => HtmlTemplate(UnrecoverableErrorView { + _ => UnrecoverableErrorView { err_code: OperationError::UI0002InvalidState, operation_id: kopid.eventid, - }) + } .into_response(), }; @@ -472,7 +477,7 @@ pub(crate) async fn view_new_totp( ))); }; - return Ok((swapped_handler_trigger, push_url, HtmlTemplate(partial)).into_response()); + return Ok((swapped_handler_trigger, push_url, partial).into_response()); } // User has submitted a totp code @@ -521,8 +526,12 @@ pub(crate) async fn view_new_totp( } }; - let template = HtmlTemplate(AddTotpPartial { check_res }); - Ok((swapped_handler_trigger, push_url, template).into_response()) + Ok(( + swapped_handler_trigger, + push_url, + AddTotpPartial { check_res }, + ) + .into_response()) } pub(crate) async fn view_new_pwd( @@ -539,10 +548,13 @@ pub(crate) async fn view_new_pwd( let new_passwords = match opt_form { None => { - let partial = AddPasswordPartial { - check_res: PwdCheckResult::Init, - }; - return Ok((swapped_handler_trigger, HtmlTemplate(partial)).into_response()); + return Ok(( + swapped_handler_trigger, + AddPasswordPartial { + check_res: PwdCheckResult::Init, + }, + ) + .into_response()); } Some(Form(new_passwords)) => new_passwords, }; @@ -572,13 +584,12 @@ pub(crate) async fn view_new_pwd( pwd_equal, warnings, }; - let template = HtmlTemplate(AddPasswordPartial { check_res }); Ok(( status, swapped_handler_trigger, HxPushUrl(Uri::from_static("/ui/reset/change_password")), - template, + AddPasswordPartial { check_res }, ) .into_response()) } @@ -619,17 +630,18 @@ pub(crate) async fn view_self_reset_get( username: uat.spn, purpose: ReauthPurpose::ProfileSettings, }), + error: None, }; - super::login::view_reauth_get( + Ok(super::login::view_reauth_get( state, client_auth_info, kopid, jar, - "/ui/update_credentials", + Urls::UpdateCredentials.as_ref(), display_ctx, ) - .await + .await) } } @@ -655,7 +667,7 @@ pub(crate) async fn view_reset_get( Query(params): Query, mut jar: CookieJar, ) -> axum::response::Result { - let push_url = HxPushUrl(Uri::from_static("/ui/reset")); + let push_url = HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())); let cookie = jar.get(COOKIE_CU_SESSION_TOKEN); let is_logged_in = state .qe_r_ref @@ -684,10 +696,10 @@ pub(crate) async fn view_reset_get( jar = jar.remove(Cookie::from(COOKIE_CU_SESSION_TOKEN)); if let Some(token) = params.token { - let token_uri_string = format!("/ui/reset?token={token}"); - return Ok((jar, Redirect::to(token_uri_string.as_str())).into_response()); + let token_uri_string = format!("{}?token={}", Urls::CredReset, token); + return Ok((jar, Redirect::to(&token_uri_string)).into_response()); } - return Ok((jar, Redirect::to("/ui/reset")).into_response()); + return Ok((jar, Redirect::to(Urls::CredReset.as_ref())).into_response()); } Err(op_err) => return Ok(HtmxError::new(&kopid, op_err).into_response()), }; @@ -709,25 +721,30 @@ pub(crate) async fn view_reset_get( Ok((jar, cu_resp).into_response()) } Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => { - let cred_form_view = ResetCredFormView { - domain_info, - wrong_code: true, - }; - // Reset code expired - Ok((push_url, HtmlTemplate(cred_form_view)).into_response()) + Ok(( + push_url, + ResetCredFormView { + domain_info, + wrong_code: true, + }, + ) + .into_response()) } Err(op_err) => Err(ErrorResponse::from( HtmxError::new(&kopid, op_err).into_response(), )), } } else { - let cred_form_view = ResetCredFormView { - domain_info, - wrong_code: false, - }; // We don't have any credential, show reset token input form - Ok((push_url, HtmlTemplate(cred_form_view)).into_response()) + Ok(( + push_url, + ResetCredFormView { + domain_info, + wrong_code: false, + }, + ) + .into_response()) } } @@ -759,11 +776,11 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView { fn get_cu_partial_response(cu_status: CUStatus) -> Response { let credentials_update_partial = get_cu_partial(cu_status); ( - HxPushUrl(Uri::from_static("/ui/reset")), + HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())), HxRetarget("#credentialUpdateDynamicSection".to_string()), HxReselect("#credentialUpdateDynamicSection".to_string()), HxReswap(SwapOption::OuterHtml), - HtmlTemplate(credentials_update_partial), + credentials_update_partial, ) .into_response() } @@ -788,23 +805,22 @@ fn get_cu_response( // TODO: fill in posix enabled posix_enabled: false, }; - let profile_view = ProfileView { - profile_partial: cred_status_view, - }; ( - HxPushUrl(Uri::from_static("/ui/update_credentials")), - HtmlTemplate(profile_view), + HxPushUrl(Uri::from_static(Urls::UpdateCredentials.as_ref())), + ProfileView { + profile_partial: cred_status_view, + }, ) .into_response() } else { ( - HxPushUrl(Uri::from_static("/ui/reset")), - HtmlTemplate(CredResetView { + HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())), + CredResetView { domain_info, names, credentials_update_partial, - }), + }, ) .into_response() } @@ -819,6 +835,10 @@ async fn get_cu_session(jar: CookieJar) -> Result { }; Ok(cu_session_token) } else { - Err((StatusCode::FORBIDDEN, Redirect::to("/ui/reset")).into_response()) + Err(( + StatusCode::FORBIDDEN, + Redirect::to(Urls::CredReset.as_ref()), + ) + .into_response()) } } diff --git a/server/core/templates/credential_update_add_password_partial.html b/server/core/templates/credential_update_add_password_partial.html index 8f900b9c2..567a4066e 100644 --- a/server/core/templates/credential_update_add_password_partial.html +++ b/server/core/templates/credential_update_add_password_partial.html @@ -54,7 +54,7 @@
- + + hx-target="#main">Discard Changes diff --git a/server/core/templates/login.html b/server/core/templates/login.html index 7af6a8a1f..c52d2d90b 100644 --- a/server/core/templates/login.html +++ b/server/core/templates/login.html @@ -1,6 +1,13 @@ (% extends "login_base.html" %) (% block logincontainer %) +(% if let Some(error) = display_ctx.error %) + +(% endif %) + +
diff --git a/server/core/templates/login_base.html b/server/core/templates/login_base.html index 655f931c9..d8237e0c5 100644 --- a/server/core/templates/login_base.html +++ b/server/core/templates/login_base.html @@ -18,9 +18,11 @@ (% endif %)

Kanidm

(% if let Some(reauth) = display_ctx.reauth %) -
Reauthenticating as (( reauth.username )) to access (( reauth.purpose ))
+
Reauthenticating as (( reauth.username )) to access (( reauth.purpose + ))
(% endif %) +
(% block logincontainer %) (% endblock %) diff --git a/server/core/templates/login_denied.html b/server/core/templates/login_denied.html index 7baea4754..d8e2c684b 100644 --- a/server/core/templates/login_denied.html +++ b/server/core/templates/login_denied.html @@ -5,7 +5,7 @@

Reason: (( reason ))

Operation ID: (( operation_id ))

- +
diff --git a/server/core/templates/navbar.html b/server/core/templates/navbar.html index 2e170eabf..0e3d9d2d0 100644 --- a/server/core/templates/navbar.html +++ b/server/core/templates/navbar.html @@ -16,14 +16,12 @@