User profile and admin privileges

This commit is contained in:
Alphonse Paix
2025-10-01 01:17:59 +02:00
parent 3e81c27ab3
commit 402c560354
22 changed files with 467 additions and 43 deletions

View File

@@ -1,3 +1,5 @@
use std::fmt::Display;
use crate::telemetry::spawn_blocking_with_tracing;
use anyhow::Context;
use argon2::{
@@ -138,6 +140,15 @@ pub enum Role {
Writer,
}
impl Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::Admin => write!(f, "admin"),
Role::Writer => write!(f, "writer"),
}
}
}
#[derive(Clone)]
pub struct AuthenticatedUser {
pub user_id: Uuid,

View File

@@ -2,8 +2,10 @@ mod new_subscriber;
mod post;
mod subscriber_email;
mod subscribers;
mod user;
pub use new_subscriber::NewSubscriber;
pub use post::PostEntry;
pub use subscriber_email::SubscriberEmail;
pub use subscribers::SubscriberEntry;
pub use user::UserEntry;

View File

@@ -3,14 +3,13 @@ use uuid::Uuid;
pub struct PostEntry {
pub post_id: Uuid,
pub author: Option<String>,
pub author: String,
pub title: String,
pub content: String,
pub published_at: DateTime<Utc>,
}
impl PostEntry {
#[allow(dead_code)]
pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string()
}

23
src/domain/user.rs Normal file
View File

@@ -0,0 +1,23 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::authentication::Role;
pub struct UserEntry {
pub user_id: Uuid,
pub username: String,
pub role: Role,
pub full_name: Option<String>,
pub bio: Option<String>,
pub member_since: DateTime<Utc>,
}
impl UserEntry {
pub fn formatted_date(&self) -> String {
self.member_since.format("%B %d, %Y").to_string()
}
pub fn is_admin(&self) -> bool {
matches!(self.role, Role::Admin)
}
}

View File

@@ -6,6 +6,7 @@ mod posts;
mod subscriptions;
mod subscriptions_confirm;
mod unsubscribe;
mod users;
pub use admin::*;
use askama::Template;
@@ -24,6 +25,7 @@ use serde::de::DeserializeOwned;
pub use subscriptions::*;
pub use subscriptions_confirm::*;
pub use unsubscribe::*;
pub use users::*;
use crate::{
authentication::AuthError,

82
src/routes/users.rs Normal file
View File

@@ -0,0 +1,82 @@
use crate::{
authentication::Role,
domain::{PostEntry, UserEntry},
routes::{AppError, not_found_html},
startup::AppState,
templates::{HtmlTemplate, UserTemplate},
};
use anyhow::Context;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct ProfilePath {
username: String,
}
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
pub async fn user_profile(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(ProfilePath { username }): Path<ProfilePath>,
) -> Result<Response, AppError> {
match fetch_user_data(&connection_pool, &username)
.await
.context("Failed to fetch user data.")?
{
Some(user) => {
let posts = fetch_user_posts(&connection_pool, &user.user_id)
.await
.context("Could not fetch user posts.")?;
let template = HtmlTemplate(UserTemplate { user, posts });
Ok(template.into_response())
}
None => {
tracing::error!(username = %username, "user not found");
Ok(not_found_html().into_response())
}
}
}
#[tracing::instrument(name = "Fetching user profile", skip_all)]
async fn fetch_user_data(
connection_pool: &PgPool,
username: &str,
) -> Result<Option<UserEntry>, sqlx::Error> {
sqlx::query_as!(
UserEntry,
r#"
SELECT user_id, username, full_name, role as "role: Role", member_since, bio
FROM users
WHERE username = $1
"#,
username
)
.fetch_optional(connection_pool)
.await
}
#[tracing::instrument(name = "Fetching user posts", skip_all)]
async fn fetch_user_posts(
connection_pool: &PgPool,
user_id: &Uuid,
) -> Result<Vec<PostEntry>, sqlx::Error> {
sqlx::query_as!(
PostEntry,
r#"
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at
FROM posts p
INNER JOIN users u ON p.author_id = u.user_id
WHERE p.author_id = $1
ORDER BY p.published_at DESC
"#,
user_id
)
.fetch_all(connection_pool)
.await
}

View File

@@ -108,6 +108,7 @@ pub fn app(
.route("/posts", get(list_posts))
.route("/posts/load_more", get(load_more))
.route("/posts/{post_id}", get(see_post))
.route("/users/{username}", get(user_profile))
.route("/favicon.ico", get(favicon))
.nest("/admin", auth_routes)
.nest_service("/assets", ServeDir::new("assets"))

View File

@@ -21,7 +21,7 @@ where
)
.with(
tracing_subscriber::fmt::layer()
.pretty()
.compact()
.with_writer(sink)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)

View File

@@ -1,6 +1,6 @@
use crate::{
authentication::AuthenticatedUser,
domain::{PostEntry, SubscriberEntry},
domain::{PostEntry, SubscriberEntry, UserEntry},
routes::{AppError, DashboardStats},
};
use askama::Template;
@@ -21,6 +21,13 @@ where
}
}
#[derive(Template)]
#[template(path = "user/profile.html")]
pub struct UserTemplate {
pub user: UserEntry,
pub posts: Vec<PostEntry>,
}
#[derive(Template)]
#[template(path = "message.html")]
pub struct MessageTemplate {