feat: Allow users to change their password

This commit is contained in:
selfhoster selfhoster 2026-09-23 13:21:04 +02:00
commit 5a5e10459b
8 changed files with 273 additions and 1 deletions

View file

@ -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<Vec<Domain>, BoxedError> {
self.inner.list_all_domains().await
}

View file

@ -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<Vec<Domain>, 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<Result<(), UserCreationError>, BoxedError>;
async fn change_password(
&mut self,
user: &User,
new_password: String,
) -> Result<(), BoxedError>;
async fn list_all_domains(&self) -> Result<Vec<Domain>, BoxedError>;
async fn list_all_users(&self) -> Result<Vec<User>, BoxedError>;

View file

@ -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<Vec<Domain>, BoxedError> {
Ok(self.domains.clone())
}

View file

@ -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<String>,

View file

@ -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));

145
src/http/user_settings.rs Normal file
View file

@ -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<HttpState>, 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<PasswordChangeError> 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<Result<(), PasswordChangeError>, 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<HttpState>,
session: HttpSession,
Form(form): Form<PasswordChangeForm>,
) -> 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(),
}
}

View file

@ -22,6 +22,10 @@
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<a href="/user_settings" class="button is-info">
<i class="fa fa-cogs mr-3" aria-hidden="true"></i>
<strong>Settings</strong>
</a>
<a href="/logout" class="button is-danger">
<strong>Logout</strong>
</a>

View file

@ -0,0 +1,66 @@
{% extends 'app.html' %}
{% block main %}
<main>
<section class="section">
{% if flash %}
<div class="container is-widescreen mt-5">
<article class="message {% if flash.success %}is-success{% else %}is-danger{% endif %}">
<div class="message-header">
{% if flash.success %}<p>Operation successful</p>
{% else %}<p>Operation failed</p>
{% endif %}
</div>
<div class="message-body">
<p>{{ flash.message }}</p>
</div>
</article>
</div>
{% endif %}
<div class="container is-widescreen mt-5">
<div class="card">
<header class="card-header">
<p class="card-header-title">My account</p>
</header>
<div class="card-content">
<div class="content">
<p><i class="fa fa-user mr-3" aria-hidden="true"></i>{{ user.username }}</p>
<p><i class="fa fa-envelope mr-3" aria-hidden="true"></i>{{ user.mail }}</p>
</div>
</div>
</div>
</div>
<div class="container is-widescreen mt-5">
<article class="message is-danger">
<div class="message-header">
<p>Change my password</p>
</div>
<div class="message-body">
<form action="/user_settings/change_password" method="POST">
<div class="field">
<label class="label">Current password</label>
<div class="control">
<input name="current_password" class="input" type="password" placeholder="My current password">
</div>
</div>
<div class="field">
<label class="label">New password</label>
<div class="control">
<input name="new_password" class="input" type="password" placeholder="My new password">
</div>
</div>
<div class="field">
<label class="label">New password (confirmation)</label>
<div class="control">
<input name="new_password_confirm" class="input" type="password" placeholder="My new password">
</div>
</div>
<button type="submit" class="button is-danger is-link">Change</button>
</form>
</div>
</article>
</div>
</section>
</main>
{% endblock %}