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", "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]] [[package]]
name = "askama_derive" name = "askama_derive"
version = "0.12.5" version = "0.12.5"
@ -3434,6 +3445,7 @@ name = "kanidmd_core"
version = "1.4.0-dev" version = "1.4.0-dev"
dependencies = [ dependencies = [
"askama", "askama",
"askama_axum",
"async-trait", "async-trait",
"axum 0.7.7", "axum 0.7.7",
"axum-auth", "axum-auth",

View file

@ -141,7 +141,8 @@ sketching = { path = "./libs/sketching", version = "=1.4.0-dev" }
anyhow = { version = "1.0.90" } anyhow = { version = "1.0.90" }
argon2 = { version = "0.5.3", features = ["alloc"] } 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" async-trait = "^0.1.83"
axum = { version = "0.7.7", features = [ axum = { version = "0.7.7", features = [
"form", "form",
@ -248,7 +249,9 @@ reqwest = { version = "0.12.8", default-features = false, features = [
] } ] }
rpassword = "^7.3.1" rpassword = "^7.3.1"
rusqlite = { version = "^0.28.0", features = ["array", "bundled"] } 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" sd-notify = "^0.4.3"
selinux = "^0.4.6" selinux = "^0.4.6"

View file

@ -39,6 +39,11 @@ run: ## Run the test/dev server
run: run:
cd server/daemon && ./run_insecure_dev_server.sh 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 .PHONY: buildx/kanidmd
buildx/kanidmd: ## Build multiarch kanidm server images and push to docker hub buildx/kanidmd: ## Build multiarch kanidm server images and push to docker hub
buildx/kanidmd: buildx/kanidmd:

View file

@ -21,6 +21,7 @@ ui_htmx = []
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
askama = { workspace = true } askama = { workspace = true }
askama_axum = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
axum-htmx = { workspace = true } axum-htmx = { workspace = true }
axum-auth = "0.7.0" axum-auth = "0.7.0"

View file

@ -9,7 +9,7 @@ use axum_htmx::HxPushUrl;
use kanidm_proto::internal::AppLink; use kanidm_proto::internal::AppLink;
use super::HtmlTemplate; use super::constants::Urls;
use crate::https::views::errors::HtmxError; use crate::https::views::errors::HtmxError;
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
@ -40,13 +40,12 @@ pub(crate) async fn view_apps_get(
.await .await
.map_err(|old| HtmxError::new(&kopid, old))?; .map_err(|old| HtmxError::new(&kopid, old))?;
let apps_partial = AppsPartialView { apps: app_links };
Ok({ Ok({
let apps_view = AppsView { apps_partial };
( (
HxPushUrl(Uri::from_static("/ui/apps")), HxPushUrl(Uri::from_static(Urls::Apps.as_ref())),
HtmlTemplate(apps_view).into_response(), AppsView {
apps_partial: AppsPartialView { apps: app_links },
},
) )
.into_response() .into_response()
}) })

View file

@ -4,7 +4,47 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub(crate) enum ProfileMenuItems { pub(crate) enum ProfileMenuItems {
UserProfile, UserProfile,
SshKeys,
Credentials, Credentials,
UnixPassword, 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 kanidm_proto::internal::OperationError;
use crate::https::middleware::KOpId; use crate::https::middleware::KOpId;
use crate::https::views::{HtmlTemplate, UnrecoverableErrorView}; use crate::https::views::UnrecoverableErrorView;
// #[derive(Template)] // #[derive(Template)]
// #[template(path = "recoverable_error_partial.html")] // #[template(path = "recoverable_error_partial.html")]
// struct ErrorPartialView { // struct ErrorPartialView {
@ -55,10 +55,10 @@ impl IntoResponse for HtmxError {
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
HxRetarget("body".to_string()), HxRetarget("body".to_string()),
HxReswap(SwapOption::OuterHtml), HxReswap(SwapOption::OuterHtml),
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: inner, err_code: inner,
operation_id: kopid, operation_id: kopid,
}), },
) )
.into_response(), .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::views::errors::HtmxError;
use crate::https::{ use crate::https::{
extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}, extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation},
@ -53,7 +54,19 @@ pub enum ReauthPurpose {
impl fmt::Display for ReauthPurpose { impl fmt::Display for ReauthPurpose {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { 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, pub domain_info: DomainInfoRead,
// We only need this on the first re-auth screen to indicate what we are doing // We only need this on the first re-auth screen to indicate what we are doing
pub reauth: Option<Reauth>, pub reauth: Option<Reauth>,
pub error: Option<LoginError>,
} }
#[derive(Template)] #[derive(Template)]
@ -146,13 +160,13 @@ pub async fn view_logout_get(
.handle_logout(client_auth_info, kopid.eventid) .handle_logout(client_auth_info, kopid.eventid)
.await .await
{ {
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} else { } 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); jar = cookies::destroy(jar, COOKIE_BEARER_TOKEN);
@ -167,13 +181,13 @@ pub async fn view_reauth_get(
jar: CookieJar, jar: CookieJar,
return_location: &str, return_location: &str,
display_ctx: LoginDisplayCtx, display_ctx: LoginDisplayCtx,
) -> axum::response::Result<Response> { ) -> Response {
let session_valid_result = state let session_valid_result = state
.qe_r_ref .qe_r_ref
.handle_auth_valid(client_auth_info.clone(), kopid.eventid) .handle_auth_valid(client_auth_info.clone(), kopid.eventid)
.await; .await;
let res = match session_valid_result { match session_valid_result {
Ok(()) => { Ok(()) => {
let inter = state let inter = state
.qe_r_ref .qe_r_ref
@ -209,18 +223,18 @@ pub async fn view_reauth_get(
{ {
Ok(r) => r, Ok(r) => r,
// Okay, these errors are actually REALLY bad. // Okay, these errors are actually REALLY bad.
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
// Probably needs to be way nicer on login, especially something like no matching users ... // 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, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
@ -233,21 +247,19 @@ pub async fn view_reauth_get(
let remember_me = !username.is_empty(); let remember_me = !username.is_empty();
HtmlTemplate(LoginView { LoginView {
display_ctx, display_ctx,
username, username,
remember_me, remember_me,
}) }
.into_response() .into_response()
} }
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
}; }
Ok(res)
} }
pub async fn view_index_get( pub async fn view_index_get(
@ -266,7 +278,7 @@ pub async fn view_index_get(
match session_valid_result { match session_valid_result {
Ok(()) => { Ok(()) => {
// Send the user to the landing. // 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) => { Err(OperationError::NotAuthenticated) | Err(OperationError::SessionExpired) => {
// cookie jar with remember me. // cookie jar with remember me.
@ -280,19 +292,20 @@ pub async fn view_index_get(
let display_ctx = LoginDisplayCtx { let display_ctx = LoginDisplayCtx {
domain_info, domain_info,
reauth: None, reauth: None,
error: None,
}; };
HtmlTemplate(LoginView { LoginView {
display_ctx, display_ctx,
username, username,
remember_me, remember_me,
}) }
.into_response() .into_response()
} }
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
@ -346,16 +359,17 @@ pub async fn view_login_begin_post(
let session_context = SessionContext { let session_context = SessionContext {
id: None, id: None,
username, username: username.clone(),
password, password,
totp, totp,
remember_me, remember_me,
after_auth_loc: None, after_auth_loc: None,
}; };
let display_ctx = LoginDisplayCtx { let mut display_ctx = LoginDisplayCtx {
domain_info, domain_info,
reauth: None, reauth: None,
error: None,
}; };
// Now process the response if ok. // Now process the response if ok.
@ -374,19 +388,30 @@ pub async fn view_login_begin_post(
{ {
Ok(r) => r, Ok(r) => r,
// Okay, these errors are actually REALLY bad. // Okay, these errors are actually REALLY bad.
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
// Probably needs to be way nicer on login, especially something like no matching users ... // Probably needs to be way nicer on login, especially something like no matching users ...
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { 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, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
},
} }
} }
@ -426,6 +451,7 @@ pub async fn view_login_mech_choose_post(
let display_ctx = LoginDisplayCtx { let display_ctx = LoginDisplayCtx {
domain_info, domain_info,
reauth: None, reauth: None,
error: None,
}; };
// Now process the response if ok. // Now process the response if ok.
@ -444,18 +470,18 @@ pub async fn view_login_mech_choose_post(
{ {
Ok(r) => r, Ok(r) => r,
// Okay, these errors are actually REALLY bad. // Okay, these errors are actually REALLY bad.
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
// Probably needs to be way nicer on login, especially something like no matching users ... // 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, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
@ -474,19 +500,22 @@ pub async fn view_login_totp_post(
Form(login_totp_form): Form<LoginTotpForm>, Form(login_totp_form): Form<LoginTotpForm>,
) -> Response { ) -> Response {
// trim leading and trailing white space. // trim leading and trailing white space.
let Ok(totp) = u32::from_str(login_totp_form.totp.trim()) else { let totp = match u32::from_str(login_totp_form.totp.trim()) {
Ok(val) => val,
Err(_) => {
let display_ctx = LoginDisplayCtx { let display_ctx = LoginDisplayCtx {
domain_info, domain_info,
reauth: None, reauth: None,
error: None,
}; };
// If not an int, we need to re-render with an error // If not an int, we need to re-render with an error
return HtmlTemplate(LoginTotpView { return LoginTotpView {
display_ctx, display_ctx,
totp: String::default(), totp: String::default(),
errors: LoginTotpError::Syntax, errors: LoginTotpError::Syntax,
}) }
.into_response(); .into_response();
}
}; };
let auth_cred = AuthCredential::Totp(totp); let auth_cred = AuthCredential::Totp(totp);
@ -582,6 +611,7 @@ async fn credential_step(
let display_ctx = LoginDisplayCtx { let display_ctx = LoginDisplayCtx {
domain_info, domain_info,
reauth: None, reauth: None,
error: None,
}; };
let inter = state // This may change in the future ... let inter = state // This may change in the future ...
@ -612,18 +642,18 @@ async fn credential_step(
{ {
Ok(r) => r, Ok(r) => r,
// Okay, these errors are actually REALLY bad. // Okay, these errors are actually REALLY bad.
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
// Probably needs to be way nicer on login, especially something like no matching users ... // 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, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
@ -668,10 +698,10 @@ async fn view_login_step(
// Should never happen. // Should never happen.
0 => { 0 => {
error!("auth state choose allowed mechs is empty"); error!("auth state choose allowed mechs is empty");
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::InvalidState, err_code: OperationError::InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
1 => { 1 => {
@ -705,7 +735,7 @@ async fn view_login_step(
name: m, name: m,
}) })
.collect(); .collect();
HtmlTemplate(LoginMechView { display_ctx, mechs }).into_response() LoginMechView { display_ctx, mechs }.into_response()
} }
}; };
// break acts as return in a loop. // break acts as return in a loop.
@ -721,48 +751,48 @@ async fn view_login_step(
// Shouldn't be possible. // Shouldn't be possible.
0 => { 0 => {
error!("auth state continued allowed mechs is empty"); error!("auth state continued allowed mechs is empty");
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::InvalidState, err_code: OperationError::InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
1 => { 1 => {
let auth_allowed = allowed[0].clone(); let auth_allowed = allowed[0].clone();
match auth_allowed { match auth_allowed {
AuthAllowed::Totp => HtmlTemplate(LoginTotpView { AuthAllowed::Totp => LoginTotpView {
display_ctx, display_ctx,
totp: session_context.totp.clone().unwrap_or_default(), totp: session_context.totp.clone().unwrap_or_default(),
errors: LoginTotpError::default(), errors: LoginTotpError::default(),
}) }
.into_response(), .into_response(),
AuthAllowed::Password => HtmlTemplate(LoginPasswordView { AuthAllowed::Password => LoginPasswordView {
display_ctx, display_ctx,
password: session_context.password.clone().unwrap_or_default(), password: session_context.password.clone().unwrap_or_default(),
}) }
.into_response(), .into_response(),
AuthAllowed::BackupCode => { AuthAllowed::BackupCode => {
HtmlTemplate(LoginBackupCodeView { display_ctx }).into_response() LoginBackupCodeView { display_ctx }.into_response()
} }
AuthAllowed::SecurityKey(chal) => { AuthAllowed::SecurityKey(chal) => {
let chal_json = serde_json::to_string(&chal) let chal_json = serde_json::to_string(&chal)
.map_err(|_| OperationError::SerdeJsonError)?; .map_err(|_| OperationError::SerdeJsonError)?;
HtmlTemplate(LoginWebauthnView { LoginWebauthnView {
display_ctx, display_ctx,
passkey: false, passkey: false,
chal: chal_json, chal: chal_json,
}) }
.into_response() .into_response()
} }
AuthAllowed::Passkey(chal) => { AuthAllowed::Passkey(chal) => {
let chal_json = serde_json::to_string(&chal) let chal_json = serde_json::to_string(&chal)
.map_err(|_| OperationError::SerdeJsonError)?; .map_err(|_| OperationError::SerdeJsonError)?;
HtmlTemplate(LoginWebauthnView { LoginWebauthnView {
display_ctx, display_ctx,
passkey: true, passkey: true,
chal: chal_json, chal: chal_json,
}) }
.into_response() .into_response()
} }
_ => return Err(OperationError::InvalidState), _ => return Err(OperationError::InvalidState),
@ -808,7 +838,7 @@ async fn view_login_step(
&state, &state,
COOKIE_USERNAME, COOKIE_USERNAME,
session_context.username.clone(), session_context.username.clone(),
"/ui/login", Urls::Login.as_ref(),
); );
jar.add(username_cookie) jar.add(username_cookie)
} else { } else {
@ -821,11 +851,11 @@ async fn view_login_step(
// Now, we need to decided where to go. // Now, we need to decided where to go.
let res = if jar.get(COOKIE_OAUTH2_REQ).is_some() { 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 { } else if let Some(auth_loc) = session_context.after_auth_loc {
Redirect::to(auth_loc.as_str()).into_response() Redirect::to(auth_loc.as_str()).into_response()
} else { } else {
Redirect::to("/ui/apps").into_response() Redirect::to(Urls::Apps.as_ref()).into_response()
}; };
break res; break res;
@ -836,11 +866,11 @@ async fn view_login_step(
debug!("🧩 -> AuthState::Denied"); debug!("🧩 -> AuthState::Denied");
jar = jar.remove(Cookie::from(COOKIE_AUTH_SESSION_ID)); jar = jar.remove(Cookie::from(COOKIE_AUTH_SESSION_ID));
break HtmlTemplate(LoginDeniedView { break LoginDeniedView {
display_ctx, display_ctx,
reason, reason,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(); .into_response();
} }
} }
@ -854,7 +884,12 @@ fn add_session_cookie(
jar: CookieJar, jar: CookieJar,
session_context: &SessionContext, session_context: &SessionContext,
) -> Result<CookieJar, OperationError> { ) -> Result<CookieJar, OperationError> {
cookies::make_signed(state, COOKIE_AUTH_SESSION_ID, session_context, "/ui/login") cookies::make_signed(
state,
COOKIE_AUTH_SESSION_ID,
session_context,
Urls::Login.as_ref(),
)
.map(|mut cookie| { .map(|mut cookie| {
// Not needed when redirecting into this site // Not needed when redirecting into this site
cookie.set_same_site(SameSite::Strict); cookie.set_same_site(SameSite::Strict);

View file

@ -1,14 +1,14 @@
use askama::Template; use askama::Template;
use axum::{ use axum::{
http::StatusCode, response::Redirect,
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use axum_htmx::HxRequestGuardLayer; use axum_htmx::HxRequestGuardLayer;
use constants::Urls;
use kanidmd_lib::prelude::{OperationError, Uuid}; use kanidmd_lib::prelude::{OperationError, Uuid};
use crate::https::{ use crate::https::{
@ -34,7 +34,10 @@ struct UnrecoverableErrorView {
pub fn view_router() -> Router<ServerState> { pub fn view_router() -> Router<ServerState> {
let unguarded_router = Router::new() 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("/apps", get(apps::view_apps_get))
.route("/reset", get(reset::view_reset_get)) .route("/reset", get(reset::view_reset_get))
.route("/update_credentials", get(reset::view_self_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/remove_passkey", post(reset::remove_passkey))
.route("/api/finish_passkey", post(reset::finish_passkey)) .route("/api/finish_passkey", post(reset::finish_passkey))
.route("/api/cancel_mfareg", post(reset::cancel_mfareg)) .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)) .route("/api/cu_commit", post(reset::commit))
.layer(HxRequestGuardLayer::new("/ui")); .layer(HxRequestGuardLayer::new("/ui"));
Router::new().merge(unguarded_router).merge(guarded_router) 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, /// Serde deserialization decorator to map empty Strings to None,
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error> fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where where

View file

@ -21,7 +21,8 @@ use axum_extra::extract::cookie::{CookieJar, SameSite};
use axum_htmx::HX_REDIRECT; use axum_htmx::HX_REDIRECT;
use serde::Deserialize; use serde::Deserialize;
use super::{cookies, HtmlTemplate, UnrecoverableErrorView}; use super::constants::Urls;
use super::{cookies, UnrecoverableErrorView};
#[derive(Template)] #[derive(Template)]
#[template(path = "oauth2_consent_request.html")] #[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 oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await
} else { } else {
error!("unable to resume session, no auth_req was found in the cookie"); error!("unable to resume session, no auth_req was found in the cookie");
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::InvalidState, err_code: OperationError::InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
} }
@ -122,12 +123,12 @@ async fn oauth2_auth_req(
consent_token, consent_token,
}) => { }) => {
// We can just render the form now, the consent token has everything we need. // We can just render the form now, the consent token has everything we need.
HtmlTemplate(ConsentRequestView { ConsentRequestView {
client_name, client_name,
// scopes, // scopes,
pii_scopes, pii_scopes,
consent_token, consent_token,
}) }
.into_response() .into_response()
} }
Err(Oauth2Error::AuthenticationRequired) => { Err(Oauth2Error::AuthenticationRequired) => {
@ -140,19 +141,19 @@ async fn oauth2_auth_req(
.ok_or(OperationError::InvalidSessionState); .ok_or(OperationError::InvalidSessionState);
match maybe_jar { match maybe_jar {
Ok(jar) => (jar, Redirect::to("/ui/login")).into_response(), Ok(jar) => (jar, Redirect::to(Urls::Login.as_ref())).into_response(),
Err(err_code) => HtmlTemplate(UnrecoverableErrorView { Err(err_code) => UnrecoverableErrorView {
err_code, err_code,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .into_response(),
} }
} }
Err(Oauth2Error::AccessDenied) => { Err(Oauth2Error::AccessDenied) => {
// If scopes are not available for this account. // If scopes are not available for this account.
HtmlTemplate(AccessDeniedView { AccessDeniedView {
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
/* /*
@ -172,10 +173,10 @@ async fn oauth2_auth_req(
&err_code.to_string() &err_code.to_string()
); );
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::InvalidState, err_code: OperationError::InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
} }
@ -231,10 +232,10 @@ pub async fn view_consent_post(
&err_code.to_string() &err_code.to_string()
); );
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::InvalidState, err_code: OperationError::InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
} }

View file

@ -1,24 +1,21 @@
use crate::https::errors::WebError;
use crate::https::extractors::{DomainInfo, VerifiedClientInformation}; use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
use crate::https::middleware::KOpId; 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 crate::https::ServerState;
use askama::Template; use askama::Template;
use axum::extract::State; use axum::extract::State;
use axum::http::Uri; use axum::response::Response;
use axum::response::{IntoResponse, Response};
use axum::Extension; use axum::Extension;
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use axum_htmx::{HxPushUrl, HxRequest};
use futures_util::TryFutureExt;
use kanidm_proto::internal::UserAuthToken; 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)] #[derive(Template)]
#[template(path = "user_settings.html")] #[template(path = "user_settings.html")]
struct ProfileView { pub(crate) struct ProfileView {
profile_partial: ProfilePartialView, profile_partial: ProfilePartialView,
} }
@ -29,7 +26,6 @@ struct ProfilePartialView {
can_rw: bool, can_rw: bool,
account_name: String, account_name: String,
display_name: String, display_name: String,
legal_name: String,
email: Option<String>, email: Option<String>,
posix_enabled: bool, posix_enabled: bool,
} }
@ -37,40 +33,26 @@ struct ProfilePartialView {
pub(crate) async fn view_profile_get( pub(crate) async fn view_profile_get(
State(state): State<ServerState>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
HxRequest(hx_request): HxRequest,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation, VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
) -> axum::response::Result<Response> { ) -> Result<ProfileView, WebError> {
let uat: UserAuthToken = state let uat: UserAuthToken = state
.qe_r_ref .qe_r_ref
.handle_whoami_uat(client_auth_info, kopid.eventid) .handle_whoami_uat(client_auth_info, kopid.eventid)
.map_err(|op_err| HtmxError::new(&kopid, op_err))
.await?; .await?;
let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0); let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
let can_rw = uat.purpose_readwrite_active(time); let can_rw = uat.purpose_readwrite_active(time);
let profile_partial_view = ProfilePartialView { Ok(ProfileView {
profile_partial: ProfilePartialView {
menu_active_item: ProfileMenuItems::UserProfile, menu_active_item: ProfileMenuItems::UserProfile,
can_rw, can_rw,
account_name: uat.name().to_string(), account_name: uat.name().to_string(),
display_name: uat.displayname.clone(), display_name: uat.displayname.clone(),
legal_name: uat.name().to_string(),
email: uat.mail_primary.clone(), email: uat.mail_primary.clone(),
posix_enabled: false, 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()
}) })
} }
@ -81,12 +63,12 @@ pub(crate) async fn view_profile_unlock_get(
DomainInfo(domain_info): DomainInfo, DomainInfo(domain_info): DomainInfo,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
jar: CookieJar, jar: CookieJar,
) -> axum::response::Result<Response> { ) -> Result<Response, HtmxError> {
let uat: UserAuthToken = state let uat: UserAuthToken = state
.qe_r_ref .qe_r_ref
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid) .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 { let display_ctx = LoginDisplayCtx {
domain_info, domain_info,
@ -94,15 +76,16 @@ pub(crate) async fn view_profile_unlock_get(
username: uat.spn, username: uat.spn,
purpose: ReauthPurpose::ProfileSettings, purpose: ReauthPurpose::ProfileSettings,
}), }),
error: None,
}; };
super::login::view_reauth_get( Ok(super::login::view_reauth_get(
state, state,
client_auth_info, client_auth_info,
kopid, kopid,
jar, jar,
"/ui/profile", Urls::Profile.as_ref(),
display_ctx, display_ctx,
) )
.await .await)
} }

View file

@ -24,6 +24,7 @@ use kanidm_proto::internal::{
COOKIE_CU_SESSION_TOKEN, COOKIE_CU_SESSION_TOKEN,
}; };
use super::constants::Urls;
use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation}; use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation};
use crate::https::middleware::KOpId; use crate::https::middleware::KOpId;
use crate::https::views::constants::ProfileMenuItems; 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::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
use crate::https::ServerState; use crate::https::ServerState;
use super::{HtmlTemplate, UnrecoverableErrorView}; use super::UnrecoverableErrorView;
#[derive(Template)] #[derive(Template)]
#[template(path = "user_settings.html")] #[template(path = "user_settings.html")]
@ -226,7 +227,7 @@ pub(crate) async fn commit(
Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response()) 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>, State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>, Extension(kopid): Extension<KOpId>,
HxRequest(_hx_request): HxRequest, HxRequest(_hx_request): HxRequest,
@ -241,7 +242,11 @@ pub(crate) async fn cancel(
.map_err(|op_err| HtmxError::new(&kopid, op_err)) .map_err(|op_err| HtmxError::new(&kopid, op_err))
.await?; .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( pub(crate) async fn cancel_mfareg(
@ -387,23 +392,23 @@ pub(crate) async fn view_new_passkey(
let response = match cu_status.mfaregstate { let response = match cu_status.mfaregstate {
CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => { CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => {
if let Ok(challenge) = serde_json::to_string(&chal) { if let Ok(challenge) = serde_json::to_string(&chal) {
HtmlTemplate(AddPasskeyPartial { AddPasskeyPartial {
challenge, challenge,
class: init_form.class, class: init_form.class,
}) }
.into_response() .into_response()
} else { } else {
HtmlTemplate(UnrecoverableErrorView { UnrecoverableErrorView {
err_code: OperationError::UI0001ChallengeSerialisation, err_code: OperationError::UI0001ChallengeSerialisation,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response() .into_response()
} }
} }
_ => HtmlTemplate(UnrecoverableErrorView { _ => UnrecoverableErrorView {
err_code: OperationError::UI0002InvalidState, err_code: OperationError::UI0002InvalidState,
operation_id: kopid.eventid, operation_id: kopid.eventid,
}) }
.into_response(), .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 // User has submitted a totp code
@ -521,8 +526,12 @@ pub(crate) async fn view_new_totp(
} }
}; };
let template = HtmlTemplate(AddTotpPartial { check_res }); Ok((
Ok((swapped_handler_trigger, push_url, template).into_response()) swapped_handler_trigger,
push_url,
AddTotpPartial { check_res },
)
.into_response())
} }
pub(crate) async fn view_new_pwd( pub(crate) async fn view_new_pwd(
@ -539,10 +548,13 @@ pub(crate) async fn view_new_pwd(
let new_passwords = match opt_form { let new_passwords = match opt_form {
None => { None => {
let partial = AddPasswordPartial { return Ok((
swapped_handler_trigger,
AddPasswordPartial {
check_res: PwdCheckResult::Init, check_res: PwdCheckResult::Init,
}; },
return Ok((swapped_handler_trigger, HtmlTemplate(partial)).into_response()); )
.into_response());
} }
Some(Form(new_passwords)) => new_passwords, Some(Form(new_passwords)) => new_passwords,
}; };
@ -572,13 +584,12 @@ pub(crate) async fn view_new_pwd(
pwd_equal, pwd_equal,
warnings, warnings,
}; };
let template = HtmlTemplate(AddPasswordPartial { check_res });
Ok(( Ok((
status, status,
swapped_handler_trigger, swapped_handler_trigger,
HxPushUrl(Uri::from_static("/ui/reset/change_password")), HxPushUrl(Uri::from_static("/ui/reset/change_password")),
template, AddPasswordPartial { check_res },
) )
.into_response()) .into_response())
} }
@ -619,17 +630,18 @@ pub(crate) async fn view_self_reset_get(
username: uat.spn, username: uat.spn,
purpose: ReauthPurpose::ProfileSettings, purpose: ReauthPurpose::ProfileSettings,
}), }),
error: None,
}; };
super::login::view_reauth_get( Ok(super::login::view_reauth_get(
state, state,
client_auth_info, client_auth_info,
kopid, kopid,
jar, jar,
"/ui/update_credentials", Urls::UpdateCredentials.as_ref(),
display_ctx, display_ctx,
) )
.await .await)
} }
} }
@ -655,7 +667,7 @@ pub(crate) async fn view_reset_get(
Query(params): Query<ResetTokenParam>, Query(params): Query<ResetTokenParam>,
mut jar: CookieJar, mut jar: CookieJar,
) -> axum::response::Result<Response> { ) -> 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 cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
let is_logged_in = state let is_logged_in = state
.qe_r_ref .qe_r_ref
@ -684,10 +696,10 @@ pub(crate) async fn view_reset_get(
jar = jar.remove(Cookie::from(COOKIE_CU_SESSION_TOKEN)); jar = jar.remove(Cookie::from(COOKIE_CU_SESSION_TOKEN));
if let Some(token) = params.token { if let Some(token) = params.token {
let token_uri_string = format!("/ui/reset?token={token}"); let token_uri_string = format!("{}?token={}", Urls::CredReset, token);
return Ok((jar, Redirect::to(token_uri_string.as_str())).into_response()); 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()), 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()) Ok((jar, cu_resp).into_response())
} }
Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => { Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => {
let cred_form_view = ResetCredFormView { // Reset code expired
Ok((
push_url,
ResetCredFormView {
domain_info, domain_info,
wrong_code: true, wrong_code: true,
}; },
)
// Reset code expired .into_response())
Ok((push_url, HtmlTemplate(cred_form_view)).into_response())
} }
Err(op_err) => Err(ErrorResponse::from( Err(op_err) => Err(ErrorResponse::from(
HtmxError::new(&kopid, op_err).into_response(), HtmxError::new(&kopid, op_err).into_response(),
)), )),
} }
} else { } else {
let cred_form_view = ResetCredFormView { // We don't have any credential, show reset token input form
Ok((
push_url,
ResetCredFormView {
domain_info, domain_info,
wrong_code: false, wrong_code: false,
}; },
// We don't have any credential, show reset token input form )
Ok((push_url, HtmlTemplate(cred_form_view)).into_response()) .into_response())
} }
} }
@ -759,11 +776,11 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
fn get_cu_partial_response(cu_status: CUStatus) -> Response { fn get_cu_partial_response(cu_status: CUStatus) -> Response {
let credentials_update_partial = get_cu_partial(cu_status); 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()), HxRetarget("#credentialUpdateDynamicSection".to_string()),
HxReselect("#credentialUpdateDynamicSection".to_string()), HxReselect("#credentialUpdateDynamicSection".to_string()),
HxReswap(SwapOption::OuterHtml), HxReswap(SwapOption::OuterHtml),
HtmlTemplate(credentials_update_partial), credentials_update_partial,
) )
.into_response() .into_response()
} }
@ -788,23 +805,22 @@ fn get_cu_response(
// TODO: fill in posix enabled // TODO: fill in posix enabled
posix_enabled: false, posix_enabled: false,
}; };
let profile_view = ProfileView {
profile_partial: cred_status_view,
};
( (
HxPushUrl(Uri::from_static("/ui/update_credentials")), HxPushUrl(Uri::from_static(Urls::UpdateCredentials.as_ref())),
HtmlTemplate(profile_view), ProfileView {
profile_partial: cred_status_view,
},
) )
.into_response() .into_response()
} else { } else {
( (
HxPushUrl(Uri::from_static("/ui/reset")), HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())),
HtmlTemplate(CredResetView { CredResetView {
domain_info, domain_info,
names, names,
credentials_update_partial, credentials_update_partial,
}), },
) )
.into_response() .into_response()
} }
@ -819,6 +835,10 @@ async fn get_cu_session(jar: CookieJar) -> Result<CUSessionToken, Response> {
}; };
Ok(cu_session_token) Ok(cu_session_token)
} else { } 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> </div>
</form> </form>
<div class="g-3 d-flex justify-content-end" hx-target="#credentialUpdateDynamicSection"> <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" <button id="password-submit" type="button" class="btn btn-primary"
hx-post="/ui/reset/add_password" hx-post="/ui/reset/add_password"
hx-include="#newPasswordForm" hx-include="#newPasswordForm"

View file

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

View file

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

View file

@ -130,7 +130,7 @@
<div class="mt-2 pt-2 border-top"> <div class="mt-2 pt-2 border-top">
<button class="btn btn-danger" <button class="btn btn-danger"
hx-post="/ui/api/cu_cancel" 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" <span class="d-inline-block" tabindex="0"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="Unresolved warnings"> data-bs-title="Unresolved warnings">

View file

@ -1,6 +1,13 @@
(% extends "login_base.html" %) (% extends "login_base.html" %)
(% block logincontainer %) (% 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> <label for="username" class="form-label">Username</label>
<form id="login" action="/ui/login/begin" method="post"> <form id="login" action="/ui/login/begin" method="post">
<div class="input-group mb-3"> <div class="input-group mb-3">

View file

@ -18,9 +18,11 @@
(% endif %) (% endif %)
<h3>Kanidm</h3> <h3>Kanidm</h3>
(% if let Some(reauth) = display_ctx.reauth %) (% 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 %) (% endif %)
</center> </center>
<div id="login-form-container" class="container"> <div id="login-form-container" class="container">
(% block logincontainer %) (% block logincontainer %)
(% endblock %) (% endblock %)

View file

@ -5,7 +5,7 @@
<main id="main"> <main id="main">
<p>Reason: (( reason ))</p> <p>Reason: (( reason ))</p>
<p>Operation ID: (( operation_id ))</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> <button type="button" class="btn btn-success">Return to Login</button>
</a> </a>
</main> </main>

View file

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

View file

@ -8,18 +8,16 @@
</a> </a>
(% endmacro %) (% 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="d-flex flex-sm-row flex-column">
<div class="list-group side-menu flex-shrink-0"> <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") %) ProfileMenuItems::UserProfile, "person") %)
(% call side_menu_item("SSH Keys", "/ui/ssh_keys",
ProfileMenuItems::SshKeys, "key") %)
(% if posix_enabled %) (% if posix_enabled %)
(% call side_menu_item("UNIX Password", "/ui/update_credentials", (% call side_menu_item("UNIX Password", (Urls::UpdateCredentials),
ProfileMenuItems::UnixPassword, "building-lock") %) ProfileMenuItems::UnixPassword, "building-lock") %)
(% endif %) (% endif %)
(% call side_menu_item("Credentials", "/ui/update_credentials", (% call side_menu_item("Credentials", (Urls::UpdateCredentials),
ProfileMenuItems::Credentials, "shield-lock") %) ProfileMenuItems::Credentials, "shield-lock") %)
</div> </div>
<div id="settings-window" class="flex-grow-1 ps-sm-4 pt-sm-0 pt-4"> <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 %) (% if can_rw %)
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_posix">Edit</button> <button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_posix">Edit</button>
(% else %) (% else %)
<a href="/ui/profile/unlock" hx-boost="false"> <a href=(Urls::ProfileUnlock) hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button> <button class="btn btn-primary" type="button">((UiMessage::UnlockEdit))</button>
</a> </a>
(% endif %) (% endif %)
(% endblock %) (% endblock %)

View file

@ -17,34 +17,27 @@ Profile
<div class="mb-2 row"> <div class="mb-2 row">
<label for="profileDisplayName" class="col-12 col-md-3 col-lg-2 col-form-label">Display name</label> <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"> <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> </div>
<div class="mb-2 row"> <div class="mb-2 row">
<label for="profileEmail" class="col-12 col-md-3 col-lg-2 col-form-label">Email</label> <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"> <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>
</div> </div>
<!-- Edit button --> <!-- Edit button -->
<div class="pt-4"> <!-- <div class="pt-4">
(% if can_rw %) (% 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 %) (% else %)
<a href="/ui/profile/unlock" hx-boost="false"> <a href=(Urls::ProfileUnlock) hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button> <button class="btn btn-primary" type="button">((UiMessage::UnlockEdit))</button>
</a> </a>
(% endif %) (% endif %)
</div> </div> -->
</form> </form>
(% endblock %) (% endblock %)