Compare commits

...

6 Commits

21 changed files with 337 additions and 247 deletions

View File

@ -107,7 +107,7 @@ impl User {
Ok(user) Ok(user)
} }
pub async fn read_for_login(pool: &PgPool, email: &str) -> Result<User> { pub async fn read_for_login(pool: &PgPool, email: &str) -> Result<Option<User>> {
let record = sqlx::query!( let record = sqlx::query!(
r#" r#"
SELECT id, SELECT id,
@ -126,25 +126,25 @@ impl User {
"#, "#,
email, email,
) )
.fetch_one(pool) .fetch_optional(pool)
.await?; .await?;
let result = User { let user = record.map(|r| User {
id: record.id, id: r.id,
name: record.name, name: r.name,
email: record.email, email: r.email,
password: record.password, password: r.password,
salt: record.salt, salt: r.salt,
role: record.role, role: r.role,
function: record.function, function: r.function,
area_id: record.areaid, area_id: r.areaid,
area: None, area: None,
locked: record.locked, locked: r.locked,
last_login: record.lastlogin, last_login: r.lastlogin,
receive_notifications: record.receivenotifications, receive_notifications: r.receivenotifications,
}; });
Ok(result) Ok(user)
} }
pub async fn exists(pool: &PgPool, email: &str) -> Result<Option<i32>> { pub async fn exists(pool: &PgPool, email: &str) -> Result<Option<i32>> {

View File

@ -9,20 +9,8 @@ snapshot_kind: text
<h3 class="title is-3">geplante Veranstaltungen</h3> <h3 class="title is-3">geplante Veranstaltungen</h3>
<p class="subtitle is-5">in den nächsten 31 Tagen</p> <p class="subtitle is-5">in den nächsten 31 Tagen</p>
<div class="panel p-2"> <div class="notification">
Keine Veranstaltungen für diesen Zeitraum geplant.
<div class="panel-block is-justify-content-space-between">
<span>
<b>WGT</b> &nbsp; Sonntag, 06.07.2025 10:00 - 20:00
</span>
<a class="button is-small is-link is-light" href="/calendar?date=2025-07-06">
<svg class="icon">
<use href="/static/feather-sprite.svg#calendar" />
</svg>
<span>im Kalender anzeigen</span>
</a>
</div>
</div> </div>

View File

@ -0,0 +1,43 @@
---
source: web/src/endpoints/user/get_changepassword.rs
expression: body
snapshot_kind: text
---
<div class="field">
<div class="control">
<a href="/profile" hx-boost="true" class="button is-link is-light">Schließen</a>
</div>
</div>
<form class="box" hx-post="/users/changepassword" hx-target-422="#error-message"
_="on change put '' into #error-message">
<div class="field">
<label class="label" for="currentpassword">aktuelles Passwort:</label>
<div class="control">
<input class="input" placeholder="**********" name="currentpassword" type="password" required>
</div>
</div>
<div class="field">
<label class="label" for="password">neues Passwort:</label>
<div class="control">
<input class="input" hx-trigger="keyup changed delay:500ms" hx-target="#password-strength"
hx-post="/users/changepassword" hx-target-422="#password-strength" placeholder="**********" name="password"
type="password" required hx-swap="outerHTML" maxlength=256 _="on input put '' into #password-strength">
</div>
<div id="password-strength" class=" mb-3 help content"></div>
</div>
<div class="field">
<label class="label" for="passwordretyped">neues Passwort wiederholen:</label>
<div class="control">
<input class="input" placeholder="**********" name="passwordretyped" type="password" maxlength=256 required>
</div>
</div>
<div id="error-message" class="mb-3 help is-danger"></div>
<div class="level">
<input class="button is-primary level-left" type="submit" value="Passwort ändern" />
</div>
</form>

View File

@ -21,42 +21,27 @@ async fn get(user: web::ReqData<User>) -> Result<impl Responder, ApplicationErro
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::utils::test_helper::{
utils::test_helper::{ assert_snapshot, test_get, DbTestContext, RequestConfig, ServiceResponseExt, StatusCode,
assert_snapshot, read_body, test_get, DbTestContext, RequestConfig, StatusCode,
},
}; };
use brass_db::models::{Function, Role}; use brass_db::models::Role;
use brass_macros::db_test; use brass_macros::db_test;
#[db_test] #[db_test]
async fn produces_template_when_user_is_admin(context: &DbTestContext) { async fn produces_template_when_user_is_admin(context: &DbTestContext) {
let app = context.app().await; let config = RequestConfig::new("/area/new").with_role(Role::Admin);
let config = RequestConfig {
uri: "/area/new".to_string(),
role: Role::Admin,
function: vec![Function::Posten],
user_area: 1,
};
let response = test_get(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::OK, response.status()); let response = test_get(&context.db_pool, context.app().await, &config).await;
let (status, body) = response.into_status_and_body().await;
let body = read_body(response).await; assert_eq!(StatusCode::OK, status);
assert_snapshot!(body); assert_snapshot!(body);
} }
#[db_test] #[db_test]
async fn returns_unauthorized_when_user_is_not_admin(context: &DbTestContext) { async fn returns_unauthorized_when_user_is_not_admin(context: &DbTestContext) {
let app = context.app().await; let config = RequestConfig::new("/area/new").with_role(Role::AreaManager);
let response = test_get(&context.db_pool, context.app().await, &config).await;
let config = RequestConfig {
uri: "/area/new".to_string(),
role: Role::AreaManager,
function: vec![Function::Posten],
user_area: 1,
};
let response = test_get(&context.db_pool, app, &config).await;
assert_eq!(StatusCode::UNAUTHORIZED, response.status()); assert_eq!(StatusCode::UNAUTHORIZED, response.status());
} }

View File

@ -15,7 +15,11 @@ use brass_db::models::{Assignment, Availability, Event, Role, User};
#[derive(Template)] #[derive(Template)]
#[cfg_attr(not(test), template(path = "overview.html"))] #[cfg_attr(not(test), template(path = "overview.html"))]
#[cfg_attr(test, template(path = "overview.html", block = "content"), allow(dead_code))] #[cfg_attr(
test,
template(path = "overview.html", block = "content"),
allow(dead_code)
)]
struct OverviewTemplate { struct OverviewTemplate {
user: User, user: User,
events: Vec<Event>, events: Vec<Event>,
@ -68,9 +72,7 @@ async fn get(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use actix_http::StatusCode; use actix_http::StatusCode;
use brass_db::models::{Clothing, Event, EventChangeset, Location};
use brass_macros::db_test; use brass_macros::db_test;
use chrono::Utc;
use crate::utils::test_helper::{ use crate::utils::test_helper::{
assert_snapshot, test_get, DbTestContext, RequestConfig, ServiceResponseExt, assert_snapshot, test_get, DbTestContext, RequestConfig, ServiceResponseExt,
@ -79,30 +81,6 @@ mod tests {
#[db_test] #[db_test]
fn endpoint_produces_template(context: &DbTestContext) { fn endpoint_produces_template(context: &DbTestContext) {
let app = context.app().await; let app = context.app().await;
Location::create(&context.db_pool, "Arena Abc", 1)
.await
.unwrap();
Clothing::create(&context.db_pool, "Anzugsordnung")
.await
.unwrap();
let event_changeset = EventChangeset {
time: (
Utc::now().date_naive().and_hms_opt(10, 0, 0).unwrap(),
Utc::now().date_naive().and_hms_opt(20, 0, 0).unwrap(),
),
name: "WGT".to_string(),
location_id: 1,
voluntary_wachhabender: false,
voluntary_fuehrungsassistent: false,
amount_of_posten: 1,
clothing: 1,
note: None,
};
Event::create(&context.db_pool, event_changeset)
.await
.unwrap();
let config = RequestConfig::new("/"); let config = RequestConfig::new("/");
let response = test_get(&context.db_pool, app, &config).await; let response = test_get(&context.db_pool, app, &config).await;

View File

@ -41,8 +41,8 @@ pub fn init(cfg: &mut ServiceConfig) {
cfg.service(user::get_logout::get); cfg.service(user::get_logout::get);
cfg.service(user::get_login::get); cfg.service(user::get_login::get);
cfg.service(user::post_login::post); cfg.service(user::post_login::post);
cfg.service(user::get_reset::get); cfg.service(user::get_reset_password::get);
cfg.service(user::post_reset::post); cfg.service(user::post_reset_password::post);
cfg.service(user::get_profile::get); cfg.service(user::get_profile::get);
cfg.service(user::get_changepassword::get); cfg.service(user::get_changepassword::get);
cfg.service(user::post_changepassword::post); cfg.service(user::post_changepassword::post);

View File

@ -15,3 +15,24 @@ pub async fn get(_user: web::ReqData<User>) -> Result<impl Responder, Applicatio
Ok(template.to_response()?) Ok(template.to_response()?)
} }
#[cfg(test)]
mod tests {
use actix_http::StatusCode;
use brass_macros::db_test;
use crate::utils::test_helper::{
assert_snapshot, test_get, DbTestContext, RequestConfig, ServiceResponseExt,
};
#[db_test]
async fn produces_template_fine(context: &DbTestContext) {
let config = RequestConfig::new("/users/changepassword");
let response = test_get(&context.db_pool, context.app().await, &config).await;
let (status, body) = response.into_status_and_body().await;
assert_eq!(StatusCode::OK, status);
assert_snapshot!(body);
}
}

View File

@ -31,7 +31,9 @@ pub async fn get(
return Ok(HttpResponse::Found() return Ok(HttpResponse::Found()
.insert_header((LOCATION, "/")) .insert_header((LOCATION, "/"))
.finish()); .finish());
} else if let Some(token) = &query.token { }
if let Some(token) = &query.token {
if let Some(_) = PasswordReset::does_token_exist(pool.get_ref(), token).await? { if let Some(_) = PasswordReset::does_token_exist(pool.get_ref(), token).await? {
let template = ResetPasswordTemplate { let template = ResetPasswordTemplate {
token, token,

View File

@ -12,14 +12,14 @@ pub mod get_new;
pub mod get_overview; pub mod get_overview;
pub mod get_profile; pub mod get_profile;
pub mod get_register; pub mod get_register;
pub mod get_reset; pub mod get_reset_password;
pub mod post_changepassword; pub mod post_changepassword;
pub mod post_edit; pub mod post_edit;
pub mod post_login; pub mod post_login;
pub mod post_new; pub mod post_new;
pub mod post_register; pub mod post_register;
pub mod post_resend_registration; pub mod post_resend_registration;
pub mod post_reset; pub mod post_reset_password;
pub mod put_lock; pub mod put_lock;
pub mod put_receive_notifications; pub mod put_receive_notifications;

View File

@ -3,7 +3,7 @@ use maud::html;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::utils::{password_change::PasswordChangeBuilder, ApplicationError}; use crate::utils::{password_change::PasswordChangeBuilder, ApplicationError, HtmxTargetHeader};
use brass_db::{models::User, NoneToken}; use brass_db::{models::User, NoneToken};
#[derive(Deserialize)] #[derive(Deserialize)]
@ -11,17 +11,16 @@ struct ChangePasswordForm {
currentpassword: String, currentpassword: String,
password: String, password: String,
passwordretyped: String, passwordretyped: String,
dry: Option<bool>,
} }
#[actix_web::post("/users/changepassword")] #[actix_web::post("/users/changepassword")]
async fn post( async fn post(
user: web::ReqData<User>, user: web::ReqData<User>,
header: web::Header<HtmxTargetHeader>,
form: web::Form<ChangePasswordForm>, form: web::Form<ChangePasswordForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
// TODO: refactor into check if HX-TARGET = #password-strength exists let is_dry = header.is_some_and_equal("password-strength");
let is_dry = form.dry.unwrap_or(false);
let mut builder = PasswordChangeBuilder::<NoneToken>::new( let mut builder = PasswordChangeBuilder::<NoneToken>::new(
pool.get_ref(), pool.get_ref(),
@ -34,9 +33,15 @@ async fn post(
let change = builder.build(); let change = builder.build();
let response = if is_dry { let response = if is_dry {
change.validate_for_input().await? match change.validate_for_input().await {
Ok(r) => r,
Err(e) => HttpResponse::UnprocessableEntity().body(e.message),
}
} else { } else {
change.validate().await?; if let Err(e) = change.validate().await {
return Ok(HttpResponse::UnprocessableEntity().body(e.message));
}
change.commit().await?; change.commit().await?;
HttpResponse::Ok().body( HttpResponse::Ok().body(
html! { html! {

View File

@ -4,7 +4,7 @@ use brass_db::models::User;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use crate::utils::auth::hash_plain_password_with_salt; use crate::utils::{auth::hash_plain_password_with_salt, ApplicationError};
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct LoginForm { pub struct LoginForm {
@ -18,26 +18,33 @@ async fn post(
web::Form(form): web::Form<LoginForm>, web::Form(form): web::Form<LoginForm>,
request: HttpRequest, request: HttpRequest,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
) -> impl Responder { ) -> Result<impl Responder, ApplicationError> {
if let Ok(user) = User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await { let not_found_response = HttpResponse::BadRequest().body("E-Mail oder Passwort falsch.");
let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt).unwrap(); let Some(user) = User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await? else {
if hash == user.password.unwrap() { return Ok(not_found_response);
Identity::login(&request.extensions(), user.id.to_string()).unwrap(); };
User::update_login_timestamp(pool.get_ref(), user.id) if user.password.is_none() || user.salt.is_none() {
.await return Ok(not_found_response);
.unwrap();
let location = form.next.unwrap_or("/".to_string());
return HttpResponse::Found()
.insert_header(("LOCATION", location.clone()))
.insert_header(("HX-LOCATION", location))
.finish();
}
} }
HttpResponse::BadRequest().body("E-Mail oder Passwort falsch.") let password = user.password.unwrap();
let salt = user.salt.unwrap();
let hash = hash_plain_password_with_salt(&form.password, &salt)?;
if hash != password {
return Ok(not_found_response);
}
Identity::login(&request.extensions(), user.id.to_string()).unwrap();
User::update_login_timestamp(pool.get_ref(), user.id).await?;
let location = form.next.unwrap_or("/".to_string());
Ok(HttpResponse::Found()
.insert_header(("LOCATION", location.clone()))
.insert_header(("HX-LOCATION", location))
.finish())
} }

View File

@ -3,7 +3,7 @@ use maud::html;
use serde::Deserialize; use serde::Deserialize;
use sqlx::PgPool; use sqlx::PgPool;
use crate::utils::{password_change::PasswordChangeBuilder, ApplicationError}; use crate::utils::{password_change::PasswordChangeBuilder, ApplicationError, HtmxTargetHeader};
use brass_db::models::Registration; use brass_db::models::Registration;
#[derive(Deserialize)] #[derive(Deserialize)]
@ -11,22 +11,19 @@ struct RegisterForm {
token: String, token: String,
password: String, password: String,
passwordretyped: String, passwordretyped: String,
dry: Option<bool>,
} }
#[post("/register")] #[post("/register")]
async fn post( async fn post(
form: web::Form<RegisterForm>, form: web::Form<RegisterForm>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
header: web::Header<HtmxTargetHeader>,
) -> Result<impl Responder, ApplicationError> { ) -> Result<impl Responder, ApplicationError> {
// TODO: refactor into check if HX-TARGET = #password-strength exists let is_dry = header.is_some_and_equal("password-strength");
let is_dry = form.dry.unwrap_or(false);
let token = let Some(token) = Registration::does_token_exist(pool.get_ref(), &form.token).await? else {
if let Some(token) = Registration::does_token_exist(pool.get_ref(), &form.token).await? { return Ok(HttpResponse::NoContent().finish());
token };
} else {
return Ok(HttpResponse::NoContent().finish());
};
let mut builder = PasswordChangeBuilder::<Registration>::new( let mut builder = PasswordChangeBuilder::<Registration>::new(
pool.get_ref(), pool.get_ref(),
@ -39,9 +36,15 @@ async fn post(
let change = builder.build(); let change = builder.build();
let response = if is_dry { let response = if is_dry {
change.validate_for_input().await? match change.validate_for_input().await {
Ok(r) => r,
Err(e) => HttpResponse::UnprocessableEntity().body(e.message),
}
} else { } else {
change.validate().await?; if let Err(e) = change.validate().await {
return Ok(HttpResponse::UnprocessableEntity().body(e.message));
}
change.commit().await?; change.commit().await?;
HttpResponse::Ok().body( HttpResponse::Ok().body(
html! { html! {

View File

@ -1,90 +0,0 @@
use actix_web::{web, HttpResponse, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
mail::Mailer,
utils::{password_change::PasswordChangeBuilder, ApplicationError},
};
use brass_db::models::{PasswordReset, User};
#[derive(Deserialize, Debug)]
struct ResetPasswordForm {
email: Option<String>,
token: Option<String>,
password: Option<String>,
passwordretyped: Option<String>,
dry: Option<bool>,
}
#[actix_web::post("/reset-password")]
async fn post(
form: web::Form<ResetPasswordForm>,
pool: web::Data<PgPool>,
mailer: web::Data<Mailer>,
) -> Result<impl Responder, ApplicationError> {
if form.email.is_some()
&& form.token.is_none()
&& form.password.is_none()
&& form.passwordretyped.is_none()
{
if let Ok(user) =
User::read_for_login(pool.get_ref(), &form.email.as_ref().unwrap().to_lowercase()).await
{
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
mailer
.send_forgot_password_mail(&user, &reset.token)
.await?;
}
Ok(HttpResponse::Ok().body("E-Mail versandt!"))
} else if form.email.is_none()
&& form.token.is_some()
&& form.password.is_some()
&& form.passwordretyped.is_some()
{
// TODO: refactor into check if HX-TARGET = #password-strength exists
let is_dry = form.dry.is_some_and(|b| b);
let token = if let Some(token) =
PasswordReset::does_token_exist(pool.get_ref(), form.token.as_ref().unwrap()).await?
{
token
} else {
return Ok(HttpResponse::NoContent().finish());
};
let mut builder = PasswordChangeBuilder::<PasswordReset>::new(
pool.get_ref(),
token.userid,
&form.password.as_ref().unwrap(),
&form.passwordretyped.as_ref().unwrap(),
)
.with_token(token);
let change = builder.build();
let response = if is_dry {
change.validate_for_input().await?
} else {
change.validate().await?;
change.commit().await?;
HttpResponse::Ok().body(
html! {
div class="block mb-3" {
"Passwort erfolgreich geändert."
}
a class="block button is-primary" hx-boost="true" href="/login"{
"Zum Login"
}
}
.into_string(),
)
};
return Ok(response);
} else {
return Ok(HttpResponse::BadRequest().finish());
}
}

View File

@ -0,0 +1,89 @@
use actix_web::{web, Either, HttpResponse, Responder};
use maud::html;
use serde::Deserialize;
use sqlx::PgPool;
use crate::{
mail::Mailer,
utils::{password_change::PasswordChangeBuilder, ApplicationError, HtmxTargetHeader},
};
use brass_db::models::{PasswordReset, User};
#[derive(Deserialize, Debug)]
struct RequestResetPasswordForm {
email: String,
}
#[derive(Deserialize, Debug)]
struct ResetPasswordForm {
token: String,
password: String,
passwordretyped: String,
}
#[actix_web::post("/reset-password")]
async fn post(
form: Either<web::Form<RequestResetPasswordForm>, web::Form<ResetPasswordForm>>,
header: web::Header<HtmxTargetHeader>,
pool: web::Data<PgPool>,
mailer: web::Data<Mailer>,
) -> Result<impl Responder, ApplicationError> {
match form {
Either::Left(form) => {
if let Some(user) =
User::read_for_login(pool.get_ref(), &form.email.to_lowercase()).await?
{
let reset = PasswordReset::insert_new_for_user(pool.get_ref(), user.id).await?;
mailer
.send_forgot_password_mail(&user, &reset.token)
.await?;
}
return Ok(HttpResponse::Ok().body("E-Mail versandt!"));
}
Either::Right(form) => {
let is_dry = header.is_some_and_equal("password-strength");
let Some(token) = PasswordReset::does_token_exist(pool.get_ref(), &form.token).await?
else {
return Ok(HttpResponse::NoContent().finish());
};
let mut builder = PasswordChangeBuilder::<PasswordReset>::new(
pool.get_ref(),
token.userid,
&form.password,
&form.passwordretyped,
)
.with_token(token);
let change = builder.build();
let response = if is_dry {
match change.validate_for_input().await {
Ok(r) => r,
Err(e) => HttpResponse::UnprocessableEntity().body(e.message),
}
} else {
if let Err(e) = change.validate().await {
return Ok(HttpResponse::UnprocessableEntity().body(e.message));
}
change.commit().await?;
HttpResponse::Ok().body(
html! {
div class="block mb-3" {
"Passwort erfolgreich geändert."
}
a class="block button is-primary" hx-boost="true" href="/login"{
"Zum Login"
}
}
.into_string(),
)
};
return Ok(response);
}
}
}

View File

@ -1,8 +1,6 @@
use actix_web::{http::StatusCode, HttpResponse}; use actix_web::{http::StatusCode, HttpResponse};
use thiserror::Error; use thiserror::Error;
use super::password_change::PasswordChangeError;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ApplicationError { pub enum ApplicationError {
#[error("unsupported value for enum")] #[error("unsupported value for enum")]
@ -25,11 +23,6 @@ pub enum ApplicationError {
Hash(#[from] argon2::password_hash::Error), Hash(#[from] argon2::password_hash::Error),
#[error("templating failed")] #[error("templating failed")]
Template(#[from] askama::Error), Template(#[from] askama::Error),
#[error("{}", inner.message)]
PasswordChange {
#[from]
inner: PasswordChangeError,
},
} }
impl actix_web::error::ResponseError for ApplicationError { impl actix_web::error::ResponseError for ApplicationError {
@ -45,7 +38,6 @@ impl actix_web::error::ResponseError for ApplicationError {
ApplicationError::Hash(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::Hash(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::Template(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::Template(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::EmailStubTransport(_) => StatusCode::INTERNAL_SERVER_ERROR, ApplicationError::EmailStubTransport(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApplicationError::PasswordChange { .. } => StatusCode::BAD_REQUEST,
} }
} }

View File

@ -0,0 +1,47 @@
use std::str::FromStr;
use actix_http::header::{Header, HeaderName, HeaderValue, InvalidHeaderValue, TryIntoHeaderValue};
use actix_web::error::ParseError;
#[derive(Debug)]
pub struct HtmxTargetHeader(Option<String>);
impl Header for HtmxTargetHeader {
fn name() -> HeaderName {
HeaderName::from_str("HX-Target").unwrap()
}
fn parse<M: actix_web::HttpMessage>(msg: &M) -> Result<Self, actix_web::error::ParseError> {
let header = msg.headers().get(Self::name());
if let Some(line) = header {
let line = line.to_str().map_err(|_| ParseError::Header)?;
if line.is_empty() {
return Err(ParseError::Header);
}
return Ok(self::HtmxTargetHeader(Some(line.to_string())));
} else {
Ok(self::HtmxTargetHeader(None))
}
}
}
impl TryIntoHeaderValue for HtmxTargetHeader {
type Error = InvalidHeaderValue;
#[inline]
fn try_into_value(self) -> Result<HeaderValue, Self::Error> {
if let Some(s) = self.0 {
return s.try_into_value();
} else {
HeaderValue::from_str("")
}
}
}
impl HtmxTargetHeader {
pub fn is_some_and_equal(&self, target: &str) -> bool {
self.0.as_ref().is_some_and(|t| t == target)
}
}

View File

@ -3,6 +3,7 @@ mod application_error;
pub mod auth; pub mod auth;
mod date_time_format; mod date_time_format;
pub mod event_planning_template; pub mod event_planning_template;
mod htmx_target_header;
pub mod manage_commands; pub mod manage_commands;
pub mod password_change; pub mod password_change;
mod template_response_trait; mod template_response_trait;
@ -13,6 +14,7 @@ pub mod test_helper;
pub use app_customization::Customization; pub use app_customization::Customization;
pub use application_error::ApplicationError; pub use application_error::ApplicationError;
pub use date_time_format::DateTimeFormat; pub use date_time_format::DateTimeFormat;
pub use htmx_target_header::HtmxTargetHeader;
pub use template_response_trait::TemplateResponse; pub use template_response_trait::TemplateResponse;
use chrono::{NaiveDate, Utc}; use chrono::{NaiveDate, Utc};

View File

@ -1,5 +1,6 @@
use maud::html; use maud::html;
use thiserror::Error; use thiserror::Error;
use tracing::error;
use zxcvbn::{ use zxcvbn::{
feedback::{Suggestion, Warning}, feedback::{Suggestion, Warning},
Entropy, Entropy,
@ -17,6 +18,28 @@ pub struct PasswordChangeError {
pub message: String, pub message: String,
} }
impl PasswordChangeError {
pub fn new(message: &str) -> Self {
PasswordChangeError {
message: message.to_string(),
}
}
}
impl From<sqlx::Error> for PasswordChangeError {
fn from(value: sqlx::Error) -> Self {
error!(error = %value, "database error while validation input");
Self::new("Datenbankfehler beim Validieren!")
}
}
impl From<argon2::password_hash::Error> for PasswordChangeError {
fn from(value: argon2::password_hash::Error) -> Self {
error!(error = %value, "argon2 hash error while validation input");
Self::new("Hashingfehler beim Validieren!")
}
}
fn generate_help_message_for_entropy(entropy: &Entropy) -> String { fn generate_help_message_for_entropy(entropy: &Entropy) -> String {
let feedback = entropy.feedback().unwrap(); let feedback = entropy.feedback().unwrap();
@ -62,11 +85,11 @@ fn generate_help_message_for_entropy(entropy: &Entropy) -> String {
"Vorschlag" "Vorschlag"
}; };
let suggestion = feedback let suggestions = feedback
.suggestions() .suggestions()
.iter() .iter()
.map(|s| { .map(|s| {
let inner = match s { match s {
Suggestion::UseAFewWordsAvoidCommonPhrases => "Mehrere Wörter verwenden, aber allgemeine Phrasen vermeiden.", Suggestion::UseAFewWordsAvoidCommonPhrases => "Mehrere Wörter verwenden, aber allgemeine Phrasen vermeiden.",
Suggestion::NoNeedForSymbolsDigitsOrUppercaseLetters => "Es ist möglich, starke Passwörter zu erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu verwenden.", Suggestion::NoNeedForSymbolsDigitsOrUppercaseLetters => "Es ist möglich, starke Passwörter zu erstellen, ohne Symbole, Zahlen oder Großbuchstaben zu verwenden.",
Suggestion::AddAnotherWordOrTwo => "Weitere Wörter, die weniger häufig vorkommen, hinzufügen.", Suggestion::AddAnotherWordOrTwo => "Weitere Wörter, die weniger häufig vorkommen, hinzufügen.",
@ -80,12 +103,10 @@ fn generate_help_message_for_entropy(entropy: &Entropy) -> String {
Suggestion::AvoidRecentYears => "Die jüngsten Jahreszahlen vermeiden.", Suggestion::AvoidRecentYears => "Die jüngsten Jahreszahlen vermeiden.",
Suggestion::AvoidYearsThatAreAssociatedWithYou => "Jahre, die mit persönlichen Daten in Verbindung gebracht werden können, vermeiden.", Suggestion::AvoidYearsThatAreAssociatedWithYou => "Jahre, die mit persönlichen Daten in Verbindung gebracht werden können, vermeiden.",
Suggestion::AvoidDatesAndYearsThatAreAssociatedWithYou => "Daten, die mit persönlichen Daten in Verbindung gebracht werden können, vermeiden.", Suggestion::AvoidDatesAndYearsThatAreAssociatedWithYou => "Daten, die mit persönlichen Daten in Verbindung gebracht werden können, vermeiden.",
}; }
format!("<li>{inner}</li>")
}) })
.collect::<Vec<String>>() .collect::<Vec<&str>>();
.join("");
html!( html!(
{ {
@ -93,7 +114,9 @@ fn generate_help_message_for_entropy(entropy: &Entropy) -> String {
p { ( warning )} p { ( warning )}
(vorschlag_text) ":" (vorschlag_text) ":"
ul { ul {
(suggestion) @for s in &suggestions {
li { (s) }
}
} }
} }

View File

@ -28,7 +28,7 @@ where
T: Token, T: Token,
{ {
/// should be called after password input has changed to hint the user of any input related problems /// should be called after password input has changed to hint the user of any input related problems
pub async fn validate_for_input(&self) -> Result<HttpResponse, ApplicationError> { pub async fn validate_for_input(&self) -> Result<HttpResponse, PasswordChangeError> {
if self.password.chars().count() > 256 { if self.password.chars().count() > 256 {
return Err(PasswordChangeError { return Err(PasswordChangeError {
message: html! { message: html! {
@ -68,7 +68,7 @@ where
} }
/// should be called after the form is fully filled and submit is clicked /// should be called after the form is fully filled and submit is clicked
pub async fn validate(&self) -> Result<(), ApplicationError> { pub async fn validate(&self) -> Result<(), PasswordChangeError> {
self.validate_for_input().await?; self.validate_for_input().await?;
if let Some(current_password) = self.current_password { if let Some(current_password) = self.current_password {

View File

@ -4,19 +4,17 @@
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title">{{ title }}</h1> <h1 class="title">{{ title }}</h1>
<form class="box" hx-post="{{ endpoint }}" hx-params="not dry" hx-target-400="#error-message-retype" <form class="box" hx-post="{{ endpoint }}" hx-target-422="#error-message-retype"
hx-on:input="document.getElementById('error-message-retype').innerHTML = ''"> _="on input put '' into #error-message-retype">
<input type="hidden" name="token" value="{{ token }}" /> <input type="hidden" name="token" value="{{ token }}" />
<input type="hidden" name="dry" value="true" />
<div class="field"> <div class="field">
<label class="label" for="password">{{ new_password_label }}</label> <label class="label" for="password">{{ new_password_label }}</label>
<div class="control"> <div class="control">
<input class="input" hx-post="{{ endpoint }}" hx-params="*" hx-trigger="keyup changed delay:500ms" <input class="input" hx-post="{{ endpoint }}" hx-trigger="keyup changed delay:500ms"
hx-target="#password-strength" hx-target-400="#password-strength" placeholder="**********" name="password" hx-target="#password-strength" hx-target-422="#password-strength" placeholder="**********"
type="password" required hx-swap="outerHTML" maxlength=256 name="password" type="password" required hx-swap="outerHTML" maxlength=256
hx-on:input="document.getElementById('password-strength').innerHTML = ''"> _="on input put '' into #password-strength">
</div> </div>
<div id="password-strength" class="mb-3 help content"></div> <div id="password-strength" class="mb-3 help content"></div>
</div> </div>

View File

@ -3,10 +3,8 @@
<a href="/profile" hx-boost="true" class="button is-link is-light">Schließen</a> <a href="/profile" hx-boost="true" class="button is-link is-light">Schließen</a>
</div> </div>
</div> </div>
<form class="box" hx-post="/users/changepassword" hx-params="not dry" hx-target-400="#error-message" <form class="box" hx-post="/users/changepassword" hx-target-422="#error-message"
hx-on:change="document.getElementById('error-message').innerHTML = ''"> _="on change put '' into #error-message">
<input type="hidden" name="dry" value="true" />
<div class="field"> <div class="field">
<label class="label" for="currentpassword">aktuelles Passwort:</label> <label class="label" for="currentpassword">aktuelles Passwort:</label>
@ -18,12 +16,11 @@
<div class="field"> <div class="field">
<label class="label" for="password">neues Passwort:</label> <label class="label" for="password">neues Passwort:</label>
<div class="control"> <div class="control">
<input class="input" hx-post="/users/changepassword" hx-params="*" hx-trigger="keyup changed delay:500ms" <input class="input" hx-trigger="keyup changed delay:500ms" hx-target="#password-strength"
hx-target="#password-strength" hx-target-400="#password-strength" placeholder="**********" name="password" hx-post="/users/changepassword" hx-target-422="#password-strength" placeholder="**********" name="password"
type="password" required hx-swap="outerHTML" maxlength=256 type="password" required hx-swap="outerHTML" maxlength=256 _="on input put '' into #password-strength">
hx-on:input="document.getElementById('password-strength').innerHTML = ''">
</div> </div>
<div id="password-strength" class="mb-3 help content"></div> <div id="password-strength" class=" mb-3 help content"></div>
</div> </div>
<div class="field"> <div class="field">