yale's rabbit-hole-chasing-htmx-fixing-megapatch (#3135)

This commit is contained in:
James Hodgkinson 2024-10-23 16:04:38 +10:00 committed by GitHub
parent 31420c3ff9
commit bbe9ad1a06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 340 additions and 262 deletions

12
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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:

View file

@ -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"

View file

@ -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()
})

View file

@ -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<str> 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())
}
}

View file

@ -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(),
}

View file

@ -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<Reauth>,
pub error: Option<LoginError>,
}
#[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> {
) -> 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<LoginTotpForm>,
) -> 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<CookieJar, OperationError> {
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)
}

View file

@ -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<ServerState> {
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<ServerState> {
.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>(T);
/// Allows us to convert Askama HTML templates into valid HTML for axum to serve in the response.
impl<T> IntoResponse for HtmlTemplate<T>
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<Option<T>, D::Error>
where

View file

@ -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()
}
}

View file

@ -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<String>,
posix_enabled: bool,
}
@ -37,40 +33,26 @@ struct ProfilePartialView {
pub(crate) async fn view_profile_get(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
HxRequest(hx_request): HxRequest,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
) -> axum::response::Result<Response> {
) -> Result<ProfileView, WebError> {
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<KOpId>,
jar: CookieJar,
) -> axum::response::Result<Response> {
) -> Result<Response, HtmxError> {
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)
}

View file

@ -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<ServerState>,
Extension(kopid): Extension<KOpId>,
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<ResetTokenParam>,
mut jar: CookieJar,
) -> axum::response::Result<Response> {
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<CUSessionToken, Response> {
};
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())
}
}

View file

@ -54,7 +54,7 @@
</div>
</form>
<div class="g-3 d-flex justify-content-end" hx-target="#credentialUpdateDynamicSection">
<button id="password-cancel" type="button" class="btn btn-danger me-2" hx-get="/ui/reset" hx-target="body">Cancel</button>
<button id="password-cancel" type="button" class="btn btn-danger me-2" hx-get=(Urls::CredReset) hx-target="body">Cancel</button>
<button id="password-submit" type="button" class="btn btn-primary"
hx-post="/ui/reset/add_password"
hx-include="#newPasswordForm"

View file

@ -50,7 +50,7 @@
Return to the home page
</button>
<button class="btn btn-primary"
hx-get="/ui/reset"
hx-get=(Urls::CredReset)
hx-include="#token"
hx-target="#cred-reset-form" hx-select="#cred-reset-form"
hx-swap="outerHTML"

View file

@ -6,8 +6,8 @@
<div class="d-flex align-items-start form-cred-reset-body">
<div class="w-100">
<div class="py-3">
<p>(( names ))</p>
<p>(( domain_info.display_name() ))</p>
<p><strong>User:</strong> (( names ))</p>
<p><strong>Kanidm domain:</strong> (( domain_info.display_name() ))</p>
</div>
(( credentials_update_partial|safe ))
</div>

View file

@ -118,19 +118,19 @@
<div class="toast-body">
<span class="d-flex align-items-center">
<div>
<svg xmlns="http://www.w3.org/2000/svg" width="16"
height="16" fill="currentColor"
class="bi bi-floppy2-fill" viewBox="0 0 16 16">
<path d="M12 2h-2v3h2z" />
<path
d="M1.5 0A1.5 1.5 0 0 0 0 1.5v13A1.5 1.5 0 0 0 1.5 16h13a1.5 1.5 0 0 0 1.5-1.5V2.914a1.5 1.5 0 0 0-.44-1.06L14.147.439A1.5 1.5 0 0 0 13.086 0zM4 6a1 1 0 0 1-1-1V1h10v4a1 1 0 0 1-1 1zM3 9h10a1 1 0 0 1 1 1v5H2v-5a1 1 0 0 1 1-1" />
</svg>
<b>Careful</b> - Unsaved changes will be lost</div>
<svg xmlns="http://www.w3.org/2000/svg" width="16"
height="16" fill="currentColor"
class="bi bi-floppy2-fill" viewBox="0 0 16 16">
<path d="M12 2h-2v3h2z" />
<path
d="M1.5 0A1.5 1.5 0 0 0 0 1.5v13A1.5 1.5 0 0 0 1.5 16h13a1.5 1.5 0 0 0 1.5-1.5V2.914a1.5 1.5 0 0 0-.44-1.06L14.147.439A1.5 1.5 0 0 0 13.086 0zM4 6a1 1 0 0 1-1-1V1h10v4a1 1 0 0 1-1 1zM3 9h10a1 1 0 0 1 1 1v5H2v-5a1 1 0 0 1 1-1" />
</svg>
<b>Careful</b> - Unsaved changes will be lost</div>
</span>
<div class="mt-2 pt-2 border-top">
<button class="btn btn-danger"
hx-post="/ui/api/cu_cancel"
hx-target="body">Discard Changes</button>
hx-target="#main">Discard Changes</button>
<span class="d-inline-block" tabindex="0"
data-bs-toggle="tooltip"
data-bs-title="Unresolved warnings">

View file

