Manage users on admin panel
Some checks failed
Rust / Test (push) Failing after 4m18s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Failing after 1m39s
Rust / Code coverage (push) Successful in 4m25s

This commit is contained in:
Alphonse Paix
2025-10-02 22:13:02 +02:00
parent 91e80b4881
commit 96e5dd0f35
12 changed files with 324 additions and 35 deletions

View File

@@ -56,7 +56,7 @@ pub async fn change_password(
}
}
fn verify_password(password: &str) -> Result<(), anyhow::Error> {
pub fn verify_password(password: &str) -> Result<(), anyhow::Error> {
if password.len() < 12 || password.len() > 128 {
anyhow::bail!("The password must contain between 12 and 128 characters.");
}

View File

@@ -1,3 +1,4 @@
use crate::routes::get_users;
use crate::{
authentication::AuthenticatedUser,
routes::{AppError, get_max_page, get_subs, get_total_subs},
@@ -45,6 +46,9 @@ pub async fn admin_dashboard(
.await
.context("Could not fetch total subscribers count from the database.")?;
let max_page = get_max_page(count);
let users = get_users(&connection_pool)
.await
.context("Could not fetch users")?;
let template = DashboardTemplate {
user,
idempotency_key_1,
@@ -53,6 +57,7 @@ pub async fn admin_dashboard(
subscribers,
current_page,
max_page,
users,
};
Ok(Html(template.render().unwrap()).into_response())
}

View File

@@ -15,10 +15,7 @@ use uuid::Uuid;
const SUBS_PER_PAGE: i64 = 5;
#[tracing::instrument(
name = "Retrieving most recent subscribers from database",
skip(connection_pool)
)]
#[tracing::instrument(name = "Retrieving subscribers from database", skip(connection_pool))]
pub async fn get_subscribers_page(
State(AppState {
connection_pool, ..

View File

@@ -1,3 +1,5 @@
use crate::routes::verify_password;
use crate::templates::MessageTemplate;
use crate::{
authentication::Role,
domain::{PostEntry, UserEntry},
@@ -7,9 +9,11 @@ use crate::{
};
use anyhow::Context;
use axum::{
Form,
extract::{Path, State},
response::{IntoResponse, Response},
};
use secrecy::{ExposeSecret, SecretString};
use sqlx::PgPool;
use uuid::Uuid;
@@ -18,6 +22,125 @@ pub struct ProfilePath {
username: String,
}
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
pub async fn get_users(connection_pool: &PgPool) -> Result<Vec<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
FROM users
ORDER BY member_since DESC
"#
)
.fetch_all(connection_pool)
.await
}
#[derive(Debug, serde::Deserialize)]
pub struct CreateUserForm {
username: String,
password: SecretString,
password_check: SecretString,
admin: Option<bool>,
}
struct NewUser {
username: String,
password_hash: SecretString,
role: Role,
}
impl TryFrom<CreateUserForm> for NewUser {
type Error = anyhow::Error;
fn try_from(value: CreateUserForm) -> Result<Self, Self::Error> {
if value.username.trim().is_empty() {
anyhow::bail!("Username cannot be empty.");
}
verify_password(value.password.expose_secret())?;
if value.password.expose_secret() != value.password_check.expose_secret() {
anyhow::bail!("Password mismatch.");
}
let role = value.admin.map(|_| Role::Admin).unwrap_or(Role::Writer);
let password_hash = crate::authentication::compute_pasword_hash(value.password)
.context("Failed to hash password.")?;
Ok(Self {
username: value.username,
password_hash,
role,
})
}
}
#[tracing::instrument(name = "Creating new user", skip_all, fields(username = %form.username))]
pub async fn create_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Form(form): Form<CreateUserForm>,
) -> Result<Response, AppError> {
let new_user: NewUser = match form.try_into().map_err(|e: anyhow::Error| e.to_string()) {
Err(e) => {
let template = HtmlTemplate(MessageTemplate::error(e));
return Ok(template.into_response());
}
Ok(new_user) => new_user,
};
insert_user(&connection_pool, new_user)
.await
.context("Could not insert user in database.")?;
let template = HtmlTemplate(MessageTemplate::success(
"The new user has been created.".into(),
));
Ok(template.into_response())
}
async fn insert_user(connection_pool: &PgPool, new_user: NewUser) -> Result<Uuid, sqlx::Error> {
let user_id = Uuid::new_v4();
sqlx::query!(
r#"
INSERT INTO users (user_id, username, password_hash, role)
VALUES ($1, $2, $3, $4)
"#,
user_id,
new_user.username,
new_user.password_hash.expose_secret(),
new_user.role as _
)
.execute(connection_pool)
.await?;
Ok(user_id)
}
#[derive(serde::Deserialize)]
pub struct SubscriberPathParams {
pub user_id: Uuid,
}
#[tracing::instrument(name = "Delete user from database", skip(connection_pool))]
pub async fn delete_user(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(SubscriberPathParams { user_id }): Path<SubscriberPathParams>,
) -> Result<Response, AppError> {
let result = sqlx::query!("DELETE FROM users WHERE user_id = $1", user_id)
.execute(&connection_pool)
.await
.context("Failed to delete user from database.")?;
let template = if result.rows_affected() == 0 {
HtmlTemplate(MessageTemplate::error(
"The user could not be deleted.".into(),
))
} else {
HtmlTemplate(MessageTemplate::success(
"The user has been deleted.".into(),
))
};
Ok(template.into_response())
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
pub async fn user_profile(
State(AppState {