Compare commits
6 Commits
1515f50ed9
...
ee4481225e
Author | SHA1 | Date | |
---|---|---|---|
ee4481225e | |||
ab648dd4f2 | |||
73c1b987cd | |||
678690855a | |||
649d1a6ecf | |||
28cc3cd9ae |
@ -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>> {
|
||||||
|
@ -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> 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>
|
||||||
|
|
||||||
|
|
||||||
|
@ -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>
|
@ -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());
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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,
|
@ -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;
|
||||||
|
|
||||||
|
@ -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! {
|
||||||
|
@ -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())
|
||||||
}
|
}
|
||||||
|
@ -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! {
|
||||||
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
89
web/src/endpoints/user/post_reset_password.rs
Normal file
89
web/src/endpoints/user/post_reset_password.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
47
web/src/utils/htmx_target_header.rs
Normal file
47
web/src/utils/htmx_target_header.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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};
|
||||||
|
@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user