@ -1,6 +1,13 @@
(% extends "login_base.html" %)
(% block logincontainer %)
(% if let Some(error) = display_ctx.error %)
<div class="alert alert-danger" role="alert">
(( error ))
</div>
(% endif %)
<label for="username" class="form-label">Username</label>
<form id="login" action="/ui/login/begin" method="post">
<div class="input-group mb-3">

View file

@ -18,9 +18,11 @@
(% endif %)
<h3>Kanidm</h3>
(% if let Some(reauth) = display_ctx.reauth %)
<h5>Reauthenticating as (( reauth.username )) to access (( reauth.purpose ))</h5>
<h5>Reauthenticating as (( reauth.username )) to access (( reauth.purpose
))</h5>
(% endif %)
</center>
<div id="login-form-container" class="container">
(% block logincontainer %)
(% endblock %)

View file

@ -5,7 +5,7 @@
<main id="main">
<p>Reason: (( reason ))</p>
<p>Operation ID: (( operation_id ))</p>
<a href="/ui/login">
<a href=((Urls::Login.as_ref()))>
<button type="button" class="btn btn-success">Return to Login</button>
</a>
</main>

View file

@ -16,14 +16,12 @@
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="(( crate::https::ui::CSS_NAVBAR_LINKS_UL ))">
<li class="mb-1">
<a class="nav-link" href="/ui/apps" hx-target="main"
hx-select="main" hx-swap="outerHTML"><span
data-feather="file"></span>Apps</a>
<a class="nav-link" href=((Urls::Apps))>
<span data-feather="file"></span>Apps</a>
</li>
<li class="mb-1">
<a class="nav-link" href="/ui/profile" hx-target="main"
hx-select="main" hx-swap="outerHTML"><span
data-feather="file"></span>Profile</a>
<a class="nav-link" href=((Urls::Profile))>
<span data-feather="file"></span>Profile</a>
</li>
<li class="mb-1">
<a class="nav-link" href="#" data-bs-toggle="modal"

View file

@ -8,18 +8,16 @@
</a>
(% endmacro %)
<main class="container-xxl pb-5">
<main id="main" class="container-xxl pb-5">
<div class="d-flex flex-sm-row flex-column">
<div class="list-group side-menu flex-shrink-0">
(% call side_menu_item("Profile", "/ui/profile",
(% call side_menu_item("Profile", (Urls::Profile),
ProfileMenuItems::UserProfile, "person") %)
(% call side_menu_item("SSH Keys", "/ui/ssh_keys",
ProfileMenuItems::SshKeys, "key") %)
(% if posix_enabled %)
(% call side_menu_item("UNIX Password", "/ui/update_credentials",
(% call side_menu_item("UNIX Password", (Urls::UpdateCredentials),
ProfileMenuItems::UnixPassword, "building-lock") %)
(% endif %)
(% call side_menu_item("Credentials", "/ui/update_credentials",
(% call side_menu_item("Credentials", (Urls::UpdateCredentials),
ProfileMenuItems::Credentials, "shield-lock") %)
</div>
<div id="settings-window" class="flex-grow-1 ps-sm-4 pt-sm-0 pt-4">

View file

@ -13,8 +13,8 @@ Unix/Posix Settings
(% if can_rw %)
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_posix">Edit</button>
(% else %)
<a href="/ui/profile/unlock" hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button>
<a href=(Urls::ProfileUnlock) hx-boost="false">
<button class="btn btn-primary" type="button">((UiMessage::UnlockEdit))</button>
</a>
(% endif %)
(% endblock %)

View file

@ -13,38 +13,31 @@ Profile
<input type="text" readonly class="form-control-plaintext" id="profileUserName" value="(( account_name ))">
</div>
</div>
<div class="mb-2 row">
<label for="profileDisplayName" class="col-12 col-md-3 col-lg-2 col-form-label">Display name</label>
<div class="col-12 col-md-6 col-lg-4">
<input type="text" class="form-control" id="profileDisplayName" value="(( display_name ))">
<input type="text" class="form-control-plaintext" id="profileDisplayName" value="(( display_name ))" disabled>
</div>
</div>
<div class="mb-2 row">
<label for="profileLegalName" class="col-12 col-md-3 col-lg-2 col-form-label">Legal name</label>
<div class="col-12 col-md-6 col-lg-4">
<input type="text" class="form-control" id="profileLegalName" value="(( legal_name ))">
</div>
</div>
<div class="mb-2 row">
<label for="profileEmail" class="col-12 col-md-3 col-lg-2 col-form-label">Email</label>
<div class="col-12 col-md-6 col-lg-4">
<input type="email" class="form-control" id="profileEmail" (% if let Some(email) = email %)value="(( email ))"(% endif %)>
<input type="email" disabled class="form-control-plaintext" id="profileEmail" value="(( email.clone().unwrap_or("None configured".to_string())))" >
</div>
</div>
<!-- Edit button -->
<div class="pt-4">
<!-- <div class="pt-4">
(% if can_rw %)
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_profile">Edit</button>
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_profile" disabled>Edit (Currently Not Working!)</button>
(% else %)
<a href="/ui/profile/unlock" hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button>
<a href=(Urls::ProfileUnlock) hx-boost="false">
<button class="btn btn-primary" type="button">((UiMessage::UnlockEdit))</button>
</a>
(% endif %)
</div>
</div> -->
</form>
(% endblock %)