[HTMX] User settings (#2929)

* Initial structure of user settings in htmx
This commit is contained in:
Merlijn 2024-08-12 09:20:50 +02:00 committed by GitHub
parent 89c5909311
commit f1dfbcc253
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 575 additions and 137 deletions

View file

@ -0,0 +1,136 @@
## User Settings Display Web UI
We need a usable centralized location for users to view and manage their own user settings.
User settings do not envelop credentials, these have their own flow as they are also used in user setup.
- Writing new values requires a writable session -> we will make the user reauthenticate to obtain a temporary profile-update-token when they want to update their user settings.
- The UI must display and make editable the following categories:
- user attributes
- user ssh public-keys
- The UI must display:
- user credential status as per [credential-display.rst](credential-display.rst)
- user groups
### User attributes
These consist of:
- username
- displayname
- legal name
- email address
In the future:
- picture
- zoneinfo/timezone
- locale/preferred language
- other business related attributes: address, phone number, ...
#### Displaying attributes
Attributes should be displayed with
- their descriptive name with tooltips or links if the name may be confusing to non IT people.
- their current value OR if not set: some clear indication that the attribute is not set.
- A method to edit the attribute if it is editable
#### Editing attributes
Users must be able to edit attributes individually.
Users should be able to see their changes before saving them.
E.g. via a popup that shows the old vs new value asking to confirm.
#### TODO: Personal Identifiable Information attributes (currently we don't have these attributes)
Certain information should not be displayed in the UI without reauthentication:
- addresses
- phone numbers
- personal emails
- birthdate
### SSH public keys
Ssh public key entries in kanidm consist of a:
- label : practically the ID of the key in kanidm
- value : the public key
A user may want to change their laptop ssh key by updating the value while keeping the label the same.
// TODO: Should a user be allowed to relabel their kanidm ssh keys ?
#### Displaying ssh keys
Due to their long length they should be line-wrapped into a text field so the entirety is visible when shown.
To reduce visible clutter and inconsistent spacing we will put the values into collapsable elements.
These collapsed elements must include:
- label
- value's key type (ECDSA, rsa, ect..)
and may include:
- value's comment, truncated to some max length
#### Editing keys
When editing keys users must be able to add keys, remove keys and update individual key values
Each action will be committed immediately, thus proper prompts and icons indicating this must be shown (like a floppy disk save icon ?)
### Credential status
Described in [credential-display.rst](credential-display.rst)
Must inform the user of the credential update/reset page, since it is very related and might be what they were looking for instead.
### User groups
Mostly a technical piece of info, should not be in direct view to avoid confusing users.
Could be displayed in tree form.
### User profile HTML Structure
To keep things oranised each category will be their own page with a subnavigation bar to navigate between them.
Since htmx cannot (without extensions) swap new scripts into the <head> on swap during boosted navigation, we must do non-boosted navigation to our profile page OR enable some htmx extension library.
The same htmx limitation means that all JS for every profile categories must be loaded on all profile categories.
Because want to use htmx to swap out content on form submission or page navigation to represent the new state as this is more efficient than triggering the client to do a redirect.
Every category will get their own Askama template which requires the relevant fields described for each category above.
And example would be
```html
<!-- /profile_templates/ssh_keys_partial.html -->
<!-- TODO: Depending on how we model modifiability of ssh keys this may change -->
(% for ssh_key in ssh_keys %)
<!-- Display ssh_key properties with respect to this doc -->
(% if ssh_key_is_modifiable %)
<!-- more clicky buttons to enable modification/deletion -->
(% endif %)
(% endfor %)
(% if ssh_key_is_modifiable %)
<!-- Add ssh_key button -->
(% endif %)
```
```js
<!-- ../static/profile.js -->
// Magically gets called on page load and swaps
function onProfileSshKeysSwapped() {
// Do implementation things like attaching event listeners
}
indow.onload = function () {
// Event triggered by HTMX because we supply a HxTrigger response header when loading this profile category.
document.body.addEventListener("profileSshKeysSwapped", () => {
onProfileSshKeysSwapped()
});
}
```
```rust
#[derive(Template)]
#[template(path = "profile_templates/ssh_keys_partial.html")]
struct SshKeysPartialView {
ssh_keys: Vec<SCIMSshKey>, // TODO: Use correct type
modifiable_state: SshKeysModifiabilityThing // ?
}
fn view_ssh_keys(...) {
// ...
let ssh_keys_swapped_trigger = HxResponseTrigger::after_swap([HxEvent::new("profileSshKeysSwapped".to_string())]);
Ok((
ssh_keys_swapped_trigger,
HxPushUrl(Uri::from_static("/ui/profile/ssh_keys")),
HtmlTemplate(SshKeysPartialView { ssh_keys, })
).into_response())
}
```

View file

@ -84,7 +84,7 @@ fn figure_out_if_we_have_all_the_routes() {
}
// now we check the things
for (module, routes) in found_routes {
if ["ui"].contains(&module.as_str()) {
if ["ui", "cookies"].contains(&module.as_str()) {
println!(
"We can skip checking {} because it's allow-listed for docs",
module

View file

@ -6,7 +6,7 @@ use axum::{
Extension,
};
use axum_htmx::extractors::HxRequest;
use axum_htmx::{HxPushUrl, HxReswap, HxRetarget, SwapOption};
use axum_htmx::HxPushUrl;
use kanidm_proto::internal::AppLink;
@ -42,25 +42,18 @@ pub(crate) async fn view_apps_get(
.await
.map_err(|old| HtmxError::new(&kopid, old))?;
let apps_view = AppsView {
apps_partial: AppsPartialView { apps: app_links },
};
let apps_partial = AppsPartialView { apps: app_links };
Ok(if hx_request {
(
// On the redirect during a login we don't push urls. We set these headers
// so that the url is updated, and we swap the correct element.
HxPushUrl(Uri::from_static("/ui/apps")),
// Tell htmx that we want to update the body instead. There is no need
// set the swap value as it defaults to innerHTML. This is because we came here
// from an htmx request so we only need to render the inner portion.
HxRetarget("body".to_string()),
// We send our own main, replace the existing one.
HxReswap(SwapOption::OuterHtml),
HtmlTemplate(apps_view),
HtmlTemplate(apps_partial),
)
.into_response()
} else {
let apps_view = AppsView { apps_partial };
HtmlTemplate(apps_view).into_response()
})
}

View file

@ -0,0 +1,87 @@
//! Support Utilities for interacting with cookies.
use crate::https::ServerState;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use compact_jwt::{Jws, JwsSigner};
use serde::de::DeserializeOwned;
use serde::Serialize;
pub fn destroy(jar: CookieJar, ck_id: &str) -> CookieJar {
if let Some(ck) = jar.get(ck_id) {
let mut ck = ck.clone();
ck.make_removal();
/*
if let Some(path) = ck.path().cloned() {
ck.set_path(&path);
}
*/
jar.add(ck)
} else {
jar
}
}
pub fn make_unsigned<'a>(
state: &'_ ServerState,
ck_id: &'a str,
value: String,
path: &'a str,
) -> Cookie<'a> {
let mut token_cookie = Cookie::new(ck_id, value);
token_cookie.set_secure(state.secure_cookies);
token_cookie.set_same_site(SameSite::Lax);
// Prevent Document.cookie accessing this. Still works with fetch.
token_cookie.set_http_only(true);
// We set a domain here because it allows subdomains
// of the idm to share the cookie. If domain was incorrect
// then webauthn won't work anyway!
token_cookie.set_domain(state.domain.clone());
token_cookie.set_path(path);
token_cookie
}
pub fn make_signed<'a, T: Serialize>(
state: &'_ ServerState,
ck_id: &'a str,
value: &'_ T,
path: &'a str,
) -> Option<Cookie<'a>> {
let kref = &state.jws_signer;
let jws = Jws::into_json(value)
.map_err(|e| {
error!(?e);
})
.ok()?;
// Get the header token ready.
let token = kref
.sign(&jws)
.map(|jwss| jwss.to_string())
.map_err(|e| {
error!(?e);
})
.ok()?;
let mut token_cookie = Cookie::new(ck_id, token);
token_cookie.set_secure(state.secure_cookies);
token_cookie.set_same_site(SameSite::Lax);
token_cookie.set_http_only(true);
token_cookie.set_path(path);
token_cookie.set_domain(state.domain.clone());
Some(token_cookie)
}
pub fn get_signed<T: DeserializeOwned>(
state: &ServerState,
jar: &CookieJar,
ck_id: &str,
) -> Option<T> {
jar.get(ck_id)
.map(|c| c.value())
.and_then(|s| state.deserialise_from_str::<T>(s))
}
pub fn get_unsigned<'a>(jar: &'a CookieJar, ck_id: &'_ str) -> Option<&'a str> {
jar.get(ck_id).map(|c| c.value())
}

View file

@ -1,3 +1,4 @@
use super::{cookies, empty_string_as_none, HtmlTemplate, UnrecoverableErrorView};
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
use askama::Template;
use axum::{
@ -6,7 +7,6 @@ use axum::{
Extension, Form, Json,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use compact_jwt::{Jws, JwsSigner};
use kanidm_proto::internal::{
COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_OAUTH2_REQ, COOKIE_USERNAME,
};
@ -21,8 +21,6 @@ use serde::{Deserialize, Serialize};
use std::str::FromStr;
use webauthn_rs::prelude::PublicKeyCredential;
use super::{empty_string_as_none, HtmlTemplate, UnrecoverableErrorView};
#[derive(Default, Serialize, Deserialize)]
struct SessionContext {
#[serde(rename = "u")]
@ -37,6 +35,9 @@ struct SessionContext {
password: Option<String>,
#[serde(rename = "t", default, skip_serializing_if = "Option::is_none")]
totp: Option<String>,
#[serde(rename = "a", default, skip_serializing_if = "Option::is_none")]
after_auth_loc: Option<String>,
}
#[derive(Template)]
@ -116,19 +117,99 @@ pub async fn view_logout_get(
} else {
let response = Redirect::to("/ui/login").into_response();
jar = if let Some(bearer_cookie) = jar.get(COOKIE_BEARER_TOKEN) {
let mut bearer_cookie = bearer_cookie.clone();
bearer_cookie.make_removal();
bearer_cookie.set_path("/");
jar.add(bearer_cookie)
} else {
jar
};
jar = cookies::destroy(jar, COOKIE_BEARER_TOKEN);
(jar, response).into_response()
}
}
pub async fn view_reauth_get(
state: ServerState,
client_auth_info: ClientAuthInfo,
kopid: KOpId,
jar: CookieJar,
return_location: &str,
) -> 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 {
Ok(()) => {
let inter = state
.qe_r_ref
.handle_reauth(
client_auth_info.clone(),
AuthIssueSession::Cookie,
kopid.eventid,
)
.await;
// Now process the response if ok.
match inter {
Ok(ar) => {
let session_context = SessionContext {
id: Some(ar.sessionid),
username: "".to_string(),
password: None,
totp: None,
remember_me: false,
after_auth_loc: Some(return_location.to_string()),
};
match view_login_step(
state,
kopid.clone(),
jar,
ar,
client_auth_info,
session_context,
)
.await
{
Ok(r) => r,
// Okay, these errors are actually REALLY bad.
Err(err_code) => HtmlTemplate(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(OperationError::NotAuthenticated) | Err(OperationError::SessionExpired) => {
// cookie jar with remember me.
let username = cookies::get_unsigned(&jar, COOKIE_USERNAME)
.map(String::from)
.unwrap_or_default();
let remember_me = !username.is_empty();
HtmlTemplate(LoginView {
username,
remember_me,
})
.into_response()
}
Err(err_code) => HtmlTemplate(UnrecoverableErrorView {
err_code,
operation_id: kopid.eventid,
})
.into_response(),
};
return Ok(res);
}
pub async fn view_index_get(
State(state): State<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
@ -221,6 +302,7 @@ pub async fn view_login_begin_post(
password,
totp,
remember_me,
after_auth_loc: None,
};
// Now process the response if ok.
@ -266,14 +348,9 @@ pub async fn view_login_mech_choose_post(
jar: CookieJar,
Form(login_mech_form): Form<LoginMechForm>,
) -> Response {
let session_context = jar
.get(COOKIE_AUTH_SESSION_ID)
.map(|c| c.value())
.and_then(|s| {
trace!(id_jws = %s);
state.deserialise_from_str::<SessionContext>(s)
})
.unwrap_or_default();
let session_context =
cookies::get_signed::<SessionContext>(&state, &jar, COOKIE_AUTH_SESSION_ID)
.unwrap_or_default();
debug!("Session ID: {:?}", session_context.id);
@ -411,14 +488,9 @@ async fn credential_step(
client_auth_info: ClientAuthInfo,
auth_cred: AuthCredential,
) -> Response {
let session_context = jar
.get(COOKIE_AUTH_SESSION_ID)
.map(|c| c.value())
.and_then(|s| {
trace!(id_jws = %s);
state.deserialise_from_str::<SessionContext>(s)
})
.unwrap_or_default();
let session_context =
cookies::get_signed::<SessionContext>(&state, &jar, COOKIE_AUTH_SESSION_ID)
.unwrap_or_default();
let inter = state // This may change in the future ...
.qe_r_ref
@ -477,6 +549,7 @@ async fn view_login_step(
state: mut auth_state,
sessionid,
} = auth_result;
session_context.id = Some(sessionid);
let mut safety = 3;
@ -493,28 +566,8 @@ async fn view_login_step(
match auth_state {
AuthState::Choose(allowed) => {
debug!("🧩 -> AuthState::Choose");
let kref = &state.jws_signer;
// Set the sessionid.
session_context.id = Some(sessionid);
let jws = Jws::into_json(&session_context).map_err(|e| {
error!(?e);
OperationError::InvalidSessionState
})?;
// Get the header token ready.
let token = kref.sign(&jws).map(|jwss| jwss.to_string()).map_err(|e| {
error!(?e);
OperationError::InvalidSessionState
})?;
let mut token_cookie = Cookie::new(COOKIE_AUTH_SESSION_ID, token);
token_cookie.set_secure(state.secure_cookies);
token_cookie.set_same_site(SameSite::Strict);
token_cookie.set_http_only(true);
// Not setting domains limits the cookie to precisely this
// url that was used.
// token_cookie.set_domain(state.domain.clone());
jar = jar.add(token_cookie);
jar = add_session_cookie(&state, jar, &session_context)?;
let res = match allowed.len() {
// Should never happen.
@ -564,6 +617,11 @@ async fn view_login_step(
break res;
}
AuthState::Continue(allowed) => {
// Reauth inits its session here so we need to be able to add cookie here ig.
if jar.get(COOKIE_AUTH_SESSION_ID).is_none() {
jar = add_session_cookie(&state, jar, &session_context)?;
}
let res = match allowed.len() {
// Shouldn't be possible.
0 => {
@ -634,25 +692,25 @@ async fn view_login_step(
AuthIssueSession::Cookie => {
// Update jar
let token_str = token.to_string();
let mut bearer_cookie = Cookie::new(COOKIE_BEARER_TOKEN, token_str.clone());
bearer_cookie.set_secure(state.secure_cookies);
bearer_cookie.set_same_site(SameSite::Lax);
// Prevent Document.cookie accessing this. Still works with fetch.
bearer_cookie.set_http_only(true);
// We set a domain here because it allows subdomains
// of the idm to share the cookie. If domain was incorrect
// then webauthn won't work anyway!
bearer_cookie.set_domain(state.domain.clone());
bearer_cookie.set_path("/");
// Important - this can be make unsigned as token_str has it's own
// signatures.
let bearer_cookie = cookies::make_unsigned(
&state,
COOKIE_BEARER_TOKEN,
token_str.clone(),
"/",
);
jar = if session_context.remember_me {
let mut username_cookie =
Cookie::new(COOKIE_USERNAME, session_context.username.clone());
username_cookie.set_secure(state.secure_cookies);
username_cookie.set_same_site(SameSite::Lax);
username_cookie.set_http_only(true);
username_cookie.set_domain(state.domain.clone());
username_cookie.set_path("/ui/login");
// Important - can be unsigned as username is just for remember
// me and no other purpose.
let username_cookie = cookies::make_unsigned(
&state,
COOKIE_USERNAME,
session_context.username.clone(),
"/ui/login",
);
jar.add(username_cookie)
} else {
jar
@ -662,10 +720,11 @@ async fn view_login_step(
.add(bearer_cookie)
.remove(Cookie::from(COOKIE_AUTH_SESSION_ID));
// Now, we need to decided where to go. If this
// 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()
} 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()
};
@ -689,3 +748,17 @@ async fn view_login_step(
Ok((jar, response).into_response())
}
fn add_session_cookie(
state: &ServerState,
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)
}

View file

@ -17,9 +17,11 @@ use crate::https::{
};
mod apps;
mod cookies;
mod errors;
mod login;
mod oauth2;
mod profile;
mod reset;
#[derive(Template)]
@ -34,6 +36,8 @@ pub fn view_router() -> Router<ServerState> {
.route("/", get(|| async { Redirect::permanent("/ui/login") }))
.route("/apps", get(apps::view_apps_get))
.route("/reset", get(reset::view_reset_get))
.route("/profile", get(profile::view_profile_get))
.route("/profile/unlock", get(profile::view_profile_unlock_get))
.route("/logout", get(login::view_logout_get))
.route("/oauth2", get(oauth2::view_index_get))
.route("/oauth2/resume", get(oauth2::view_resume_get))

View file

@ -1,4 +1,3 @@
use compact_jwt::{Jws, JwsSigner};
use kanidmd_lib::idm::oauth2::{
AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error,
};
@ -18,11 +17,11 @@ use axum::{
response::{IntoResponse, Redirect, Response},
Extension, Form,
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use axum_extra::extract::cookie::{CookieJar, SameSite};
use axum_htmx::HX_REDIRECT;
use serde::Deserialize;
use super::{HtmlTemplate, UnrecoverableErrorView};
use super::{cookies, HtmlTemplate, UnrecoverableErrorView};
#[derive(Template)]
#[template(path = "oauth2_consent_request.html")]
@ -55,10 +54,8 @@ pub async fn view_resume_get(
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> Response {
let maybe_auth_req = jar
.get(COOKIE_OAUTH2_REQ)
.map(|c| c.value())
.and_then(|s| state.deserialise_from_str::<AuthorisationRequest>(s));
let maybe_auth_req =
cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
if let Some(auth_req) = maybe_auth_req {
oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await
@ -134,24 +131,16 @@ async fn oauth2_auth_req(
.into_response()
}
Err(Oauth2Error::AuthenticationRequired) => {
// We store the auth_req into the cookie.
let kref = &state.jws_signer;
let token = Jws::into_json(&auth_req)
.map_err(|err| {
error!(?err, "Failed to serialise AuthorisationRequest");
OperationError::InvalidSessionState
// Sign the auth req and hide it in our cookie.
let maybe_jar = cookies::make_signed(&state, COOKIE_OAUTH2_REQ, &auth_req, "/ui")
.map(|mut cookie| {
cookie.set_same_site(SameSite::Strict);
jar.add(cookie)
})
.and_then(|jws| {
kref.sign(&jws).map_err(|err| {
error!(?err, "Failed to sign AuthorisationRequest");
OperationError::InvalidSessionState
})
})
.map(|jwss| jwss.to_string());
.ok_or(OperationError::InvalidSessionState);
let token = match token {
Ok(jws) => jws,
match maybe_jar {
Ok(jar) => (jar, Redirect::to("/ui/login")).into_response(),
Err(err_code) => {
return HtmlTemplate(UnrecoverableErrorView {
err_code,
@ -159,17 +148,7 @@ async fn oauth2_auth_req(
})
.into_response();
}
};
let mut authreq_cookie = Cookie::new(COOKIE_OAUTH2_REQ, token);
authreq_cookie.set_secure(state.secure_cookies);
authreq_cookie.set_same_site(SameSite::Strict);
authreq_cookie.set_http_only(true);
authreq_cookie.set_domain(state.domain.clone());
authreq_cookie.set_path("/ui");
let jar = jar.add(authreq_cookie);
(jar, Redirect::to("/ui/login")).into_response()
}
}
Err(Oauth2Error::AccessDenied) => {
// If scopes are not available for this account.
@ -227,21 +206,13 @@ pub async fn view_consent_post(
state,
code,
}) => {
let jar = if let Some(authreq_cookie) = jar.get(COOKIE_OAUTH2_REQ) {
let mut authreq_cookie = authreq_cookie.clone();
authreq_cookie.make_removal();
authreq_cookie.set_path("/ui");
jar.add(authreq_cookie)
} else {
jar
};
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ);
redirect_uri
.query_pairs_mut()
.clear()
.append_pair("state", &state)
.append_pair("code", &code);
(
jar,
[

View file

@ -0,0 +1,80 @@
use crate::https::extractors::VerifiedClientInformation;
use crate::https::middleware::KOpId;
use crate::https::views::errors::HtmxError;
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::Extension;
use axum_extra::extract::cookie::CookieJar;
use axum_htmx::{HxPushUrl, HxRequest};
use futures_util::TryFutureExt;
use kanidm_proto::internal::UserAuthToken;
#[derive(Template)]
#[template(path = "user_settings.html")]
struct ProfileView {
profile_partial: ProfilePartialView,
}
#[derive(Template, Clone)]
#[template(path = "user_settings_profile_partial.html")]
struct ProfilePartialView {
can_rw: bool,
account_name: String,
display_name: String,
legal_name: String,
email: Option<String>,
posix_enabled: bool,
}
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> {
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 {
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()
})
}
// #[axum::debug_handler]
pub(crate) async fn view_profile_unlock_get(
State(state): State<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Extension(kopid): Extension<KOpId>,
jar: CookieJar,
) -> axum::response::Result<Response> {
super::login::view_reauth_get(state, client_auth_info, kopid, jar, "/ui/profile").await
}

View file

@ -243,19 +243,6 @@ pub(crate) async fn cancel_mfareg(
Ok(get_cu_partial_response(cu_status))
}
async fn get_cu_session(jar: CookieJar) -> Result<CUSessionToken, Response> {
let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
return if let Some(cookie) = cookie {
let cu_session_token = cookie.value();
let cu_session_token = CUSessionToken {
token: cu_session_token.into(),
};
Ok(cu_session_token)
} else {
Err((StatusCode::FORBIDDEN, Redirect::to("/ui/reset")).into_response())
};
}
pub(crate) async fn remove_alt_creds(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
@ -711,6 +698,19 @@ fn get_cu_response(domain: String, cu_status: CUStatus) -> Response {
.into_response()
}
async fn get_cu_session(jar: CookieJar) -> Result<CUSessionToken, Response> {
let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
return if let Some(cookie) = cookie {
let cu_session_token = cookie.value();
let cu_session_token = CUSessionToken {
token: cu_session_token.into(),
};
Ok(cu_session_token)
} else {
Err((StatusCode::FORBIDDEN, Redirect::to("/ui/reset")).into_response())
};
}
// Any filter defined in the module `filters` is accessible in your template.
mod filters {
pub fn blank_if<T: std::fmt::Display>(

View file

@ -116,6 +116,11 @@ body {
text-transform: uppercase;
}
/*
* Personal Settings sidemenu
*/
/*
* Navbar
*/

View file

@ -10,10 +10,10 @@
<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"><span data-feather="file"></span>Apps</a>
<a class="nav-link" href="/ui/apps" hx-target="main" hx-select="main" hx-swap="outerHTML"><span data-feather="file"></span>Apps</a>
</li>
<li class="mb-1">
<a class="nav-link" href="/ui/profile"><span data-feather="file"></span>Profile</a>
<a class="nav-link" href="/ui/profile" hx-target="main" hx-select="main" hx-swap="outerHTML"><span data-feather="file"></span>Profile</a>
</li>
<li class="mb-1">
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#signoutModal">Sign out</a>

View file

@ -0,0 +1,10 @@
(% extends "base_htmx_with_nav.html" %)
(% block title %)Profile(% endblock %)
(% block head %)
(% endblock %)
(% block main %)
(( profile_partial|safe ))
(% endblock %)

View file

@ -0,0 +1,31 @@
<main class="p-3 x-auto">
<div class="d-flex flex-sm-row flex-column">
<div class="list-group side-menu">
<a href="#" class="list-group-item list-group-item-action active">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-person-fill me-3" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>Profile</a>
<a href="#" class="list-group-item list-group-item-action">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-key-fill me-3" viewBox="0 0 16 16">
<path d="M3.5 11.5a3.5 3.5 0 1 1 3.163-5H14L15.5 8 14 9.5l-1-1-1 1-1-1-1 1-1-1-1 1H6.663a3.5 3.5 0 0 1-3.163 2M2.5 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2"/>
</svg>SSH Keys</a>
(% if posix_enabled %)
<a href="#" class="list-group-item list-group-item-action"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-building-fill-lock me-3" viewBox="0 0 16 16">
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7.764a3 3 0 0 0-4.989 2.497 2 2 0 0 0-.743.739H6.5a.5.5 0 0 0-.5.5V16H3a1 1 0 0 1-1-1zm2 1.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5m3 0v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5m3.5-.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zM4 5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5M7.5 5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5M4.5 8a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm2.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5"/>
<path d="M9 13a1 1 0 0 1 1-1v-1a2 2 0 1 1 4 0v1a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zm3-3a1 1 0 0 0-1 1v1h2v-1a1 1 0 0 0-1-1"/>
</svg>UNIX Password</a>
(% endif %)
<a href="/ui/add_new_dev" class="list-group-item list-group-item-action"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-shield-fill-plus me-3" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0c-.69 0-1.843.265-2.928.56-1.11.3-2.229.655-2.887.87a1.54 1.54 0 0 0-1.044 1.262c-.596 4.477.787 7.795 2.465 9.99a11.8 11.8 0 0 0 2.517 2.453c.386.273.744.482 1.048.625.28.132.581.24.829.24s.548-.108.829-.24a7 7 0 0 0 1.048-.625 11.8 11.8 0 0 0 2.517-2.453c1.678-2.195 3.061-5.513 2.465-9.99a1.54 1.54 0 0 0-1.044-1.263 63 63 0 0 0-2.887-.87C9.843.266 8.69 0 8 0m-.5 5a.5.5 0 0 1 1 0v1.5H10a.5.5 0 0 1 0 1H8.5V9a.5.5 0 0 1-1 0V7.5H6a.5.5 0 0 1 0-1h1.5z"/>
</svg>Add another device</a>
</div>
<div id="settings-window" class="px-3">
<div class="(( crate::https::ui::CSS_PAGE_HEADER ))">
<h2>(% block selected_setting_group %)(% endblock %)</h2>
</div>
(% block settings_window %)
(% endblock %)
</div>
</div>
</main>

View file

@ -0,0 +1,21 @@
(% extends "user_settings_partial_base.html" %)
(% block selected_setting_group %)
Unix/Posix Settings
(% endblock %)
(% block settings_window %)
<fieldset>
<legend>POSIX Settings</legend>
<label for="posix-pwd-input">Posix Password</label>
<input type="text" id="posix-pwd-input" name="posix-pwd" disabled value="*********">
</fieldset>
(% 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>
(% endif %)
(% endblock %)

View file

@ -0,0 +1,27 @@
(% extends "user_settings_partial_base.html" %)
(% block selected_setting_group %)
Profile
(% endblock %)
(% block settings_window %)
<h3>Account name: (( account_name ))</h3>
<h3>Display name: (( display_name ))</h3>
<h3>Legal name: (( legal_name ))</h3>
(% if let Some(email) = email %)
<h3>Email: (( email ))</h3>
(% else %)
<h3>Email is not set</h3>
(% endif %)
<!-- Edit button -->
(% if can_rw %)
<button class="btn btn-primary" type="button" hx-post="/ui/api/user_settings/edit_profile">Edit</button>
(% else %)
<a href="/ui/profile/unlock" hx-boost="false">
<button class="btn btn-primary" type="button">Unlock Edit 🔒</button>
</a>
(% endif %)
(% endblock %)