add admin section w/ basic user roles system
This commit is contained in:
parent
ef3cc5a11b
commit
17f1b29951
8 changed files with 214 additions and 4 deletions
6
migrations/20250725194521_add-user-roles.sql
Normal file
6
migrations/20250725194521_add-user-roles.sql
Normal 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)
|
||||||
|
);
|
|
@ -6,6 +6,7 @@ use axum::extract::FromRef;
|
||||||
use axum_extra::extract::cookie::Key;
|
use axum_extra::extract::cookie::Key;
|
||||||
use futures_util::FutureExt as _;
|
use futures_util::FutureExt as _;
|
||||||
use pin_project::pin_project;
|
use pin_project::pin_project;
|
||||||
|
use session::GetPgPool;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::future::{Future, IntoFuture};
|
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() {
|
async fn shutdown_signal() {
|
||||||
let ctrl_c = async {
|
let ctrl_c = async {
|
||||||
signal::ctrl_c()
|
signal::ctrl_c()
|
||||||
|
|
95
src/server/routes/admin/mod.rs
Normal file
95
src/server/routes/admin/mod.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod admin;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ pub fn build() -> Router<AppState> {
|
||||||
.route("/", get(homepage))
|
.route("/", get(homepage))
|
||||||
.route("/health", get(health_check))
|
.route("/health", get(health_check))
|
||||||
.nest("/auth", auth::build())
|
.nest("/auth", auth::build())
|
||||||
|
.nest("/admin", admin::build())
|
||||||
.nest("/subscriptions", subscriptions::build())
|
.nest("/subscriptions", subscriptions::build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,11 +24,13 @@ async fn health_check() {}
|
||||||
#[template(path = "homepage.html")]
|
#[template(path = "homepage.html")]
|
||||||
struct Homepage {
|
struct Homepage {
|
||||||
is_logged_in: bool,
|
is_logged_in: bool,
|
||||||
|
is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn homepage(user: Auth) -> Homepage {
|
async fn homepage(user: Auth) -> Homepage {
|
||||||
Homepage {
|
Homepage {
|
||||||
is_logged_in: user.user.is_some(),
|
is_logged_in: user.user.is_some(),
|
||||||
|
is_admin: user.has_role(super::session::Role::Admin),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,19 +8,61 @@ use axum::{
|
||||||
use axum_extra::extract::{cookie::Cookie, CookieJar};
|
use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||||
use rand::{distr::Alphanumeric, rng, Rng as _};
|
use rand::{distr::Alphanumeric, rng, Rng as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
// roles: Vec<String>,
|
roles: Vec<Role>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn new(id: Uuid) -> 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 {
|
pub struct Auth {
|
||||||
|
@ -29,23 +71,45 @@ pub struct Auth {
|
||||||
pub user: Option<User>,
|
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
|
impl<S> FromRequestParts<S> for Auth
|
||||||
where
|
where
|
||||||
S: Send + Sync,
|
S: Send + Sync + GetPgPool,
|
||||||
{
|
{
|
||||||
type Rejection = (StatusCode, &'static str);
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let session = Session::from_request_parts(parts, state).await?;
|
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();
|
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();
|
// session.insert("user", &data).await.unwrap();
|
||||||
|
|
||||||
Ok(Self { session, user })
|
Ok(Self { session, user })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait GetPgPool {
|
||||||
|
fn get_pg_pool(&self) -> PgPool;
|
||||||
|
}
|
||||||
|
|
||||||
pub struct CsrfCookie {
|
pub struct CsrfCookie {
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
token: String,
|
token: String,
|
||||||
|
|
7
templates/admin.html
Normal file
7
templates/admin.html
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<title>Admin</title>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/admin/users">Users</a></li>
|
||||||
|
</ul>
|
||||||
|
</html>
|
|
@ -8,9 +8,15 @@
|
||||||
<li><a href="/auth/signup">Signup</a></li>
|
<li><a href="/auth/signup">Signup</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% if is_logged_in %}
|
{% if is_logged_in %}
|
||||||
<h1>Super Secret Pages</h1>
|
<h1>Secret Pages</h1>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/auth/logout">Logout</a></li>
|
<li><a href="/auth/logout">Logout</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_admin %}
|
||||||
|
<h1>Super Secret Pages</h1>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/admin">Admin</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</html>
|
</html>
|
||||||
|
|
21
templates/users.html
Normal file
21
templates/users.html
Normal 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>
|
Loading…
Add table
Reference in a new issue