From 5a5e10459b0c7a0b753f039899965e15c590801b Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Wed, 23 Sep 2026 13:21:04 +0200 Subject: [PATCH] feat: Allow users to change their password --- src/db/filesystem.rs | 12 +++ src/db/interface.rs | 17 ++++ src/db/memory.rs | 18 +++++ src/db/user.rs | 2 +- src/http/mod.rs | 10 +++ src/http/user_settings.rs | 145 +++++++++++++++++++++++++++++++++++ templates/app.html | 4 + templates/user_settings.html | 66 ++++++++++++++++ 8 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/http/user_settings.rs create mode 100644 templates/user_settings.html diff --git a/src/db/filesystem.rs b/src/db/filesystem.rs index 4c805ec..b8304a8 100644 --- a/src/db/filesystem.rs +++ b/src/db/filesystem.rs @@ -155,6 +155,18 @@ impl DatabaseInterface for FilesystemDatabase { Ok(Ok(())) } + async fn change_password( + &mut self, + user: &User, + new_password: String, + ) -> Result<(), BoxedError> { + let mut new_db = self.inner.clone(); + + new_db.change_password(user, new_password).await?; + self.save(new_db).await?; + Ok(()) + } + async fn list_all_domains(&self) -> Result, BoxedError> { self.inner.list_all_domains().await } diff --git a/src/db/interface.rs b/src/db/interface.rs index 36d6266..fcff6bb 100644 --- a/src/db/interface.rs +++ b/src/db/interface.rs @@ -37,6 +37,18 @@ impl DatabaseInterface for Database { .await } + async fn change_password( + &mut self, + user: &User, + new_password: String, + ) -> Result<(), BoxedError> { + self.inner + .write() + .await + .change_password(user, new_password) + .await + } + async fn list_all_domains(&self) -> Result, BoxedError> { self.inner.read().await.list_all_domains().await } @@ -68,6 +80,11 @@ pub trait DatabaseInterface: std::fmt::Debug + Send + Sync + 'static { new_user: User, current_user: &User, ) -> Result, BoxedError>; + async fn change_password( + &mut self, + user: &User, + new_password: String, + ) -> Result<(), BoxedError>; async fn list_all_domains(&self) -> Result, BoxedError>; async fn list_all_users(&self) -> Result, BoxedError>; diff --git a/src/db/memory.rs b/src/db/memory.rs index 76a53be..6ec5c1f 100644 --- a/src/db/memory.rs +++ b/src/db/memory.rs @@ -100,6 +100,24 @@ impl DatabaseInterface for MemoryDatabase { self.create_user(new_user).await } + async fn change_password( + &mut self, + user: &User, + new_password: String, + ) -> Result<(), BoxedError> { + let user = user.user_ref(); + if let Some(user) = self.users.iter_mut().find(|u| u.user_ref() == user) { + user.password = new_password; + } else { + tracing::warn!( + "Failed to change password for unknown user {}", + user.username + ); + } + + Ok(()) + } + async fn list_all_domains(&self) -> Result, BoxedError> { Ok(self.domains.clone()) } diff --git a/src/db/user.rs b/src/db/user.rs index ba9e986..5cb76b0 100644 --- a/src/db/user.rs +++ b/src/db/user.rs @@ -8,7 +8,7 @@ use crate::db::{Operation, Role}; /// /// Domain may be empty, but a value with more than one /// `@` is considered invalid. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct UserRef { pub username: String, pub domain: Option, diff --git a/src/http/mod.rs b/src/http/mod.rs index 5cfe591..5675bd0 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -22,6 +22,7 @@ use redirect::InternalRedirect; mod session; use session::{HttpSession, HttpSessionManager, OptionalHttpSession}; mod user; +mod user_settings; impl AxumListener for Listener { type Io = AbstractStreamKind; @@ -106,6 +107,15 @@ pub async fn http_listen(listener: Listener, db: Database) { .route("/domain/{domain}", get(domain::get_domain)) .route("/domain", get(domain::list_domains)) .route("/domain", post(domain::create_domain)) + .route("/user_settings", get(user_settings::get_user_settings)) + .route( + "/user_settings/change_password", + get(user_settings::get_user_settings), + ) + .route( + "/user_settings/change_password", + post(user_settings::post_change_password), + ) .route("/user", post(user::create_user)) .with_state(HttpState::new(db)); diff --git a/src/http/user_settings.rs b/src/http/user_settings.rs new file mode 100644 index 0000000..8065237 --- /dev/null +++ b/src/http/user_settings.rs @@ -0,0 +1,145 @@ +use axum::extract::{Form, State}; +use axum::response::{Html, IntoResponse, Response}; +use http::StatusCode; +use minijinja::context; +use serde::{Deserialize, Serialize}; + +use std::fmt; + +use crate::db::{Database, DatabaseInterface, User, error::BoxedError}; +use crate::http::{HttpSession, HttpState}; + +#[derive(Debug, Serialize)] +pub struct SettingsFlash { + success: bool, + message: String, +} + +impl SettingsFlash { + pub fn success(message: String) -> Self { + Self { + success: true, + message, + } + } + + pub fn error(message: String) -> Self { + Self { + success: false, + message, + } + } +} + +pub fn user_settings_page( + state: &HttpState, + session: &HttpSession, + flash: Option<&SettingsFlash>, +) -> Response { + let ctx = context! { + flash => flash, + user => session.user, + }; + + let page = state + .templates + .get_template("user_settings.html") + .unwrap() + .render(ctx) + .unwrap(); + (StatusCode::OK, Html(page)).into_response() +} + +pub async fn get_user_settings(State(state): State, session: HttpSession) -> Response { + user_settings_page(&state, &session, None) +} + +#[derive(Debug, Serialize)] +pub enum PasswordChangeError { + InvalidPreviousPassword, + PasswordsDoNotMatch, + EmptyPassword, +} + +impl fmt::Display for PasswordChangeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::InvalidPreviousPassword => "The current password is wrong", + Self::PasswordsDoNotMatch => "Provided new passwords do not match", + Self::EmptyPassword => "You cannot set an empty password!", + } + ) + } +} + +impl std::error::Error for PasswordChangeError {} + +impl From for SettingsFlash { + fn from(e: PasswordChangeError) -> Self { + Self::error(e.to_string()) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct PasswordChangeForm { + current_password: String, + new_password: String, + new_password_confirm: String, +} + +impl PasswordChangeForm { + pub async fn validate( + &self, + db: &Database, + user: &User, + ) -> Result, BoxedError> { + if self.new_password.trim().is_empty() { + return Ok(Err(PasswordChangeError::EmptyPassword)); + } + + if self.new_password != self.new_password_confirm { + return Ok(Err(PasswordChangeError::PasswordsDoNotMatch)); + } + + // TODO: implement minimum password security (eg. minimum length) + if db + .check_password(&user.user_ref(), &self.current_password) + .await? + { + Ok(Ok(())) + } else { + Ok(Err(PasswordChangeError::InvalidPreviousPassword)) + } + } +} + +pub async fn post_change_password( + State(mut state): State, + session: HttpSession, + Form(form): Form, +) -> Response { + match form.validate(&state.db, &session.user).await { + Ok(Ok(())) => { + if let Err(e) = state + .db + .change_password(&session.user, form.new_password) + .await + { + return format!("Database error: {e}").into_response(); + } + + user_settings_page( + &state, + &session, + Some(&SettingsFlash::success( + "Your password was changed successfully".to_string(), + )), + ) + } + Ok(Err(e)) => user_settings_page(&state, &session, Some(&e.into())), + Err(e) => format!("Database error: {e}").into_response(), + } +} diff --git a/templates/app.html b/templates/app.html index fb7c463..ff56da2 100644 --- a/templates/app.html +++ b/templates/app.html @@ -22,6 +22,10 @@