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
+115 -42
View File
@@ -1,36 +1,90 @@
# Stick
Stick is a Rust web application starter template built on Axum, Askama, and MongoDB. Unlike traditional MVC architectures that organize code by technical layers (controllers, models, views), Stick is organized by **vertical slices** (features or use-cases). All files related to a specific domain feature—such as authentication or task management—live together in a single module.
Stick is a high-fidelity Rust web application starter template built on Axum, Askama, and MongoDB.
This setup is ideal for medium-to-large projects where horizontal layers become hard to navigate, and compiling templates at runtime is too risky.
Unlike traditional MVC frameworks that segment code horizontally by technical layers (controllers, models, views), Stick is organized by **vertical slices**. Each domain feature—such as authentication, tasks, or audit logging—lives together in its own self-contained module. This simplifies code maintenance and cognitive load in medium-to-large codebases where horizontal layers inevitably grow difficult to navigate.
---
## Technical Architecture
### 1. Vertical Slice Layout
Each feature slice is self-contained. For example, the authentication domain has the following structure:
## Architectural Philosophy & Layout
### 1. Vertical Slice Domain Layout
Every domain feature owns its database access models, business logic, endpoints, and markup templates. For example:
* `src/auth/models.rs`: BSON data structures and claims.
* `src/auth/repository.rs`: Database operations and query logic.
* `src/auth/handlers.rs`: Request/response lifecycle logic.
* `src/auth/extractors.rs`: Axum extractors for session state.
* `templates/auth/`: HTML markup templates rendered at compile-time.
* `src/auth/extractors.rs`: Axum extractors for user session handling.
* `templates/auth/`: HTML templates compiled at build-time.
### 2. Key Stack Decisions
* **Axum (v0.8)**: Handles routing, middleware, and request extraction.
* **Askama (v0.16)**: Evaluates and compiles HTML templates into Rust code at compile time. If you reference a variable or field that doesn't exist, the project fails to compile, catching UI rendering bugs before deployment.
### 2. Separating Components from Documentation
To prevent domain pollution and maintain code integrity, we strictly separate template assets:
* **Reusable UI Components** (`templates/components/`): Reserved strictly for operational UI blocks used across the application. Reusable elements like fields, dropdowns, calendars, and buttons are defined as Askama macros inside [templates/components/macros.html](templates/components/macros.html).
* **System Documentation** (`templates/docs/` & `src/docs/`): Design manuals, guidelines, and interactive preview sandboxes are isolated. The Documentation portal is served on a dedicated `/docs` route, separate from core application logic.
### 3. Key Stack Decisions
* **Axum (v0.8)**: Modern, async-first routing and middleware.
* **Askama (v0.16)**: Evaluates and compiles HTML templates into Rust code at compile time. Syntax, variable existence, and type constraints are verified by the compiler.
* **MongoDB**: Standard Rust driver configured with BSON serialization.
* **Tailwind CSS**: Pre-compiled utility styling using a Node-based wrapper process.
* **Authentication**: Managed via JWT (JSON Web Tokens) stored in secure, encrypted `HttpOnly` cookies.
* **Tailwind CSS**: Utility-first styling compiled using a Node-based wrapper process.
* **JWT Authentication**: Managed via JSON Web Tokens stored in secure, encrypted `HttpOnly` cookies.
---
## Audit Logging Framework (First-Class Feature)
Stick contains a built-in, request-scoped **Audit Logging and Replay** framework designed to make all critical system mutations (Create, Read, Update, Delete, Search) auditable.
```
Request
┌──────────────────┐
│ AuditLogger │ ──► Resolves: DB, User, User-Agent
│ Extractor │
└──────────────────┘
├──► Parses "x-forwarded-for" & "x-real-ip" proxy headers
├──► Parses Direct ConnectInfo TCP socket IP
┌──────────────────┐
│ Log Entry Writ │ ──► Formats: "ProxyIP (Socket: SocketIP)" if mismatched
└──────────────────┘
┌──────────────────┐
│ MongoDB Store │ ──► Stores structured entry + JSON state snapshot
└──────────────────┘
```
### 1. Request-Scoped Extractor (`AuditLogger`)
The custom `AuditLogger` extractor implements Axum's `FromRequestParts` trait. When added to any handler function signature, it automatically resolves:
* Database connection reference.
* Optional authenticated user context.
* Client metadata (IP address and User-Agent).
### 2. Intelligent IP & Proxy Resolution
To support load-balanced or proxied environments, the extractor resolves and logs both network paths:
- Inspects standard proxy headers (`x-forwarded-for`, `x-real-ip`).
- Captures the direct TCP client connection address via Axum's `ConnectInfo`.
- If the proxy IP and connecting socket IP differ, the logger combines them (e.g. `10.0.0.1 (Socket: 127.0.0.1)`) ensuring full visibility of both the request origin and proxy hop.
### 3. Historical State Snapshotting (Replayability)
Log entries include a serialized JSON `payload` field containing the state of the affected entity. For edits, it saves the state transition. For deletions, it saves the final snapshot before removal, ensuring data remains reconstructible in the event of an audit inquiry.
### 4. Admin Audit Panel
Authorized administrators can review, search, and audit system activities at `/auth/audit`. The dashboard allows filtering entries by:
* **Username**: Filter logs to a specific actor.
* **Event Type**: Filter by actions (e.g. `Login`, `Create`, `Update`, `Delete`).
* **Entity Type & ID**: Pinpoint history for a specific resource (e.g. `Task`, `Developer`).
* **Timeline**: Filter logs by start and end timestamps.
---
## Core Features Included
### Self-Provisioning Administrator
On startup, the application checks if the `users` collection in the MongoDB database is empty. If no users are found, it generates a secure, random 16-character alphanumeric password, hashes it with bcrypt, and creates a default `admin` account. The credentials are logged directly to standard output:
On startup, if the MongoDB `users` collection is empty, Stick automatically provisions an administrator account with username `admin`, generates a secure random 16-character alphanumeric password, and outputs the credentials directly to the console logs:
```text
======================================================
CREATED INITIAL ADMINISTRATOR ACCOUNT:
@@ -39,20 +93,11 @@ Password: [GeneratedPassword]
======================================================
```
### Decoupled Identity vs. Domain Entities
Stick strictly separates infrastructure/user models from business domain models:
* **Users** (`User`): Manage authentication, roles (`is_admin`), and settings under `/auth`.
* **Developers** (`Developer`): Plain domain entities managed under `/developers` that represent team members.
### User Management Panel (Admin Only)
Accessible at `/auth/users`. Administrators can view all users, register new ones, toggle administrative roles, reset passwords, or delete standard accounts (with safety blocks preventing administrators from deleting their own active profile or revoking their own privileges).
### User Management Panel (Administrators Only)
Accessible under `/auth/users` by logged-in administrators. The panel allows:
* Viewing all registered users and their administrative permissions.
* Editing user profiles, resetting passwords, and toggling administrator roles.
* Deleting users (with safeguards preventing administrators from deleting their own active accounts or revoking their own admin permissions).
* Registering new users.
### Self-Service Account Settings
Any authenticated user can change their own password by clicking their username in the navigation bar, which directs them to `/auth/password`.
### Self-Service Password Reset
Any logged-in user can change their password at `/auth/password` by clicking their username in the navigation bar.
---
@@ -61,7 +106,7 @@ Any authenticated user can change their own password by clicking their username
### Prerequisites
* **Rust**: Toolchain v1.75+ (for native async traits).
* **Node.js & npm**: Required to build Tailwind CSS.
* **MongoDB**: Running locally on `mongodb://localhost:27017`.
* **MongoDB**: Running locally on `mongodb://127.0.0.1:27017`.
### Local Setup
1. Copy the environment configuration:
@@ -77,16 +122,16 @@ Any authenticated user can change their own password by clicking their username
```bash
cargo run
```
The server will start listening at `http://127.0.0.1:3000`.
The server will start listening at `http://127.0.0.1:3009` (as configured in `.env`).
### Running with Docker
A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust binary, and bundle a lightweight Debian run container.
A multi-stage `Dockerfile` compiles Tailwind, compiles the Rust binary, and bundle a lightweight Debian runtime container.
1. Build the image:
```bash
docker build -t stick .
```
2. Start the container (assumes MongoDB is running on the host machine):
2. Start the container:
```bash
docker run --name stick-app --rm --network="host" \
-e DATABASE_URL="mongodb://127.0.0.1:27017" \
@@ -99,31 +144,59 @@ A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust bin
---
## Developer Guide: Adding a Feature Slice
To add a new feature (e.g. `projects`):
## Developer Cookbook
### 1. Adding a Feature Slice (e.g. `projects`)
1. Create a module folder: `src/projects/`.
2. Define models in `models.rs` and database access functions in `repository.rs`.
2. Define models in `models.rs` and database access operations in `repository.rs`.
3. Add request handlers in `handlers.rs`.
4. Create a router configuration in `src/projects/mod.rs` exposing a routing module setup:
4. Create `src/projects/mod.rs` to expose the router:
```rust
pub fn router<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoUserRepository: axum::extract::FromRef<S>,
crate::common::config::Config: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/projects", get(handlers::get_projects))
}
```
5. Place HTML layouts under `templates/projects/` extending the `base.html` layout.
6. Register and merge the router in `src/main.rs`:
5. Place HTML templates under `templates/projects/` extending the `base.html` layout.
6. Register the module in `src/main.rs` and merge the sub-router:
```rust
let app = Router::new()
.merge(main_view::router())
.merge(auth::router())
.merge(projects::router()) // Merged domain router
.merge(projects::router()) // Merge domain router
.with_state(state);
```
### 2. Logging Operations with `AuditLogger`
To log any action in a handler, inject `logger: AuditLogger` and invoke `.log()` after a successful DB mutation:
```rust
use crate::audit::AuditLogger;
use serde_json::json;
pub async fn delete_task_handler(
State(repo): State<MongoTaskRepository>,
logger: AuditLogger, // <-- Automatically injected
Path(task_id): Path<ObjectId>,
) -> Result<impl IntoResponse, AppError> {
// 1. Fetch task and perform mutation
let task = repo.get_by_id(&task_id).await?;
repo.delete(&task_id).await?;
// 2. Audit the event (single line, fully context-aware)
logger.log(
"Delete", // Event action
"Task", // Entity type
Some(task_id), // Target entity ID
Some(format!("Deleted task '{}'", task.title)), // Description
Some(json!(task)), // Serialized payload for replayability
).await;
Ok(Redirect::to("/tasks"))
}
```
+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");
+223
View File
@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% import "components/macros.html" as ui %}
{% block title %}Audit Logs - Stick{% endblock %}
{% block content %}
<div class="grow py-12 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto w-full">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8 pb-6 border-b border-slate-900 gap-4">
<div>
<h1 class="text-3xl font-extrabold text-slate-100 tracking-tight">Audit Logs</h1>
<p class="text-slate-400 text-sm mt-1">Review, search, and audit system activities and state changes</p>
</div>
<div class="flex items-center gap-3">
<a href="/auth/users" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300">
Manage Users
</a>
<span class="text-xs font-semibold px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-slate-300">
Matches Found: {{ logs.len() }}
</span>
</div>
</div>
<!-- Filters Form -->
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-6 shadow-xl mb-8">
<h3 class="text-sm font-bold text-slate-200 mb-4">Filter Log Entries</h3>
<form method="get" action="/auth/audit" class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
<!-- User Filter -->
<div>
<label for="username" class="block text-[11px] font-semibold text-slate-400 mb-1.5">User/Username</label>
<input type="text" id="username" name="username"
value="{% if let Some(u) = query.username %}{{ u }}{% endif %}"
placeholder="Filter by user"
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
</div>
<!-- Action Type Filter -->
<div>
<label for="action_type" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Event Type</label>
<select id="action_type" name="action_type"
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
<option value="all" {% if let Some(a) = query.action_type %}{% if a == "all" %}selected{% endif %}{% endif %}>All Actions</option>
<option value="Login" {% if let Some(a) = query.action_type %}{% if a == "Login" %}selected{% endif %}{% endif %}>Login</option>
<option value="Logout" {% if let Some(a) = query.action_type %}{% if a == "Logout" %}selected{% endif %}{% endif %}>Logout</option>
<option value="Register" {% if let Some(a) = query.action_type %}{% if a == "Register" %}selected{% endif %}{% endif %}>Register</option>
<option value="Create" {% if let Some(a) = query.action_type %}{% if a == "Create" %}selected{% endif %}{% endif %}>Create</option>
<option value="Update" {% if let Some(a) = query.action_type %}{% if a == "Update" %}selected{% endif %}{% endif %}>Update</option>
<option value="Delete" {% if let Some(a) = query.action_type %}{% if a == "Delete" %}selected{% endif %}{% endif %}>Delete</option>
<option value="View" {% if let Some(a) = query.action_type %}{% if a == "View" %}selected{% endif %}{% endif %}>View</option>
<option value="Search" {% if let Some(a) = query.action_type %}{% if a == "Search" %}selected{% endif %}{% endif %}>Search</option>
</select>
</div>
<!-- Entity Type Filter -->
<div>
<label for="entity_type" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Entity Type</label>
<select id="entity_type" name="entity_type"
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
<option value="all" {% if let Some(e) = query.entity_type %}{% if e == "all" %}selected{% endif %}{% endif %}>All Entities</option>
<option value="User" {% if let Some(e) = query.entity_type %}{% if e == "User" %}selected{% endif %}{% endif %}>User</option>
<option value="Task" {% if let Some(e) = query.entity_type %}{% if e == "Task" %}selected{% endif %}{% endif %}>Task</option>
<option value="Developer" {% if let Some(e) = query.entity_type %}{% if e == "Developer" %}selected{% endif %}{% endif %}>Developer</option>
<option value="System" {% if let Some(e) = query.entity_type %}{% if e == "System" %}selected{% endif %}{% endif %}>System</option>
</select>
</div>
<!-- Specific Object ID -->
<div>
<label for="entity_id" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Target Entity ID</label>
<input type="text" id="entity_id" name="entity_id"
value="{% if let Some(e_id) = query.entity_id %}{{ e_id }}{% endif %}"
placeholder="e.g. 6a12ab..."
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
</div>
<!-- Start Date -->
<div>
<label for="start_date" class="block text-[11px] font-semibold text-slate-400 mb-1.5">Start Date</label>
<input type="date" id="start_date" name="start_date"
value="{% if let Some(s) = query.start_date %}{{ s }}{% endif %}"
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
</div>
<!-- End Date -->
<div>
<label for="end_date" class="block text-[11px] font-semibold text-slate-400 mb-1.5">End Date</label>
<input type="date" id="end_date" name="end_date"
value="{% if let Some(e) = query.end_date %}{{ e }}{% endif %}"
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
</div>
<!-- Submit & Reset Buttons -->
<div class="md:col-span-3 lg:col-span-6 flex justify-end gap-3 mt-2">
<a href="/auth/audit" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all border border-border bg-transparent hover:bg-secondary text-slate-200 px-5 py-2.5">
Clear Filters
</a>
<button type="submit" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-5 py-2.5 bg-indigo-650 hover:bg-indigo-600 text-white shadow-lg shadow-indigo-650/10">
Apply Filters
</button>
</div>
</form>
</div>
<!-- Logs Table -->
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl shadow-2xl overflow-hidden mb-8">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-800 text-left text-sm text-slate-300">
<thead>
<tr class="text-xs font-bold text-slate-400 uppercase tracking-wider bg-slate-900/30">
<th class="px-6 py-4">Timestamp</th>
<th class="px-6 py-4">User</th>
<th class="px-6 py-4">Event</th>
<th class="px-6 py-4">Target Entity</th>
<th class="px-6 py-4">IP / Details</th>
<th class="px-6 py-4 text-right">Replay Payload</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% if logs.is_empty() %}
<tr>
<td colspan="6" class="px-6 py-12 text-center text-slate-500 font-medium">
No audit log entries matched the filter criteria.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr class="hover:bg-[#1e293b]/10 transition duration-150 align-top">
<!-- Timestamp -->
<td class="px-6 py-4 whitespace-nowrap text-xs font-semibold text-slate-400">
{{ log.timestamp.format("%Y-%m-%d %H:%M:%S UTC") }}
</td>
<!-- User -->
<td class="px-6 py-4 whitespace-nowrap">
{% if let Some(uname) = log.username %}
<span class="text-sm font-semibold text-slate-200">{{ uname }}</span>
{% if let Some(uid) = log.user_id %}
<div class="text-[9px] font-mono text-slate-500">{{ uid.to_hex() }}</div>
{% endif %}
{% else %}
<span class="text-xs italic text-slate-500">Anonymous</span>
{% endif %}
</td>
<!-- Event / Action Type -->
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border
{% if log.action_type == "Delete" %}
bg-rose-500/10 text-rose-400 border-rose-500/20
{% elif log.action_type == "Create" %}
bg-emerald-500/10 text-emerald-400 border-emerald-500/20
{% elif log.action_type == "Update" %}
bg-sky-500/10 text-sky-400 border-sky-500/20
{% elif log.action_type == "Login" %}
bg-indigo-500/10 text-indigo-400 border-indigo-500/20
{% elif log.action_type == "Logout" %}
bg-amber-500/10 text-amber-400 border-amber-500/20
{% else %}
bg-slate-900 text-slate-400 border-slate-800
{% endif %}">
{{ log.action_type }}
</span>
</td>
<!-- Target Entity -->
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-semibold text-slate-300">{{ log.entity_type }}</span>
{% if let Some(e_id) = log.entity_id %}
<div class="text-[9px] font-mono text-sky-400 hover:underline cursor-pointer" onclick="document.getElementById('entity_id').value='{{ e_id.to_hex() }}';">
{{ e_id.to_hex() }}
</div>
{% endif %}
</td>
<!-- IP / Details -->
<td class="px-6 py-4">
<div class="text-xs text-slate-200">
{% if let Some(det) = log.details %}
{{ det }}
{% endif %}
</div>
<div class="flex items-center gap-2 mt-1 text-[9px] text-slate-500 font-medium">
{% if let Some(ip) = log.ip_address %}
<span class="bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded">IP: {{ ip }}</span>
{% endif %}
{% if let Some(ua) = log.user_agent %}
<span class="truncate max-w-xs bg-slate-900 border border-slate-800 px-1.5 py-0.5 rounded" title="{{ ua }}">{{ ua }}</span>
{% endif %}
</div>
</td>
<!-- Replay Payload -->
<td class="px-6 py-4 whitespace-nowrap text-right">
{% if !log.formatted_payload().is_empty() %}
<details class="group text-left">
<summary class="inline-flex items-center gap-1 text-[11px] font-bold text-sky-400 hover:text-sky-300 cursor-pointer list-none select-none">
<span>View Payload</span>
<svg class="w-3.5 h-3.5 transition-transform duration-200 group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<polyline points="6 9 12 15 18 9"/>
</svg>
</summary>
<div class="mt-2 p-3 bg-slate-950 border border-slate-900 rounded-xl max-w-md overflow-x-auto text-[10px] font-mono text-emerald-400/90 whitespace-pre scrollbar-thin">{{ log.formatted_payload() }}</div>
</details>
{% else %}
<span class="text-xs italic text-slate-650">No payload</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Footer Navigation -->
<div class="mt-6 text-center text-sm text-slate-400">
<a href="/auth/password" class="font-medium text-sky-400 hover:underline">← Back to Account Settings</a>
</div>
</div>
{% endblock %}
+3
View File
@@ -13,6 +13,9 @@
<p class="text-slate-400 text-sm mt-1">Manage system access, toggle roles, and provision credentials</p>
</div>
<div class="flex items-center gap-3">
<a href="/auth/audit" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-indigo-650 hover:bg-indigo-600 text-white shadow-md shadow-indigo-600/10">
View Audit Logs
</a>
<a href="/auth/register" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white shadow-md shadow-emerald-500/10">
Register New User
</a>
+2 -2
View File
@@ -49,8 +49,8 @@
<!-- Navigation Links -->
<nav class="flex items-center space-x-4">
<a href="/components" class="text-sm font-medium text-muted-foreground hover:text-white transition py-2 px-3 rounded-lg hover:bg-secondary">
Design Wiki
<a href="/docs" class="text-sm font-medium text-muted-foreground hover:text-white transition py-2 px-3 rounded-lg hover:bg-secondary">
Documentation
</a>
{% if authenticated %}
<a href="/tasks" class="text-sm font-medium text-muted-foreground hover:text-white transition py-2 px-3 rounded-lg hover:bg-secondary">
-27
View File
@@ -1,27 +0,0 @@
<aside class="lg:w-64 shrink-0">
<div class="sticky top-24 space-y-1.5 p-4 rounded-3xl border border-border bg-card/50 backdrop-blur-xl" id="wiki-sidebar">
<span class="px-3 text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-2">Wiki Navigation</span>
<a href="/components" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components">Introduction</a>
<div class="h-px bg-secondary my-1 mx-2"></div>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Actions</span>
<a href="/components/buttons" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/buttons">Buttons</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Forms & Inputs</span>
<a href="/components/inputs" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/inputs">Form Fields & Select</a>
<a href="/components/date-time" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/date-time">Date & Time Pickers</a>
<a href="/components/combobox" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/combobox">Autocomplete (Combobox)</a>
<a href="/components/toggles" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/toggles">Switches & Checkboxes</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Overlays</span>
<a href="/components/modals" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/modals">Dialog Modals</a>
<a href="/components/sheets" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/sheets">Slide-over Drawers</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Layout & Navigation</span>
<a href="/components/tabs-accordion" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/tabs-accordion">Tabs & Accordions</a>
<a href="/components/scrollbars" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/scrollbars">Custom Scrollbars</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Visuals & Feedback</span>
<a href="/components/visuals" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/visuals">Avatars & Badges</a>
<a href="/components/feedback" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/components/feedback">Toasts & Alerts</a>
</div>
</aside>
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -6,7 +6,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -6,7 +6,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Floating Sidebar Navigation -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content Area -->
<div class="flex-1 space-y-10">
@@ -32,7 +32,7 @@
</div>
<h3 class="text-sm font-bold text-slate-200">Vertical Feature Architecture</h3>
<p class="text-xs text-muted-foreground/90 leading-relaxed">
Components are packaged inside feature directories (e.g. <code>src/components/</code>) rather than spread horizontally. Handlers render Askama templates, static JS, and compiled Tailwind assets dynamically.
The backend routes and documentation handlers are located under <code>src/docs/</code>. Shared components reside in <code>templates/components/</code>. The rest of the application is packaged inside clean vertical feature directories (e.g. <code>src/auth/</code>, <code>src/tasks/</code>, <code>src/audit/</code>).
</p>
</div>
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
+240
View File
@@ -0,0 +1,240 @@
{% extends "base.html" %}
{% import "components/macros.html" as ui %}
{% block title %}Audit Logging - Documentation{% endblock %}
{% block content %}
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
<!-- Header -->
<div class="pb-6 border-b border-border">
<span class="text-xs font-semibold text-indigo-400">Architecture / Operations</span>
<h1 class="text-3xl font-extrabold text-slate-100 tracking-tight mt-1">Audit Logging Architecture</h1>
<p class="text-muted-foreground text-sm mt-2 leading-relaxed">
A first-class, request-scoped auditing framework built to log and replay critical user mutations (Create, Read, Update, Delete, Search) transparently. Powered by Axum extractors, MongoDB, and JSON payloads.
</p>
</div>
<!-- Section: Design Philosophy -->
<section class="space-y-4">
<h2 class="text-lg font-bold text-slate-200">1. Architectural Philosophy</h2>
<p class="text-sm text-muted-foreground leading-relaxed">
In enterprise-grade software, logs are not secondary diagnostics. They are an <strong>immutable audit trail</strong> that ensures accountability and replayability. Every critical event records the state of the entity at the time of modification, who performed it, and their network metadata.
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div class="border border-border bg-secondary/5 rounded-2xl p-5 space-y-2">
<span class="text-xs font-bold text-sky-400">Request-Scoped Context</span>
<p class="text-xs text-muted-foreground leading-relaxed">
No manual extraction of IP addresses, headers, or cookies. The system resolves all metadata implicitly via request parts.
</p>
</div>
<div class="border border-border bg-secondary/5 rounded-2xl p-5 space-y-2">
<span class="text-xs font-bold text-emerald-400">State Snapshotting</span>
<p class="text-xs text-muted-foreground leading-relaxed">
Snapshots are preserved as raw JSON payloads, making historical state transitions fully auditable and replayable.
</p>
</div>
<div class="border border-border bg-secondary/5 rounded-2xl p-5 space-y-2">
<span class="text-xs font-bold text-indigo-400">Administrator Console</span>
<p class="text-xs text-muted-foreground leading-relaxed">
A real-time search interface available to authorized administrators, supporting filtering by user, action, type, and timeline.
</p>
</div>
</div>
</section>
<!-- Section: How it Works (Extractor) -->
<section class="space-y-4">
<h2 class="text-lg font-bold text-slate-200">2. The AuditLogger Extractor</h2>
<p class="text-sm text-muted-foreground leading-relaxed">
Rather than cluttering handlers with boilerplate code to parse headers and look up user accounts, developers can leverage the custom Axum <code>AuditLogger</code> extractor. This struct implements Axum's <code>FromRequestParts</code> trait.
</p>
<div class="border border-border rounded-3xl p-5 bg-secondary/10 space-y-4">
<div class="flex border-b border-border/60 pb-1.5">
<button class="px-3 py-1.5 text-xs font-semibold border-b-2 border-sky-500 text-sky-400" onclick="toggleWikiTabs(this, 'logger-impl')">Usage in Handlers</button>
<button class="px-3 py-1.5 text-xs font-semibold border-b-2 border-transparent text-muted-foreground hover:text-muted-foreground" onclick="toggleWikiTabs(this, 'logger-struct')">Extractor Source</button>
</div>
<!-- Handler Usage Pane -->
<div id="logger-impl" class="wiki-pane space-y-4">
<p class="text-xs text-muted-foreground">
Adding <code>logger: AuditLogger</code> to any Axum handler automatically intercepts the request context. Writing a log requires just a single asynchronous call.
</p>
<div class="relative group">
<button class="absolute top-2 right-2 p-1.5 rounded-lg border border-border bg-popover/80 backdrop-blur text-[10px] font-semibold text-muted-foreground/90 hover:text-white hover:bg-secondary opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center gap-1.5" onclick="copyCodeSnippet(this)">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 002 2h2a2 2 0 002-2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
Copy Code
</button>
<pre class="bg-popover p-4 rounded-xl border border-border overflow-x-auto text-[10.5px] text-sky-450 font-mono"><code class="text-sky-400">use crate::audit::AuditLogger;
use axum::{response::IntoResponse, extract::State};
use serde_json::json;
pub async fn update_task_handler(
State(repo): State<MongoTaskRepository>,
logger: AuditLogger, // <-- Extractor automatically fetches DB, User, IP, UA
axum::Form(input): axum::Form<UpdateTaskForm>,
) -> Result<impl IntoResponse, AppError> {
// 1. Perform database operation
let updated_task = repo.update(&input.id, &input).await?;
// 2. Audit the event (single line, fully request-aware)
logger.log(
"Update", // action_type
"Task", // entity_type
Some(updated_task.id), // entity_id
Some("Updated task details".into()), // details
Some(json!(updated_task)), // payload for replayability
).await;
Ok(Redirect::to("/tasks"))
}</code></pre>
</div>
</div>
<!-- Extractor Code Pane -->
<div id="logger-struct" class="wiki-pane hidden space-y-4">
<p class="text-xs text-muted-foreground">
Below is the underlying implementation of the extractor. It queries the JWT cookie, standardizes client IP resolution, and manages the database handle.
</p>
<div class="relative group">
<pre class="bg-popover p-4 rounded-xl border border-border overflow-x-auto text-[10px] text-sky-400 font-mono"><code>pub struct AuditLogger {
pub db: mongodb::Database,
pub user: Option<AuthenticatedUser>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
impl&lt;S&gt; axum::extract::FromRequestParts&lt;S&gt; for AuditLogger
where
mongodb::Database: axum::extract::FromRef&lt;S&gt;,
Config: axum::extract::FromRef&lt;S&gt;,
S: Send + Sync,
{
type Rejection = crate::common::errors::AppError;
async fn from_request_parts(parts: &amp;mut Parts, state: &amp;S) -&gt; Result&lt;Self, Self::Rejection&gt; {
let db = mongodb::Database::from_ref(state);
let user = AuthenticatedUser::from_request_parts(parts, state).await.ok();
let user_agent = parts.headers.get(USER_AGENT)
.and_then(|h| h.to_str().ok())
.map(String::from);
let ip_address = 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.extensions.get::&lt;ConnectInfo&lt;SocketAddr&gt;&gt;()
.map(|ConnectInfo(addr)| addr.ip().to_string())
});
Ok(AuditLogger { db, user, ip_address, user_agent })
}
}</code></pre>
</div>
</div>
</div>
</section>
<!-- Section: Audit Log Model Schema -->
<section class="space-y-4">
<h2 class="text-lg font-bold text-slate-200">3. Schema Design</h2>
<p class="text-sm text-muted-foreground leading-relaxed">
Audit logs are persisted in the <code>audit_logs</code> collection in MongoDB. The model captures details on the actor, action, target entity, network details, and the structural payload.
</p>
<div class="border border-border bg-card/50 rounded-2xl p-5 overflow-x-auto">
<table class="w-full border-collapse text-left text-xs">
<thead>
<tr class="border-b border-border text-muted-foreground font-bold">
<th class="pb-2.5">Field</th>
<th class="pb-2.5">Data Type</th>
<th class="pb-2.5">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-border/40 text-muted-foreground/90 font-sans text-xs">
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">timestamp</td>
<td class="py-2.5 font-mono text-[11px]">DateTime&lt;Utc&gt;</td>
<td class="py-2.5">Chronological timestamp of when the action occurred in UTC.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">user_id</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;ObjectId&gt;</td>
<td class="py-2.5">Reference identifier of the actor. <code>None</code> for guest actions.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">username</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;String&gt;</td>
<td class="py-2.5">Cached username of the user for immediate visual queries.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">action_type</td>
<td class="py-2.5 font-mono text-[11px]">String</td>
<td class="py-2.5">The command category (e.g. <code>Create</code>, <code>Update</code>, <code>Delete</code>, <code>View</code>, <code>Login</code>, <code>Search</code>).</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">entity_type</td>
<td class="py-2.5 font-mono text-[11px]">String</td>
<td class="py-2.5">The resource kind (e.g. <code>Task</code>, <code>User</code>, <code>Developer</code>, <code>Auth</code>).</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">entity_id</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;ObjectId&gt;</td>
<td class="py-2.5">The target resource primary key identifier.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">details</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;String&gt;</td>
<td class="py-2.5">Human readable description of the event.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">payload</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;serde_json::Value&gt;</td>
<td class="py-2.5 text-sky-400">A snapshot of the affected model state or diff data for complete audit replayability.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">ip_address</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;String&gt;</td>
<td class="py-2.5">Origin client IP address resolved via proxy headers or TCP socket.</td>
</tr>
<tr>
<td class="py-2.5 font-mono text-[11px] text-indigo-400">user_agent</td>
<td class="py-2.5 font-mono text-[11px]">Option&lt;String&gt;</td>
<td class="py-2.5">Browser details used for authentication forensics.</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Section: Best Practices for Junior Developers -->
<section class="space-y-4">
<h2 class="text-lg font-bold text-slate-200">4. Guidelines for Developers</h2>
<div class="rounded-2xl border border-indigo-500/20 bg-indigo-500/5 p-5 text-xs space-y-4">
<div>
<span class="font-bold text-indigo-400 block mb-1">💡 When to write audit logs?</span>
<p class="text-slate-400 leading-normal">
Audit logs should always be populated during state changes. Whenever writing an endpoint that uses <code>INSERT</code>, <code>UPDATE</code>, or <code>DELETE</code> operations, inject the <code>AuditLogger</code> and record the event after a successful database execution.
</p>
</div>
<div>
<span class="font-bold text-indigo-400 block mb-1">🔄 Replayability Principle</span>
<p class="text-slate-400 leading-normal">
The `payload` field is crucial. By storing the JSON serialization of the model BEFORE or AFTER the action, system administrators can reconstruct state history. For deletions, always record the serial snapshot of the deleted model in the payload so it is never permanently lost to audit inquiries.
</p>
</div>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -6,7 +6,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
+31
View File
@@ -0,0 +1,31 @@
<aside class="lg:w-64 shrink-0">
<div class="sticky top-24 space-y-1.5 p-4 rounded-3xl border border-border bg-card/50 backdrop-blur-xl" id="wiki-sidebar">
<span class="px-3 text-[10px] font-bold text-slate-500 uppercase tracking-wider block mb-2">Wiki Navigation</span>
<a href="/docs" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs">Introduction</a>
<div class="h-px bg-secondary my-1 mx-2"></div>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Architecture</span>
<a href="/docs/logging" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/logging">Audit Logging</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Actions</span>
<a href="/docs/buttons" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/buttons">Buttons</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Forms & Inputs</span>
<a href="/docs/inputs" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/inputs">Form Fields & Select</a>
<a href="/docs/date-time" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/date-time">Date & Time Pickers</a>
<a href="/docs/combobox" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/combobox">Autocomplete (Combobox)</a>
<a href="/docs/toggles" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/toggles">Switches & Checkboxes</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Overlays</span>
<a href="/docs/modals" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/modals">Dialog Modals</a>
<a href="/docs/sheets" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/sheets">Slide-over Drawers</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Layout & Navigation</span>
<a href="/docs/tabs-accordion" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/tabs-accordion">Tabs & Accordions</a>
<a href="/docs/scrollbars" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/scrollbars">Custom Scrollbars</a>
<span class="px-3 text-[9px] font-bold text-slate-600 uppercase tracking-wider block mt-2 mb-1">Visuals & Feedback</span>
<a href="/docs/visuals" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/visuals">Avatars & Badges</a>
<a href="/docs/feedback" class="flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-secondary transition" data-wiki-path="/docs/feedback">Toasts & Alerts</a>
</div>
</aside>
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -7,7 +7,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">
@@ -6,7 +6,7 @@
<div class="grow max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-10 flex flex-col lg:flex-row gap-8">
<!-- Left Navigation Sidebar -->
{% include "components/sidebar.html" %}
{% include "docs/sidebar.html" %}
<!-- Main Content -->
<div class="flex-1 space-y-8">