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 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()
|
||||
|
|
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 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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 {
|
||||
|
@ -29,23 +71,45 @@ pub struct Auth {
|
|||
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
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>
|
||||
</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
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