add admin section w/ basic user roles system

This commit is contained in:
azdle 2025-07-25 15:20:34 -05:00
parent ef3cc5a11b
commit 17f1b29951
8 changed files with 214 additions and 4 deletions

View file

@ -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)
);

View file

@ -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<AppState> 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()

View file

@ -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<AppState> {
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<UserRecord>,
}
struct UserRecord {
id: Uuid,
email: String,
status: String,
}
#[tracing::instrument(skip(auth))]
pub async fn users_page(
State(AppState { db, .. }): State<AppState>,
auth: Auth,
) -> Result<impl IntoResponse, AdminError> {
info!("get login page");
if !auth.has_role(crate::server::session::Role::Admin) {
return Ok((StatusCode::FORBIDDEN).into_response());
}
let users: Vec<UserRecord> = 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()
}
}

View file

@ -1,3 +1,4 @@
mod admin;
mod auth;
mod subscriptions;
@ -12,6 +13,7 @@ pub fn build() -> Router<AppState> {
.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),
}
}

View file

@ -8,44 +8,108 @@ 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<String>,
roles: Vec<Role>,
}
impl User {
pub fn new(id: Uuid) -> User {
User { id }
User {
id,
roles: Vec::new(),
}
}
pub async fn fetch(db: &PgPool, id: Uuid) -> Option<User> {
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 {
// id: Uuid,
pub session: Session,
pub user: Option<User>,
}
impl Auth {
pub fn has_role(&self, role: Role) -> bool {
if let Some(user) = &self.user {
user.roles().contains(&role)
} else {
false
}
}
}
impl<S> FromRequestParts<S> 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<Self, Self::Rejection> {
let session = Session::from_request_parts(parts, state).await?;
let db = state.get_pg_pool();
let user: Option<User> = 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,

7
templates/admin.html Normal file
View file

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<title>Admin</title>
<ul>
<li><a href="/admin/users">Users</a></li>
</ul>
</html>

View file

@ -8,9 +8,15 @@
<li><a href="/auth/signup">Signup</a></li>
</ul>
{% if is_logged_in %}
<h1>Super Secret Pages</h1>
<h1>Secret Pages</h1>
<ul>
<li><a href="/auth/logout">Logout</a></li>
</ul>
{% endif %}
{% if is_admin %}
<h1>Super Secret Pages</h1>
<ul>
<li><a href="/admin">Admin</a></li>
</ul>
{% endif %}
</html>

21
templates/users.html Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<title>Users</title>
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<th><a href="/admin/users/{{user.id}}">{{user.id}}</a></th>
<td>{{user.email}}</td>
<td>{{user.status}}</td>
{% endfor %}
<tbody>
</table>
</html>