reset/update password
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/fmt Pipeline failed
ci/woodpecker/push/test Pipeline was successful

This commit is contained in:
2026-08-03 11:33:24 -04:00
parent 3160a898be
commit a6482ddb0e
7 changed files with 133 additions and 1 deletions
+38
View File
@@ -92,6 +92,7 @@ pub fn build_router(state: AppState) -> Router {
"/account/passkeys/{passkey_id}/delete",
post(delete_passkey),
)
.route("/account/password", post(change_password))
.route("/lists", get(lists_page).post(create_list))
.route("/lists/{list_id}", get(list_page))
.route("/lists/{list_id}/items", post(add_item))
@@ -274,6 +275,13 @@ struct DeletePasskeyForm {
csrf: String,
}
#[derive(Debug, Deserialize)]
struct ChangePasswordForm {
csrf: String,
new_password: String,
confirm_password: String,
}
#[derive(Debug, Deserialize)]
struct MealForm {
name: String,
@@ -448,9 +456,39 @@ async fn account_page(
&user.session.user,
&passkeys,
&user.session.csrf_token,
None,
false,
)))
}
async fn change_password(
State(state): State<AppState>,
user: CurrentUser,
LoggedForm(form): LoggedForm<ChangePasswordForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let passkeys = state.webauthn.list_passkeys(user.session.user.id).await?;
let render = |error: Option<&str>, success: bool| {
html_response(views::account_page(
&user.session.user,
&passkeys,
&user.session.csrf_token,
error,
success,
))
};
if form.new_password != form.confirm_password {
return Ok(render(Some("New password and confirmation do not match."), false));
}
state
.auth
.change_password(user.session.user.id, form.new_password)
.await?;
Ok(render(None, true))
}
async fn passkey_register_start(
State(state): State<AppState>,
user: CurrentUser,
+6
View File
@@ -29,6 +29,12 @@ pub trait UserRepository: Send + Sync {
txn: &mut SqliteConnection,
user_handle: Vec<u8>,
) -> DomainResult<Option<User>>;
async fn update_password_hash(
&self,
txn: &mut SqliteConnection,
user_id: i64,
password_hash: String,
) -> DomainResult<()>;
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool>;
}
+16
View File
@@ -147,6 +147,22 @@ impl AuthService {
})
.await
}
/// Replaces the user's password hash with a freshly hashed new password.
/// No current-password check is performed because the account page is
/// already authenticated and this app has no email capabilities.
pub async fn change_password(&self, user_id: i64, new_password: String) -> DomainResult<()> {
let users = Arc::clone(&self.users);
let hasher = Arc::clone(&self.hasher);
self.db
.run(move |txn| {
Box::pin(async move {
let new_hash = hasher.hash(&new_password)?;
users.update_password_hash(txn, user_id, new_hash).await
})
})
.await
}
}
pub struct ListService {
+15
View File
@@ -209,6 +209,21 @@ impl UserRepository for SqliteUserRepository {
}))
}
async fn update_password_hash(
&self,
txn: &mut SqliteConnection,
user_id: i64,
password_hash: String,
) -> DomainResult<()> {
sqlx::query("UPDATE users SET password_hash = ?1 WHERE id = ?2")
.bind(&password_hash)
.bind(user_id)
.execute(&mut *txn)
.await
.map_err(db_error)?;
Ok(())
}
async fn has_users(&self, txn: &mut SqliteConnection) -> DomainResult<bool> {
let row = sqlx::query("SELECT EXISTS(SELECT 1 FROM users)")
.fetch_one(&mut *txn)
+27 -1
View File
@@ -82,7 +82,13 @@ pub fn registration_closed_page() -> Markup {
)
}
pub fn account_page(user: &User, passkeys: &[Passkey], csrf_token: &str) -> Markup {
pub fn account_page(
user: &User,
passkeys: &[Passkey],
csrf_token: &str,
password_error: Option<&str>,
password_success: bool,
) -> Markup {
page(
"Account",
Some(user),
@@ -121,6 +127,26 @@ pub fn account_page(user: &User, passkeys: &[Passkey], csrf_token: &str) -> Mark
}
button id="add-passkey" class="button button-primary" type="button" data-csrf=(csrf_token) { "Add a passkey" }
}
section class="panel" {
div class="panel-heading" {
h2 { "Password" }
}
p { "Set a new password for your account." }
@if let Some(error) = password_error {
div class="alert alert-error" role="alert" { (error) }
}
@if password_success {
div class="alert alert-success" role="alert" { "Your password has been updated." }
}
form method="post" action="/account/password" class="stack" {
input type="hidden" name="csrf" value=(csrf_token);
label for="new-password" { "New password" }
input id="new-password" name="new_password" type="password" autocomplete="new-password" required;
label for="confirm-password" { "Confirm new password" }
input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required;
button class="button button-primary" type="submit" { "Update password" }
}
}
}
script src="/static/passkey-register.js" {}
},