From 96e5dd0f35184e4abf68b08fcb6d37c5d278d4c8 Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Thu, 2 Oct 2025 22:13:02 +0200 Subject: [PATCH] Manage users on admin panel --- src/authentication.rs | 2 +- src/domain/user.rs | 3 +- src/routes/admin/change_password.rs | 2 +- src/routes/admin/dashboard.rs | 5 ++ src/routes/admin/subscribers.rs | 5 +- src/routes/users.rs | 123 ++++++++++++++++++++++++++++ src/startup.rs | 2 + src/templates.rs | 1 + templates/dashboard/dashboard.html | 59 +++++++------ templates/dashboard/users/card.html | 39 +++++++++ templates/dashboard/users/form.html | 78 ++++++++++++++++++ templates/dashboard/users/list.html | 40 +++++++++ 12 files changed, 324 insertions(+), 35 deletions(-) create mode 100644 templates/dashboard/users/card.html create mode 100644 templates/dashboard/users/form.html create mode 100644 templates/dashboard/users/list.html diff --git a/src/authentication.rs b/src/authentication.rs index eacbc1b..6d20c97 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -43,7 +43,7 @@ pub async fn change_password( Ok(()) } -fn compute_pasword_hash(password: SecretString) -> Result { +pub(crate) fn compute_pasword_hash(password: SecretString) -> Result { let salt = SaltString::generate(&mut OsRng); let password_hash = Argon2::new( Algorithm::Argon2id, diff --git a/src/domain/user.rs b/src/domain/user.rs index cab8222..93899c4 100644 --- a/src/domain/user.rs +++ b/src/domain/user.rs @@ -1,8 +1,7 @@ +use crate::authentication::Role; use chrono::{DateTime, Utc}; use uuid::Uuid; -use crate::authentication::Role; - pub struct UserEntry { pub user_id: Uuid, pub username: String, diff --git a/src/routes/admin/change_password.rs b/src/routes/admin/change_password.rs index 4b527a5..d6320c5 100644 --- a/src/routes/admin/change_password.rs +++ b/src/routes/admin/change_password.rs @@ -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."); } diff --git a/src/routes/admin/dashboard.rs b/src/routes/admin/dashboard.rs index a9587bb..1a7c09f 100644 --- a/src/routes/admin/dashboard.rs +++ b/src/routes/admin/dashboard.rs @@ -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()) } diff --git a/src/routes/admin/subscribers.rs b/src/routes/admin/subscribers.rs index c8b6eac..3cb4a73 100644 --- a/src/routes/admin/subscribers.rs +++ b/src/routes/admin/subscribers.rs @@ -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, .. diff --git a/src/routes/users.rs b/src/routes/users.rs index 5673c38..876fa4e 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -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, 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, +} + +struct NewUser { + username: String, + password_hash: SecretString, + role: Role, +} + +impl TryFrom for NewUser { + type Error = anyhow::Error; + + fn try_from(value: CreateUserForm) -> Result { + 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, + Form(form): Form, +) -> Result { + 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 { + 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, + Path(SubscriberPathParams { user_id }): Path, +) -> Result { + 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 { diff --git a/src/startup.rs b/src/startup.rs index 3d7b521..91999c3 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -88,6 +88,8 @@ pub fn app( .route("/subscribers", get(get_subscribers_page)) .route("/subscribers/{subscriber_id}", delete(delete_subscriber)) .route("/posts/{post_id}", delete(delete_post)) + .route("/users", post(create_user)) + .route("/users/{user_id}", delete(delete_user)) .layer(middleware::from_fn(require_admin)); let auth_routes = Router::new() .route("/dashboard", get(admin_dashboard)) diff --git a/src/templates.rs b/src/templates.rs index 8551492..42abe83 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -64,6 +64,7 @@ pub struct DashboardTemplate { pub subscribers: Vec, pub current_page: i64, pub max_page: i64, + pub users: Vec, } #[derive(Template)] diff --git a/templates/dashboard/dashboard.html b/templates/dashboard/dashboard.html index 75c308b..24007d2 100644 --- a/templates/dashboard/dashboard.html +++ b/templates/dashboard/dashboard.html @@ -1,39 +1,44 @@ {% extends "base.html" %} {% block title %}Dashboard{% endblock %} {% block content %} -
-
-

Dashboard

-

+

+
+

Dashboard

+

Connected as {{ user.username }} - {% if user.is_admin() %} - + {% if user.is_admin() %} + admin - {% endif %} -

- -
- {% if user.is_admin() %} -
-

Administration

- {% include "stats.html" %} - {% include "subscribers/list.html" %} -
- {% endif %} + {% endif %} +

+ +
+ {% if user.is_admin() %} +
+

Administration

+ {% include "stats.html" %} + {% include "subscribers/list.html" %}
- {% include "publish.html" %} - {% include "send_email.html" %} - {% include "change_password.html" %} + {% include "users/list.html" %} + {% include "users/form.html" %}
+ {% endif %} +
+ {% include "publish.html" %} + {% include "send_email.html" %} + {% include "change_password.html" %} +
+
{% endblock %} diff --git a/templates/dashboard/users/card.html b/templates/dashboard/users/card.html new file mode 100644 index 0000000..3578326 --- /dev/null +++ b/templates/dashboard/users/card.html @@ -0,0 +1,39 @@ +
+
+
+
+ + {{ user.username }} + + {% if user.role.to_string() == "admin" %} + + admin + + {% else %} + + writer + + {% endif %} +
+
{{ user.formatted_date() }}
+
+
+ +
+
+
diff --git a/templates/dashboard/users/form.html b/templates/dashboard/users/form.html new file mode 100644 index 0000000..976940d --- /dev/null +++ b/templates/dashboard/users/form.html @@ -0,0 +1,78 @@ +
+
+
+
+

+ + + + Create a new user +

+

Add a new user to the system.

+
+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+
\ No newline at end of file diff --git a/templates/dashboard/users/list.html b/templates/dashboard/users/list.html new file mode 100644 index 0000000..e3c6bf0 --- /dev/null +++ b/templates/dashboard/users/list.html @@ -0,0 +1,40 @@ +
+
+
+
+

+ + + + Users management +

+

View and manage users.

+
+
+
+
+ {% if users.is_empty() %} +
+
+ + + +
+

No users found

+

No users in the system.

+
+ {% else %} + {% for user in users %} + {% include "dashboard/users/card.html" %} + {% endfor %} + {% endif %} +
+
\ No newline at end of file