feat: implement audit logs system, request extractor, admin log panel, and dedicated documentation

- Added an enterprise-grade, request-scoped AuditLogger extractor in Axum.
- Configured MongoDB persistence for structured, replayable audit logs (capturing timestamp, user, action, type, payload snapshot, client IP with proxy header support, and User-Agent).
- Created a live Administrator console at /auth/audit to filter and inspect log events.
- Re-architected documentation by moving Design Wiki pages out of /components into a dedicated /docs route.
- Published logging architecture documentation at /docs/logging.
This commit is contained in:
2026-05-30 18:23:49 +05:00
parent f6ea8a99d9
commit 4c98dd93ad
34 changed files with 1389 additions and 134 deletions
+75
View File
@@ -0,0 +1,75 @@
use askama::Template;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse, Response},
http::StatusCode,
};
use serde::Deserialize;
use crate::common::errors::AppError;
use crate::auth::extractors::AuthenticatedUser;
use crate::audit::repository::MongoAuditRepository;
use crate::audit::models::AuditLog;
// Wrapper for rendering Askama HTML
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => {
tracing::error!("Failed to render template: {:?}", err);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct AuditQuery {
pub username: Option<String>,
pub action_type: Option<String>,
pub entity_type: Option<String>,
pub entity_id: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Template)]
#[template(path = "audit/list.html")]
pub struct AuditListTemplate {
pub authenticated: bool,
pub username: String,
pub logs: Vec<AuditLog>,
pub query: AuditQuery,
}
pub async fn get_audit_logs(
user: AuthenticatedUser,
State(audit_repo): State<MongoAuditRepository>,
Query(query): Query<AuditQuery>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can access audit logs".to_string()));
}
let logs = audit_repo.find_filtered(
query.username.as_deref(),
query.action_type.as_deref(),
query.entity_type.as_deref(),
query.entity_id.as_deref(),
query.start_date.as_deref(),
query.end_date.as_deref(),
).await?;
Ok(HtmlTemplate(AuditListTemplate {
authenticated: true,
username: user.username,
logs,
query,
})
.into_response())
}
+165
View File
@@ -0,0 +1,165 @@
pub mod models;
pub mod repository;
pub mod handlers;
use axum::{routing::get, Router, extract::FromRef};
use crate::common::config::Config;
use crate::audit::repository::MongoAuditRepository;
use crate::auth::extractors::AuthenticatedUser;
use crate::audit::models::AuditLog;
use axum::extract::ConnectInfo;
use std::net::SocketAddr;
pub fn router<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoAuditRepository: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/audit", get(handlers::get_audit_logs))
}
/// Lower-level helper function to write audit log entries directly to the database.
pub async fn log_action(
db: &mongodb::Database,
user_opt: Option<&AuthenticatedUser>,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
ip_address: Option<String>,
user_agent: Option<String>,
) {
let log = AuditLog {
id: None,
timestamp: chrono::Utc::now(),
user_id: user_opt.map(|u| u.user_id),
username: user_opt.map(|u| u.username.clone()),
action_type: action_type.to_string(),
entity_type: entity_type.to_string(),
entity_id,
details,
payload,
ip_address,
user_agent,
};
let repo = MongoAuditRepository::new(db.clone());
if let Err(e) = repo.insert(log).await {
tracing::error!("Failed to write audit log: {:?}", e);
}
}
/// A request-scoped extractor that automatically resolves request metadata (IP, User-Agent),
/// retrieves the database connection, and captures the optional authenticated user.
/// This pattern simplifies audit trail logging inside handler functions to a single line.
pub struct AuditLogger {
pub db: mongodb::Database,
pub user: Option<AuthenticatedUser>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
impl<S> axum::extract::FromRequestParts<S> for AuditLogger
where
mongodb::Database: axum::extract::FromRef<S>,
Config: axum::extract::FromRef<S>,
S: Send + Sync,
{
type Rejection = crate::common::errors::AppError;
async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> {
let db = mongodb::Database::from_ref(state);
// Try to authenticate the user for this request. If it fails, user is None.
let user = AuthenticatedUser::from_request_parts(parts, state).await.ok();
let user_agent = parts.headers.get(axum::http::header::USER_AGENT)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let socket_ip = parts.extensions.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string());
let proxy_ip = parts.headers.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.or_else(|| {
parts.headers.get("x-real-ip")
.and_then(|h| h.to_str().ok())
.map(|s| s.trim().to_string())
});
let ip_address = match (proxy_ip, socket_ip) {
(Some(p_ip), Some(s_ip)) => {
if p_ip == s_ip {
Some(p_ip)
} else {
Some(format!("{} (Socket: {})", p_ip, s_ip))
}
}
(Some(p_ip), None) => Some(p_ip),
(None, Some(s_ip)) => Some(s_ip),
(None, None) => None,
};
Ok(AuditLogger {
db,
user,
ip_address,
user_agent,
})
}
}
impl AuditLogger {
/// Logs an action for the currently authenticated user resolved by this request.
pub async fn log(
&self,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
) {
log_action(
&self.db,
self.user.as_ref(),
action_type,
entity_type,
entity_id,
details,
payload,
self.ip_address.clone(),
self.user_agent.clone(),
).await;
}
/// Logs an action with an explicitly provided user (e.g., during login sequences
/// before the user token cookie is set, or for login failures).
pub async fn log_with_user(
&self,
user: Option<&AuthenticatedUser>,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
) {
log_action(
&self.db,
user,
action_type,
entity_type,
entity_id,
details,
payload,
self.ip_address.clone(),
self.user_agent.clone(),
).await;
}
}
+35
View File
@@ -0,0 +1,35 @@
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuditLog {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
pub timestamp: chrono::DateTime<chrono::Utc>,
pub user_id: Option<ObjectId>,
pub username: Option<String>,
pub action_type: String, // e.g., Login, Logout, Create, Update, Delete, View, Search
pub entity_type: String, // e.g., User, Task, Developer, System
pub entity_id: Option<ObjectId>,
pub details: Option<String>,
pub payload: Option<serde_json::Value>, // State snapshot / diff for replayability
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
impl AuditLog {
pub fn formatted_payload(&self) -> String {
match &self.payload {
Some(val) => serde_json::to_string_pretty(val).unwrap_or_else(|_| "".to_string()),
None => "".to_string(),
}
}
}
+107
View File
@@ -0,0 +1,107 @@
use mongodb::{
bson::{doc, oid::ObjectId},
Database,
};
use crate::common::errors::AppError;
use crate::audit::models::AuditLog;
#[derive(Clone)]
pub struct MongoAuditRepository {
db: Database,
}
impl MongoAuditRepository {
pub fn new(db: Database) -> Self {
Self { db }
}
pub async fn insert(&self, log: AuditLog) -> Result<(), AppError> {
let collection = self.db.collection::<AuditLog>("audit_logs");
collection.insert_one(log).await?;
Ok(())
}
pub async fn find_filtered(
&self,
username: Option<&str>,
action_type: Option<&str>,
entity_type: Option<&str>,
entity_id: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> Result<Vec<AuditLog>, AppError> {
use futures::TryStreamExt;
let collection = self.db.collection::<AuditLog>("audit_logs");
let mut filter = doc! {};
if let Some(uname) = username {
let trimmed = uname.trim();
if !trimmed.is_empty() {
filter.insert("username", doc! { "$regex": trimmed, "$options": "i" });
}
}
if let Some(act) = action_type {
let trimmed = act.trim();
if !trimmed.is_empty() && trimmed != "all" {
filter.insert("action_type", trimmed);
}
}
if let Some(ent) = entity_type {
let trimmed = ent.trim();
if !trimmed.is_empty() && trimmed != "all" {
filter.insert("entity_type", trimmed);
}
}
if let Some(ent_id_str) = entity_id {
let trimmed = ent_id_str.trim();
if !trimmed.is_empty() {
if let Ok(oid) = ObjectId::parse_str(trimmed) {
filter.insert("entity_id", oid);
}
}
}
// Date filters range
let mut date_query = doc! {};
if let Some(start) = start_date {
let trimmed = start.trim();
if !trimmed.is_empty() {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
if let Some(naive_datetime) = naive_date.and_hms_opt(0, 0, 0) {
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
date_query.insert("$gte", mongodb::bson::DateTime::from_chrono(dt));
}
}
}
}
if let Some(end) = end_date {
let trimmed = end.trim();
if !trimmed.is_empty() {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
if let Some(naive_datetime) = naive_date.and_hms_opt(23, 59, 59) {
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
date_query.insert("$lte", mongodb::bson::DateTime::from_chrono(dt));
}
}
}
}
if !date_query.is_empty() {
filter.insert("timestamp", date_query);
}
let find_options = mongodb::options::FindOptions::builder()
.sort(doc! { "timestamp": -1 })
.limit(1000)
.build();
let cursor = collection.find(filter).with_options(find_options).await?;
let logs = cursor.try_collect::<Vec<AuditLog>>().await?;
Ok(logs)
}
}
+129 -3
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
use crate::audit::AuditLogger;
use axum_extra::extract::{
cookie::{Cookie, SameSite},
CookieJar,
@@ -79,6 +80,7 @@ pub struct AuthPayload {
pub async fn post_login(
State(config): State<Config>,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
jar: CookieJar,
Form(payload): Form<AuthPayload>,
) -> Result<Response, AppError> {
@@ -87,6 +89,14 @@ pub async fn post_login(
// Find user
let user = user_repo.find_by_username(username).await?;
let Some(user) = user else {
audit.log_with_user(
None,
"Login",
"User",
None,
Some(format!("Failed login attempt (unknown username): {}", username)),
None
).await;
return Ok(HtmlTemplate(LoginTemplate {
error: Some("Invalid username or password".to_string()),
authenticated: false,
@@ -99,6 +109,14 @@ pub async fn post_login(
match verify(&payload.password, &user.password_hash) {
Ok(true) => {}
_ => {
audit.log_with_user(
None,
"Login",
"User",
user.id,
Some(format!("Failed login attempt (incorrect password) for user: {}", username)),
None
).await;
return Ok(HtmlTemplate(LoginTemplate {
error: Some("Invalid username or password".to_string()),
authenticated: false,
@@ -116,7 +134,7 @@ pub async fn post_login(
let claims = Claims {
sub: user.id.expect("User document must have ID").to_hex(),
username: user.username,
username: user.username.clone(),
is_admin: user.is_admin,
exp,
};
@@ -127,6 +145,21 @@ pub async fn post_login(
&EncodingKey::from_secret(config.jwt_secret.as_bytes()),
)?;
// Log success
let session_user = AuthenticatedUser {
user_id: user.id.unwrap(),
username: user.username.clone(),
is_admin: user.is_admin,
};
audit.log_with_user(
Some(&session_user),
"Login",
"User",
user.id,
Some(format!("Successful login for user: {}", username)),
None
).await;
// Set cookie
let cookie = Cookie::build(("token", token))
.path("/")
@@ -164,6 +197,7 @@ pub struct RegisterPayload {
pub async fn post_register(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
Form(payload): Form<RegisterPayload>,
) -> Result<Response, AppError> {
if !user.is_admin {
@@ -200,7 +234,20 @@ pub async fn post_register(
// Create user
let is_admin_val = payload.is_admin.is_some();
user_repo.create(username, &hashed_password, is_admin_val).await?;
let new_user = user_repo.create(username, &hashed_password, is_admin_val).await?;
let payload_val = serde_json::json!({
"username": new_user.username,
"is_admin": new_user.is_admin,
});
audit.log(
"Create",
"User",
new_user.id,
Some(format!("Registered new user: {}", username)),
Some(payload_val)
).await;
Ok(HtmlTemplate(RegisterTemplate {
error: None,
@@ -211,7 +258,21 @@ pub async fn post_register(
.into_response())
}
pub async fn post_logout(jar: CookieJar) -> impl IntoResponse {
pub async fn post_logout(
user_opt: Option<AuthenticatedUser>,
audit: AuditLogger,
jar: CookieJar,
) -> impl IntoResponse {
if let Some(user) = &user_opt {
audit.log(
"Logout",
"User",
Some(user.user_id),
Some(format!("Logged out user: {}", user.username)),
None
).await;
}
let mut cookie = Cookie::new("token", "");
cookie.set_path("/");
cookie.set_max_age(Some(Duration::ZERO)); // Clear cookie
@@ -252,6 +313,7 @@ pub async fn get_password(
pub async fn post_password(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
Form(payload): Form<PasswordPayload>,
) -> Result<Response, AppError> {
let current_password = payload.current_password.trim();
@@ -307,6 +369,14 @@ pub async fn post_password(
// Update password in database
user_repo.update_password(&user.user_id, &hashed_password).await?;
audit.log(
"Update",
"User",
Some(user.user_id),
Some(format!("Self-updated password for user: {}", user.username)),
None
).await;
Ok(HtmlTemplate(PasswordTemplate {
error: None,
success: Some("Password updated successfully!".to_string()),
@@ -340,6 +410,7 @@ pub struct UserEditTemplate {
pub async fn get_users(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can access user management".to_string()));
@@ -347,6 +418,14 @@ pub async fn get_users(
let users = user_repo.find_all().await?;
audit.log(
"View",
"User",
None,
Some("Viewed list of all registered users".to_string()),
None
).await;
Ok(HtmlTemplate(UserListTemplate {
authenticated: true,
username: user.username,
@@ -358,6 +437,7 @@ pub async fn get_users(
pub async fn get_edit_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
Path(id_str): Path<String>,
) -> Result<Response, AppError> {
if !user.is_admin {
@@ -370,6 +450,14 @@ pub async fn get_edit_user(
let user_to_edit = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
audit.log(
"View",
"User",
Some(oid),
Some(format!("Viewed edit page for user: {}", user_to_edit.username)),
None
).await;
Ok(HtmlTemplate(UserEditTemplate {
error: None,
success: None,
@@ -390,6 +478,7 @@ pub struct EditUserPayload {
pub async fn post_edit_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
Path(id_str): Path<String>,
Form(payload): Form<EditUserPayload>,
) -> Result<Response, AppError> {
@@ -456,6 +545,26 @@ pub async fn post_edit_user(
let updated_user = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let payload_val = serde_json::json!({
"before": {
"username": user_to_edit.username,
"is_admin": user_to_edit.is_admin,
},
"after": {
"username": updated_user.username,
"is_admin": updated_user.is_admin,
"password_reset": password_hash_opt.is_some(),
}
});
audit.log(
"Update",
"User",
Some(oid),
Some(format!("Updated details for user: {}", new_username)),
Some(payload_val)
).await;
Ok(HtmlTemplate(UserEditTemplate {
error: None,
success: Some("User updated successfully!".to_string()),
@@ -469,6 +578,7 @@ pub async fn post_edit_user(
pub async fn post_delete_user(
user: AuthenticatedUser,
State(user_repo): State<MongoUserRepository>,
audit: AuditLogger,
Path(id_str): Path<String>,
) -> Result<Response, AppError> {
if !user.is_admin {
@@ -483,7 +593,23 @@ pub async fn post_delete_user(
return Err(AppError::BadRequest("You cannot delete your own account while logged in".to_string()));
}
let user_to_delete = user_repo.find_by_id(&oid).await?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
user_repo.delete(&oid).await?;
let payload_val = serde_json::json!({
"username": user_to_delete.username,
"is_admin": user_to_delete.is_admin,
});
audit.log(
"Delete",
"User",
Some(oid),
Some(format!("Deleted user account: {}", user_to_delete.username)),
Some(payload_val)
).await;
Ok(Redirect::to("/auth/users").into_response())
}
+1
View File
@@ -14,6 +14,7 @@ pub fn router<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoUserRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
-30
View File
@@ -1,30 +0,0 @@
pub mod handlers;
use axum::{routing::get, Router};
use self::handlers::{
index_handler, buttons_handler, inputs_handler, date_time_handler,
combobox_handler, toggles_handler, modals_handler, sheets_handler,
tabs_accordion_handler, visuals_handler, scrollbars_handler, feedback_handler,
};
pub fn router<S>() -> Router<S>
where
crate::common::config::Config: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/components", get(index_handler))
.route("/components/buttons", get(buttons_handler))
.route("/components/inputs", get(inputs_handler))
.route("/components/date-time", get(date_time_handler))
.route("/components/combobox", get(combobox_handler))
.route("/components/toggles", get(toggles_handler))
.route("/components/modals", get(modals_handler))
.route("/components/sheets", get(sheets_handler))
.route("/components/tabs-accordion", get(tabs_accordion_handler))
.route("/components/visuals", get(visuals_handler))
.route("/components/scrollbars", get(scrollbars_handler))
.route("/components/feedback", get(feedback_handler))
}
+96 -1
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
use crate::audit::AuditLogger;
use mongodb::bson::oid::ObjectId;
use serde::Deserialize;
use crate::common::errors::AppError;
@@ -56,6 +57,7 @@ struct DeveloperSearchResultsTemplate {
pub async fn get_list(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
) -> Result<Response, AppError> {
let Some(user) = user_opt else {
@@ -65,6 +67,14 @@ pub async fn get_list(
let _ = dev_repo.ensure_seeded(&user.user_id).await;
let developers = dev_repo.find_all_by_user(&user.user_id).await?;
audit.log(
"View",
"Developer",
None,
Some("Viewed list of all developers".to_string()),
None
).await;
Ok(HtmlTemplate(DeveloperListTemplate {
username: user.username,
authenticated: true,
@@ -82,6 +92,7 @@ pub struct CreateDevForm {
pub async fn post_create(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Form(payload): Form<CreateDevForm>,
) -> Result<Response, AppError> {
@@ -101,13 +112,28 @@ pub async fn post_create(
.filter(|s| !s.is_empty())
.collect();
dev_repo.create(&user.user_id, name, email, skills).await?;
let new_dev = dev_repo.create(&user.user_id, name, email, skills).await?;
let payload_val = serde_json::json!({
"name": new_dev.name,
"email": new_dev.email,
"skills": new_dev.skills,
});
audit.log(
"Create",
"Developer",
new_dev.id,
Some(format!("Created developer: {}", name)),
Some(payload_val)
).await;
Ok(Redirect::to("/developers").into_response())
}
pub async fn get_edit(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(dev_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -123,6 +149,14 @@ pub async fn get_edit(
return Err(AppError::Unauthorized("Developer not found".to_string()));
};
audit.log(
"View",
"Developer",
Some(dev_id),
Some(format!("Viewed edit page for developer: {}", developer.name)),
None
).await;
Ok(HtmlTemplate(DeveloperEditTemplate {
username: user.username,
authenticated: true,
@@ -133,6 +167,7 @@ pub async fn get_edit(
pub async fn post_update(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(dev_id_str): Path<String>,
Form(payload): Form<CreateDevForm>,
@@ -156,13 +191,41 @@ pub async fn post_update(
.filter(|s| !s.is_empty())
.collect();
let dev_before = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
dev_repo.update(&dev_id, &user.user_id, name, email, skills).await?;
let dev_after = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
if let (Some(dbef), Some(daft)) = (dev_before, dev_after) {
let payload_val = serde_json::json!({
"before": {
"name": dbef.name,
"email": dbef.email,
"skills": dbef.skills,
},
"after": {
"name": daft.name,
"email": daft.email,
"skills": daft.skills,
}
});
audit.log(
"Update",
"Developer",
Some(dev_id),
Some(format!("Updated developer details for: {}", name)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/developers").into_response())
}
pub async fn post_delete(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(dev_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -173,8 +236,26 @@ pub async fn post_delete(
let dev_id = ObjectId::parse_str(&dev_id_str)
.map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?;
let dev_to_delete = dev_repo.find_by_id(&dev_id, &user.user_id).await?;
dev_repo.delete(&dev_id, &user.user_id).await?;
if let Some(dd) = dev_to_delete {
let payload_val = serde_json::json!({
"name": dd.name,
"email": dd.email,
"skills": dd.skills,
});
audit.log(
"Delete",
"Developer",
Some(dev_id),
Some(format!("Deleted developer: {}", dd.name)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/developers").into_response())
}
@@ -185,6 +266,7 @@ pub struct SearchQuery {
pub async fn get_search(
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Query(params): Query<SearchQuery>,
) -> Result<Response, AppError> {
@@ -200,6 +282,19 @@ pub async fn get_search(
let matched_devs = dev_repo.search_by_name(&user.user_id, query_str).await?;
let payload_val = serde_json::json!({
"query": query_str,
"results_count": matched_devs.len(),
});
audit.log(
"Search",
"Developer",
None,
Some(format!("Searched developers with query: '{}'", query_str)),
Some(payload_val)
).await;
Ok(HtmlTemplate(DeveloperSearchResultsTemplate {
developers: matched_devs,
})
+1
View File
@@ -13,6 +13,7 @@ pub fn router<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoDeveloperRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
@@ -31,89 +31,96 @@ fn get_session_info(user_opt: Option<AuthenticatedUser>) -> (bool, String) {
// Define individual templates
#[derive(Template)]
#[template(path = "components/index.html")]
#[template(path = "docs/index.html")]
pub struct IndexTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/buttons.html")]
#[template(path = "docs/buttons.html")]
pub struct ButtonsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/inputs.html")]
#[template(path = "docs/inputs.html")]
pub struct InputsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/date_time.html")]
#[template(path = "docs/date_time.html")]
pub struct DateTimeTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/combobox.html")]
#[template(path = "docs/combobox.html")]
pub struct ComboboxTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/toggles.html")]
#[template(path = "docs/toggles.html")]
pub struct TogglesTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/modals.html")]
#[template(path = "docs/modals.html")]
pub struct ModalsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/sheets.html")]
#[template(path = "docs/sheets.html")]
pub struct SheetsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/tabs_accordion.html")]
#[template(path = "docs/tabs_accordion.html")]
pub struct TabsAccordionTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/visuals.html")]
#[template(path = "docs/visuals.html")]
pub struct VisualsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/scrollbars.html")]
#[template(path = "docs/scrollbars.html")]
pub struct ScrollbarsTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "components/feedback.html")]
#[template(path = "docs/feedback.html")]
pub struct FeedbackTemplate {
pub username: String,
pub authenticated: bool,
}
#[derive(Template)]
#[template(path = "docs/logging.html")]
pub struct LoggingTemplate {
pub username: String,
pub authenticated: bool,
}
// Define individual handlers
pub async fn index_handler(
user_opt: Option<AuthenticatedUser>,
@@ -207,3 +214,10 @@ pub async fn feedback_handler(
let (authenticated, username) = get_session_info(user_opt);
Ok(HtmlTemplate(FeedbackTemplate { username, authenticated }))
}
pub async fn logging_handler(
user_opt: Option<AuthenticatedUser>,
) -> Result<impl IntoResponse, AppError> {
let (authenticated, username) = get_session_info(user_opt);
Ok(HtmlTemplate(LoggingTemplate { username, authenticated }))
}
+32
View File
@@ -0,0 +1,32 @@
pub mod handlers;
use axum::{routing::get, Router};
use self::handlers::{
index_handler, buttons_handler, inputs_handler, date_time_handler,
combobox_handler, toggles_handler, modals_handler, sheets_handler,
tabs_accordion_handler, visuals_handler, scrollbars_handler, feedback_handler,
logging_handler,
};
pub fn router<S>() -> Router<S>
where
crate::common::config::Config: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/docs", get(index_handler))
.route("/docs/buttons", get(buttons_handler))
.route("/docs/inputs", get(inputs_handler))
.route("/docs/date-time", get(date_time_handler))
.route("/docs/combobox", get(combobox_handler))
.route("/docs/toggles", get(toggles_handler))
.route("/docs/modals", get(modals_handler))
.route("/docs/sheets", get(sheets_handler))
.route("/docs/tabs-accordion", get(tabs_accordion_handler))
.route("/docs/visuals", get(visuals_handler))
.route("/docs/scrollbars", get(scrollbars_handler))
.route("/docs/feedback", get(feedback_handler))
.route("/docs/logging", get(logging_handler))
}
+15 -3
View File
@@ -1,9 +1,10 @@
mod common;
mod auth;
mod tasks;
mod audit;
mod developers;
mod main_view;
mod components;
mod docs;
use axum::{extract::FromRef, Router};
use std::net::SocketAddr;
@@ -15,6 +16,7 @@ use crate::common::database::connect_db;
use crate::auth::repository::MongoUserRepository;
use crate::tasks::repository::MongoTaskRepository;
use crate::developers::repository::MongoDeveloperRepository;
use crate::audit::repository::MongoAuditRepository;
#[derive(Clone)]
struct AppState {
@@ -23,6 +25,7 @@ struct AppState {
user_repo: MongoUserRepository,
task_repo: MongoTaskRepository,
dev_repo: MongoDeveloperRepository,
audit_repo: MongoAuditRepository,
}
impl FromRef<AppState> for Config {
@@ -55,6 +58,12 @@ impl FromRef<AppState> for MongoDeveloperRepository {
}
}
impl FromRef<AppState> for MongoAuditRepository {
fn from_ref(state: &AppState) -> Self {
state.audit_repo.clone()
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Initialize logging
@@ -74,6 +83,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let user_repo = MongoUserRepository::new(db.clone());
let task_repo = MongoTaskRepository::new(db.clone());
let dev_repo = MongoDeveloperRepository::new(db.clone());
let audit_repo = MongoAuditRepository::new(db.clone());
// Auto-provision initial administrator if users collection is empty
let users_count = db.collection::<crate::auth::models::User>("users")
@@ -118,15 +128,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
user_repo,
task_repo,
dev_repo,
audit_repo,
};
// 6. Build and merge routers by use-case
let app = Router::new()
.merge(main_view::router())
.merge(components::router())
.merge(docs::router())
.merge(auth::router())
.merge(tasks::router())
.merge(developers::router())
.merge(audit::router())
.with_state(state);
// 7. Bind address and run server
@@ -137,7 +149,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("Listening on http://{}", host_addr);
let listener = tokio::net::TcpListener::bind(host_addr).await?;
axum::serve(listener, app).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
Ok(())
}
+71 -1
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
use crate::audit::AuditLogger;
use mongodb::bson::oid::ObjectId;
use serde::Deserialize;
use crate::common::errors::AppError;
@@ -49,6 +50,7 @@ struct DashboardTemplate {
pub async fn get_dashboard(
State(task_repo): State<MongoTaskRepository>,
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
) -> Result<Response, AppError> {
let Some(user) = user_opt else {
@@ -70,6 +72,14 @@ pub async fn get_dashboard(
});
}
audit.log(
"View",
"Task",
None,
Some("Viewed task dashboard".to_string()),
None
).await;
Ok(HtmlTemplate(DashboardTemplate {
username: user.username,
authenticated: true,
@@ -87,6 +97,7 @@ pub struct CreateTaskForm {
pub async fn post_create_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Form(payload): Form<CreateTaskForm>,
) -> Result<Response, AppError> {
@@ -108,15 +119,31 @@ pub async fn post_create_task(
_ => None,
};
task_repo
let new_task = task_repo
.create(&user.user_id, title, description, assigned_to)
.await?;
let payload_val = serde_json::json!({
"title": new_task.title,
"description": new_task.description,
"assigned_to": new_task.assigned_to.map(|id| id.to_hex()),
"is_completed": new_task.is_completed,
});
audit.log(
"Create",
"Task",
new_task.id,
Some(format!("Created task: {}", title)),
Some(payload_val)
).await;
Ok(Redirect::to("/tasks").into_response())
}
pub async fn post_complete_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(task_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -127,13 +154,37 @@ pub async fn post_complete_task(
let task_id = ObjectId::parse_str(&task_id_str)
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
let task_before = task_repo.find_by_id(&task_id, &user.user_id).await?;
task_repo.mark_completed(&task_id, &user.user_id).await?;
if let Some(tb) = task_before {
let payload_val = serde_json::json!({
"before": {
"title": tb.title,
"is_completed": tb.is_completed,
},
"after": {
"title": tb.title,
"is_completed": true,
}
});
audit.log(
"Update",
"Task",
Some(task_id),
Some(format!("Marked task '{}' as completed", tb.title)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/tasks").into_response())
}
pub async fn post_delete_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(task_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -144,7 +195,26 @@ pub async fn post_delete_task(
let task_id = ObjectId::parse_str(&task_id_str)
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
let task_to_delete = task_repo.find_by_id(&task_id, &user.user_id).await?;
task_repo.delete(&task_id, &user.user_id).await?;
if let Some(td) = task_to_delete {
let payload_val = serde_json::json!({
"title": td.title,
"description": td.description,
"assigned_to": td.assigned_to.map(|id| id.to_hex()),
"is_completed": td.is_completed,
});
audit.log(
"Delete",
"Task",
Some(task_id),
Some(format!("Deleted task: {}", td.title)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/tasks").into_response())
}
+1
View File
@@ -14,6 +14,7 @@ where
Config: axum::extract::FromRef<S>,
MongoTaskRepository: axum::extract::FromRef<S>,
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
+8
View File
@@ -10,6 +10,7 @@ use crate::tasks::models::Task;
#[async_trait::async_trait]
pub trait TaskRepository {
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError>;
async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Task>, AppError>;
async fn create(&self, user_id: &ObjectId, title: &str, description: Option<&str>, assigned_to: Option<ObjectId>) -> Result<Task, AppError>;
async fn mark_completed(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
async fn delete(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
@@ -28,6 +29,13 @@ impl MongoTaskRepository {
#[async_trait::async_trait]
impl TaskRepository for MongoTaskRepository {
async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Task>, AppError> {
let collection = self.db.collection::<Task>("tasks");
let filter = doc! { "_id": task_id, "user_id": user_id };
let task = collection.find_one(filter).await?;
Ok(task)
}
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError> {
let collection = self.db.collection::<Task>("tasks");