From 17f1b299512a7aaba39266de51db51ffaea057bf Mon Sep 17 00:00:00 2001 From: azdle Date: Fri, 25 Jul 2025 15:20:34 -0500 Subject: [PATCH] add admin section w/ basic user roles system --- migrations/20250725194521_add-user-roles.sql | 6 ++ src/server/mod.rs | 7 ++ src/server/routes/admin/mod.rs | 95 ++++++++++++++++++++ src/server/routes/mod.rs | 4 + src/server/session/mod.rs | 70 ++++++++++++++- templates/admin.html | 7 ++ templates/homepage.html | 8 +- templates/users.html | 21 +++++ 8 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 migrations/20250725194521_add-user-roles.sql create mode 100644 src/server/routes/admin/mod.rs create mode 100644 templates/admin.html create mode 100644 templates/users.html diff --git a/migrations/20250725194521_add-user-roles.sql b/migrations/20250725194521_add-user-roles.sql new file mode 100644 index 0000000..2101ca7 --- /dev/null +++ b/migrations/20250725194521_add-user-roles.sql @@ -0,0 +1,6 @@ +CREATE TABLE user_roles( + role TEXT NOT NULL, + user_id uuid NOT NULL + REFERENCES users (id), + PRIMARY KEY (user_id, role) +); diff --git a/src/server/mod.rs b/src/server/mod.rs index b7c0df2..8012aa8 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -6,6 +6,7 @@ use axum::extract::FromRef; use axum_extra::extract::cookie::Key; use futures_util::FutureExt as _; use pin_project::pin_project; +use session::GetPgPool; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use std::future::{Future, IntoFuture}; @@ -111,6 +112,12 @@ impl FromRef for Key { } } +impl GetPgPool for AppState { + fn get_pg_pool(&self) -> PgPool { + self.db.clone() + } +} + async fn shutdown_signal() { let ctrl_c = async { signal::ctrl_c() diff --git a/src/server/routes/admin/mod.rs b/src/server/routes/admin/mod.rs new file mode 100644 index 0000000..f7cd4ea --- /dev/null +++ b/src/server/routes/admin/mod.rs @@ -0,0 +1,95 @@ +use anyhow::Context; +use askama::Template; +use askama_web::WebTemplate; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Router}; +use tracing::info; +use uuid::Uuid; + +use crate::server::{session::Auth, AppState}; + +use super::ErrorPage; + +pub fn build() -> Router { + Router::new() + .route("/", get(index_page)) + .route("/users", get(users_page)) +} + +#[derive(Template, WebTemplate, Default)] +#[template(path = "admin.html")] +struct IndexPage; + +#[tracing::instrument(skip(auth))] +pub async fn index_page(auth: Auth) -> impl IntoResponse { + info!("get admin index page"); + + if !auth.has_role(crate::server::session::Role::Admin) { + return (StatusCode::FORBIDDEN).into_response(); + } + + IndexPage.into_response() +} + +#[derive(Template, WebTemplate, Default)] +#[template(path = "users.html")] +struct UsersPage { + users: Vec, +} + +struct UserRecord { + id: Uuid, + email: String, + status: String, +} + +#[tracing::instrument(skip(auth))] +pub async fn users_page( + State(AppState { db, .. }): State, + auth: Auth, +) -> Result { + info!("get login page"); + + if !auth.has_role(crate::server::session::Role::Admin) { + return Ok((StatusCode::FORBIDDEN).into_response()); + } + + let users: Vec = sqlx::query!( + r#" + SELECT id, email, status FROM users; + "# + ) + .fetch_all(&db) + .await + .context("fetch users from db")? + .into_iter() + .map(|r| UserRecord { + id: r.id, + email: r.email, + status: r.status, + }) + .collect(); + + Ok(UsersPage { users }.into_response()) +} + +#[derive(thiserror::Error, Debug)] +pub enum AdminError { + #[error("Unknown Error: {0}")] + Unknown(#[from] anyhow::Error), +} + +impl IntoResponse for AdminError { + fn into_response(self) -> axum::response::Response { + let (status, message) = match self { + AdminError::Unknown(e) => (StatusCode::INTERNAL_SERVER_ERROR, e), + }; + + ( + status, + ErrorPage { + error: message.to_string(), + }, + ) + .into_response() + } +} diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index e426331..efdbabe 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -1,3 +1,4 @@ +mod admin; mod auth; mod subscriptions; @@ -12,6 +13,7 @@ pub fn build() -> Router { .route("/", get(homepage)) .route("/health", get(health_check)) .nest("/auth", auth::build()) + .nest("/admin", admin::build()) .nest("/subscriptions", subscriptions::build()) } @@ -22,11 +24,13 @@ async fn health_check() {} #[template(path = "homepage.html")] struct Homepage { is_logged_in: bool, + is_admin: bool, } async fn homepage(user: Auth) -> Homepage { Homepage { is_logged_in: user.user.is_some(), + is_admin: user.has_role(super::session::Role::Admin), } } diff --git a/src/server/session/mod.rs b/src/server/session/mod.rs index 0f836a2..be5d8d6 100644 --- a/src/server/session/mod.rs +++ b/src/server/session/mod.rs @@ -8,19 +8,61 @@ use axum::{ use axum_extra::extract::{cookie::Cookie, CookieJar}; use rand::{distr::Alphanumeric, rng, Rng as _}; use serde::{Deserialize, Serialize}; +use sqlx::PgPool; use tower_sessions::Session; use uuid::Uuid; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { pub id: Uuid, - // roles: Vec, + roles: Vec, } impl User { pub fn new(id: Uuid) -> User { - User { id } + User { + id, + roles: Vec::new(), + } } + + pub async fn fetch(db: &PgPool, id: Uuid) -> Option { + let roles = sqlx::query!( + r#" + SELECT role FROM user_roles WHERE user_id = $1; + "#, + id + ) + .fetch_all(db) + .await; + + if let Ok(roles) = roles { + Some(User { + id, + roles: roles + .into_iter() + .filter_map(|r| match r.role.as_str() { + "user" => Some(Role::User), + "admin" => Some(Role::Admin), + _ => None, + }) + .collect(), + }) + } else { + None + } + } + + pub fn roles(&self) -> &[Role] { + &self.roles + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Role { + User, + Admin, } pub struct Auth { @@ -29,23 +71,45 @@ pub struct Auth { pub user: Option, } +impl Auth { + pub fn has_role(&self, role: Role) -> bool { + if let Some(user) = &self.user { + user.roles().contains(&role) + } else { + false + } + } +} + impl FromRequestParts for Auth where - S: Send + Sync, + S: Send + Sync + GetPgPool, { type Rejection = (StatusCode, &'static str); async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let session = Session::from_request_parts(parts, state).await?; + let db = state.get_pg_pool(); let user: Option = session.get("user").await.unwrap_or_default(); + // pull roles from db + let user = if let Some(user) = user { + User::fetch(&db, user.id).await + } else { + user + }; + // session.insert("user", &data).await.unwrap(); Ok(Self { session, user }) } } +pub trait GetPgPool { + fn get_pg_pool(&self) -> PgPool; +} + pub struct CsrfCookie { jar: CookieJar, token: String, diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..2646b60 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,7 @@ + + + Admin + + diff --git a/templates/homepage.html b/templates/homepage.html index d50a761..f9ea749 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -8,9 +8,15 @@
  • Signup
  • {% if is_logged_in %} -

    Super Secret Pages

    +

    Secret Pages

    {% endif %} + {% if is_admin %} +

    Super Secret Pages

    + + {% endif %} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..c81b7d0 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,21 @@ + + + Users + + + + + + + + + + {% for user in users %} + + + + + {% endfor %} + +
    IDEmailStatus
    {{user.id}}{{user.email}}{{user.status}}
    +