From f6ea8a99d9e8ab98a46cc9ec99f8b7a970226834 Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sat, 30 May 2026 14:05:11 +0500 Subject: [PATCH] feat: refactor and refine authentication system with decoupled user management and admin console --- .env | 11 ++ Cargo.lock | 1 + Cargo.toml | 2 + README.md | 181 +++++++++++-------- conversation_summary.md | 88 ---------- src/auth/extractors.rs | 3 + src/auth/handlers.rs | 318 ++++++++++++++++++++++++++++++++-- src/auth/mod.rs | 4 + src/auth/models.rs | 2 + src/auth/repository.rs | 55 +++++- src/common/errors.rs | 8 + src/main.rs | 36 ++++ templates/auth/edit_user.html | 61 +++++++ templates/auth/password.html | 110 ++++++++++++ templates/auth/register.html | 16 +- templates/auth/users.html | 104 +++++++++++ templates/base.html | 7 +- 17 files changed, 816 insertions(+), 191 deletions(-) create mode 100644 .env delete mode 100644 conversation_summary.md create mode 100644 templates/auth/edit_user.html create mode 100644 templates/auth/password.html create mode 100644 templates/auth/users.html diff --git a/.env b/.env new file mode 100644 index 0000000..d8389cf --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +# MongoDB Connection Options +DATABASE_URL=mongodb://localhost:27017 +DATABASE_NAME=stick_db + +# Cryptography Settings +# Ensure this secret is a strong, random character sequence in production +JWT_SECRET=super_secret_template_signing_key_that_is_at_least_32_characters_long + +# Web Server Options +HOST=127.0.0.1 +PORT=3000 diff --git a/Cargo.lock b/Cargo.lock index b127d5c..541c236 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2554,6 +2554,7 @@ dependencies = [ "futures", "jsonwebtoken", "mongodb", + "rand 0.8.6", "serde", "serde_json", "serde_with", diff --git a/Cargo.toml b/Cargo.toml index 04a3ed2..68a2244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,5 @@ tokio = { version = "1.52.3", features = ["full"] } tower-http = { version = "0.6.11", features = ["trace", "cors"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" +rand = "0.8.5" + diff --git a/README.md b/README.md index f0c8a1f..5c3f3ba 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,129 @@ -# Stick: Use-Case Oriented Axum + Askama + MongoDB Template +# Stick -A production-ready Rust web application template organized by vertical features (use-cases) rather than horizontal technical layers. +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. + +This setup is ideal for medium-to-large projects where horizontal layers become hard to navigate, and compiling templates at runtime is too risky. --- -## πŸ› οΈ Technology Stack -* **Web Framework**: [Axum](https://github.com/tokio-rs/axum) (v0.8) - Native RPITIT (Return-Position Impl Trait in Traits) extractors. -* **Template Engine**: [Askama](https://github.com/djc/askama) (v0.16) - Type-safe, compile-time HTML templates. -* **Styling**: [Tailwind CSS](https://tailwindcss.com/) - Modern utility-first styling. -* **Database**: [MongoDB Rust Driver](https://github.com/mongodb/mongo-rust-driver) (v3.7) - Configured with BSON v3. -* **Authentication/Authorization**: [jsonwebtoken](https://github.com/Keats/jsonwebtoken) (v10) with the `rust_crypto` backend, stored in secure `HttpOnly` session cookies. -* **Password Hashing**: [bcrypt](https://github.com/Keats/rust-bcrypt) for secure password storage. +## Technical Architecture + +### 1. Vertical Slice Layout +Each feature slice is self-contained. For example, the authentication domain has the following structure: + +* `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. + +### 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. +* **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. --- -## πŸ“ Use-Case Centered Project Layout -Unlike traditional MVC setups, files are grouped by their business domain. A single use-case directory contains its models, database repositories, route handlers, local extractors, and templates. +## 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: ```text -stick/ -β”œβ”€β”€ Cargo.toml -β”œβ”€β”€ .env.example -β”œβ”€β”€ templates/ # Raw HTML template layout files -β”‚ β”œβ”€β”€ base.html # Global layout wrapper -β”‚ β”œβ”€β”€ auth/ # Auth views -β”‚ β”‚ β”œβ”€β”€ login.html -β”‚ β”‚ └── register.html -β”‚ β”œβ”€β”€ tasks/ # Task manager views -β”‚ β”‚ └── dashboard.html -β”‚ └── main_view/ # Main views -β”‚ └── index.html -└── src/ - β”œβ”€β”€ main.rs # Server composition, shared state, and route merging - β”œβ”€β”€ common/ # Shared features (errors, database, settings) - β”‚ β”œβ”€β”€ config.rs - β”‚ β”œβ”€β”€ database.rs - β”‚ └── errors.rs - β”œβ”€β”€ auth/ # Authentication & User Management Use-Case - β”‚ β”œβ”€β”€ extractors.rs # Session context extractors - β”‚ β”œβ”€β”€ handlers.rs # User interaction handlers - β”‚ β”œβ”€β”€ models.rs # User database schemas - β”‚ └── repository.rs # User database actions - β”œβ”€β”€ tasks/ # Tasks & Dashboard Management Use-Case - β”‚ β”œβ”€β”€ handlers.rs # Task CRUD handlers - β”‚ β”œβ”€β”€ models.rs # Task database schemas - β”‚ └── repository.rs # Task database actions - └── main_view/ # Static Navigation & Branding Use-Case - └── mod.rs # Serves index & handles public routes +====================================================== +CREATED INITIAL ADMINISTRATOR ACCOUNT: +Username: admin +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 (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`. + --- -## πŸš€ Setup & Execution +## Setup and Running -### 1. Prerequisites -* [Rust](https://www.rust-lang.org/tools/install) (v1.75+ required for native async traits) -* [MongoDB](https://www.mongodb.com/) running locally (port `27017`) +### Prerequisites +* **Rust**: Toolchain v1.75+ (for native async traits). +* **Node.js & npm**: Required to build Tailwind CSS. +* **MongoDB**: Running locally on `mongodb://localhost:27017`. -### 2. Configuration -Copy the configuration example file and customize your settings: -```bash -cp .env.example .env -``` +### Local Setup +1. Copy the environment configuration: + ```bash + cp .env.example .env + ``` +2. Build Tailwind CSS styling: + ```bash + npm install + npx tailwindcss -i src/input.css -o static/tailwind.css + ``` +3. Run the development server: + ```bash + cargo run + ``` + The server will start listening at `http://127.0.0.1:3000`. -### 3. Run the Server -Start the development server: -```bash -cargo run -``` -The server will start listening at `http://127.0.0.1:3000`. +### Running with Docker +A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust binary, and bundle a lightweight Debian run container. + +1. Build the image: + ```bash + docker build -t stick . + ``` +2. Start the container (assumes MongoDB is running on the host machine): + ```bash + docker run --name stick-app --rm --network="host" \ + -e DATABASE_URL="mongodb://127.0.0.1:27017" \ + -e DATABASE_NAME="stick_db" \ + -e JWT_SECRET="super_secret_template_signing_key_that_is_at_least_32_characters_long" \ + -e HOST="127.0.0.1" \ + -e PORT="3009" \ + stick + ``` --- -## πŸ’‘ Designing Custom Use-Cases -When adding a new feature (e.g., `projects`): +## Developer Guide: Adding a Feature Slice -1. Create `src/projects/` containing: - * `models.rs` (BSON schemas) - * `repository.rs` (Database access) - * `handlers.rs` (Endpoints) - * `mod.rs` (Usecase module entrypoint exposing a `pub fn router() -> Router`) -2. Add its view templates under `templates/projects/`. -3. Expose the repository and compile constraints in `src/main.rs`. -4. Merge the usecase router inside the main router builder: - ```rust - let app = Router::new() - .merge(main_view::router()) - .merge(auth::router()) - .merge(projects::router()) // Custom vertical router - .with_state(state); - ``` +To add a new feature (e.g. `projects`): + +1. Create a module folder: `src/projects/`. +2. Define models in `models.rs` and database access functions 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: + ```rust + pub fn router() -> Router + where + Config: axum::extract::FromRef, + MongoUserRepository: axum::extract::FromRef, + 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`: + ```rust + let app = Router::new() + .merge(main_view::router()) + .merge(auth::router()) + .merge(projects::router()) // Merged domain router + .with_state(state); + ``` diff --git a/conversation_summary.md b/conversation_summary.md deleted file mode 100644 index 366888f..0000000 --- a/conversation_summary.md +++ /dev/null @@ -1,88 +0,0 @@ -# Detailed Project Architecture & Design System Reference Manual - -This document provides a comprehensive technical overview of the **Stick** template, detailing its vertical structure, core dependencies, event delegation contracts, component reuse solutions, and our completed implementation history. - ---- - -## πŸ“ 1. Project Directory Layout & Vertical Architecture - -Unlike traditional MVC applications that split code horizontally (e.g., separating all controllers, all models, and all views), Stick is organized by **vertical features (use-cases)**. Every business module contains its own Rust handler logic, database schemas, repository queries, and custom routing. - -### Directory Mapping -```text -stick/ -β”œβ”€β”€ Cargo.toml # Rust dependency management -β”œβ”€β”€ package.json # Node assets configuration (optional tooling) -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ main.rs # Core composition, shared state, and route merging -β”‚ β”œβ”€β”€ common/ # Shared features (errors, database, settings) -β”‚ β”œβ”€β”€ auth/ # AUTH USE-CASE (Users, Passwords, Session cookies) -β”‚ β”œβ”€β”€ tasks/ # TASKS USE-CASE (CRUD tasks, dashboard view) -β”‚ β”œβ”€β”€ developers/ # DEVELOPERS USE-CASE (Assignee autocomplete query) -β”‚ β”œβ”€β”€ main_view/ # STATIC VIEWS & GLOBAL ASSET ENDPOINTS -β”‚ β”œβ”€β”€ components/ # WIKI & INTERACTIVE DESIGN SYSTEM -β”‚ └── input.css # Tailwind CSS v4 custom theme mappings and custom scrollbars -β”œβ”€β”€ static/ # Raw assets compiled/included in compilation -└── templates/ # Raw HTML template layout files (Askama templates) - β”œβ”€β”€ base.html # Global HTML layout wrapper - β”œβ”€β”€ auth/ # Login / registration markup (refactored to macros) - β”œβ”€β”€ tasks/ # Task management layouts (refactored to macros) - β”œβ”€β”€ main_view/ # Landing page layout - └── components/ # Component Wiki pages (refactored to macros) -``` - ---- - -## πŸ› οΈ 2. Core Technical Dependencies - -* **Web Server**: `axum = "0.8.9"` -* **Database**: `mongodb = "3.7.0"` (using `bson = "3.1.0"`) -* **Template Rendering**: `askama = "0.16.0"` -* **Styling Engine**: Tailwind CSS (v4) -* **Security & JWT**: `jsonwebtoken = "10.4.0"` + `bcrypt = "0.19.1"` - ---- - -## 🎨 3. JavaScript & HTML Event Delegation Contract - -Stick uses **document-level event delegation** via `components.js` and `combobox.js`. - -| Component Type | JavaScript Trigger / Hook | Expected Target / Functional Structure | -| :--- | :--- | :--- | -| **Modal / Dialog** | `[data-modal-target="id"]` | `.modal-dialog`, `.modal-backdrop`, `.modal-close` | -| **Sheets / Drawers** | `[data-sheet-target="id"]` | `.sheet-dialog`, `.sheet-backdrop`, `.sheet-close` | -| **Custom Dropdowns** | `.dropdown-trigger` | `.dropdown-menu`, `.dropdown-content` | -| **Interactive Tabs**| `[data-tab-target="pane-id"]` | `[data-tab-group="group-name"]` on buttons & content wrappers | -| **Accordions** | `.accordion-trigger` | `.accordion-item`, `.accordion-content`, `.accordion-chevron` | -| **Custom Select** | `.select-trigger` | `.custom-select`, `.select-popover`, `.select-item`, `.select-value` | -| **Autocomplete Combobox**| `.combobox-input` | `.autocomplete-combobox`, `.combobox-results`, `.combobox-value` | - ---- - -## πŸš€ 4. Completed Askama Macros Refactoring (Completed Tasks) -We successfully migrated all UI components to type-safe templates on the branch `demo/askama-macros`. - -### A. Phase 1: Form Inputs & Basic Controls -Consolidated basic form controls into [templates/components/macros.html](file:///home/enciphered/Desktop/Code/stick/templates/components/macros.html): -* `button`: Unified visual styles with custom icon inclusion (`label|safe`). -* `text_input` & `textarea`: Standardized label, placeholder, name, and value variables. -* `select_open`, `select_item`, `select_close`: Structural paired macros bypassing Askama's lack of template slots. -* `toggle_switch` & `checkbox`: Standardized custom check styles. -* `range_slider` & `datepicker` & `timepicker`: Popover-based pickers integration. - -### B. Phase 2: Layout & Overlays (Paired Macro Pattern) -* `modal_open` & `modal_close`: Paired overlay layout block wrapping. -* `sheet_open` & `sheet_close`: Paired slide-out detail drawers. -* `tabs_header_open`, `tab_trigger`, `tabs_header_close`, `tabs_content_open`, `tab_pane_open`, `tab_pane_close`, `tabs_content_close`: Flexible dynamic tabs. -* `accordion_open` & `accordion_close`: Paired collapsible vertical containers. - -### C. Phase 3: Global Cleanup & Integration -* Refactored the Component Wiki demo views to use all new macros (`buttons.html`, `inputs.html`, `toggles.html`, `date_time.html`, `modals.html`, `sheets.html`, `tabs_accordion.html`). -* Refactored the authentication views (`login.html` and `register.html`) to use unified `text_input` and `button` macros. -* Refactored the rest of the Task Dashboard (`dashboard.html`) to use macro inputs for the task name and task description fields. - ---- - -## πŸ§ͺ 5. Testing & Verification Results -* **Compile-time Check**: Successfully ran `cargo check` to ensure all macro scopes, imports, and variables are resolved at compilation time. -* **Docker Container Status**: Image rebuilt successfully. Running on network port `3009`. diff --git a/src/auth/extractors.rs b/src/auth/extractors.rs index 08d4d26..f6383c3 100644 --- a/src/auth/extractors.rs +++ b/src/auth/extractors.rs @@ -12,6 +12,7 @@ use crate::auth::models::Claims; pub struct AuthenticatedUser { pub user_id: ObjectId, pub username: String, + pub is_admin: bool, } impl FromRequestParts for AuthenticatedUser @@ -46,6 +47,7 @@ where Ok(AuthenticatedUser { user_id, username: token_data.claims.username, + is_admin: token_data.claims.is_admin, }) } } @@ -82,6 +84,7 @@ where Ok(Some(AuthenticatedUser { user_id, username: token_data.claims.username, + is_admin: token_data.claims.is_admin, })) } } diff --git a/src/auth/handlers.rs b/src/auth/handlers.rs index e3c46dc..2d48a9e 100644 --- a/src/auth/handlers.rs +++ b/src/auth/handlers.rs @@ -1,6 +1,6 @@ use askama::Template; use axum::{ - extract::{Form, State}, + extract::{Form, State, Path}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, }; @@ -117,6 +117,7 @@ pub async fn post_login( let claims = Claims { sub: user.id.expect("User document must have ID").to_hex(), username: user.username, + is_admin: user.is_admin, exp, }; @@ -139,24 +140,36 @@ pub async fn post_login( } pub async fn get_register( - user_opt: Option, -) -> impl IntoResponse { - if user_opt.is_some() { - return Redirect::to("/tasks").into_response(); + user: AuthenticatedUser, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can access registration".to_string())); } - HtmlTemplate(RegisterTemplate { + Ok(HtmlTemplate(RegisterTemplate { error: None, success: None, - authenticated: false, - username: "".to_string(), + authenticated: true, + username: user.username, }) - .into_response() + .into_response()) +} + +#[derive(Deserialize)] +pub struct RegisterPayload { + pub username: String, + pub password: String, + pub is_admin: Option, } pub async fn post_register( + user: AuthenticatedUser, State(user_repo): State, - Form(payload): Form, + Form(payload): Form, ) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can register new users".to_string())); + } + let username = payload.username.trim(); let password = payload.password.trim(); @@ -164,8 +177,8 @@ pub async fn post_register( return Ok(HtmlTemplate(RegisterTemplate { error: Some("Username and password cannot be empty".to_string()), success: None, - authenticated: false, - username: "".to_string(), + authenticated: true, + username: user.username, }) .into_response()); } @@ -176,8 +189,8 @@ pub async fn post_register( return Ok(HtmlTemplate(RegisterTemplate { error: Some("Username already taken".to_string()), success: None, - authenticated: false, - username: "".to_string(), + authenticated: true, + username: user.username, }) .into_response()); } @@ -186,13 +199,14 @@ pub async fn post_register( let hashed_password = hash(password, DEFAULT_COST)?; // Create user - user_repo.create(username, &hashed_password).await?; + let is_admin_val = payload.is_admin.is_some(); + user_repo.create(username, &hashed_password, is_admin_val).await?; Ok(HtmlTemplate(RegisterTemplate { error: None, - success: Some("Registration successful! You can now log in.".to_string()), - authenticated: false, - username: "".to_string(), + success: Some(format!("User '{}' registered successfully!", username)), + authenticated: true, + username: user.username, }) .into_response()) } @@ -205,3 +219,271 @@ pub async fn post_logout(jar: CookieJar) -> impl IntoResponse { let updated_jar = jar.add(cookie); (updated_jar, Redirect::to("/")).into_response() } + +#[derive(Template)] +#[template(path = "auth/password.html")] +struct PasswordTemplate { + error: Option, + success: Option, + authenticated: bool, + username: String, + is_admin: bool, +} + +#[derive(Deserialize)] +pub struct PasswordPayload { + pub current_password: String, + pub new_password: String, + pub confirm_password: String, +} + +pub async fn get_password( + user: AuthenticatedUser, +) -> impl IntoResponse { + HtmlTemplate(PasswordTemplate { + error: None, + success: None, + authenticated: true, + username: user.username.clone(), + is_admin: user.is_admin, + }) +} + +pub async fn post_password( + user: AuthenticatedUser, + State(user_repo): State, + Form(payload): Form, +) -> Result { + let current_password = payload.current_password.trim(); + let new_password = payload.new_password.trim(); + let confirm_password = payload.confirm_password.trim(); + + if current_password.is_empty() || new_password.is_empty() || confirm_password.is_empty() { + return Ok(HtmlTemplate(PasswordTemplate { + error: Some("All fields are required".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + is_admin: user.is_admin, + }) + .into_response()); + } + + if new_password != confirm_password { + return Ok(HtmlTemplate(PasswordTemplate { + error: Some("New passwords do not match".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + is_admin: user.is_admin, + }) + .into_response()); + } + + // Fetch user details to verify current password + let user_db = user_repo.find_by_username(&user.username).await?; + let Some(user_db) = user_db else { + return Err(AppError::Unauthorized("User not found".to_string())); + }; + + // Verify current password + match verify(current_password, &user_db.password_hash) { + Ok(true) => {} + _ => { + return Ok(HtmlTemplate(PasswordTemplate { + error: Some("Current password is incorrect".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + is_admin: user.is_admin, + }) + .into_response()); + } + } + + // Hash new password + let hashed_password = hash(new_password, DEFAULT_COST)?; + + // Update password in database + user_repo.update_password(&user.user_id, &hashed_password).await?; + + Ok(HtmlTemplate(PasswordTemplate { + error: None, + success: Some("Password updated successfully!".to_string()), + authenticated: true, + username: user.username.clone(), + is_admin: user.is_admin, + }) + .into_response()) +} + +// User Management Templates and Handlers + +#[derive(Template)] +#[template(path = "auth/users.html")] +pub struct UserListTemplate { + pub authenticated: bool, + pub username: String, + pub users: Vec, +} + +#[derive(Template)] +#[template(path = "auth/edit_user.html")] +pub struct UserEditTemplate { + pub error: Option, + pub success: Option, + pub authenticated: bool, + pub username: String, + pub user_to_edit: crate::auth::models::User, +} + +pub async fn get_users( + user: AuthenticatedUser, + State(user_repo): State, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can access user management".to_string())); + } + + let users = user_repo.find_all().await?; + + Ok(HtmlTemplate(UserListTemplate { + authenticated: true, + username: user.username, + users, + }) + .into_response()) +} + +pub async fn get_edit_user( + user: AuthenticatedUser, + State(user_repo): State, + Path(id_str): Path, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can edit users".to_string())); + } + + let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str) + .map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?; + + let user_to_edit = user_repo.find_by_id(&oid).await? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + Ok(HtmlTemplate(UserEditTemplate { + error: None, + success: None, + authenticated: true, + username: user.username, + user_to_edit, + }) + .into_response()) +} + +#[derive(Deserialize)] +pub struct EditUserPayload { + pub username: String, + pub password: Option, + pub is_admin: Option, +} + +pub async fn post_edit_user( + user: AuthenticatedUser, + State(user_repo): State, + Path(id_str): Path, + Form(payload): Form, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can edit users".to_string())); + } + + let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str) + .map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?; + + let user_to_edit = user_repo.find_by_id(&oid).await? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + let new_username = payload.username.trim(); + if new_username.is_empty() { + return Ok(HtmlTemplate(UserEditTemplate { + error: Some("Username cannot be empty".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + user_to_edit, + }) + .into_response()); + } + + if new_username != user_to_edit.username { + let existing = user_repo.find_by_username(new_username).await?; + if existing.is_some() { + return Ok(HtmlTemplate(UserEditTemplate { + error: Some("Username already taken".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + user_to_edit, + }) + .into_response()); + } + } + + let is_admin_val = payload.is_admin.is_some(); + + // Safety check: Don't allow removing own admin status + if oid == user.user_id && !is_admin_val { + return Ok(HtmlTemplate(UserEditTemplate { + error: Some("You cannot revoke your own administrator privileges".to_string()), + success: None, + authenticated: true, + username: user.username.clone(), + user_to_edit: user_to_edit.clone(), + }) + .into_response()); + } + + let new_password = payload.password.as_deref().unwrap_or("").trim(); + let password_hash_opt = if !new_password.is_empty() { + let hash = bcrypt::hash(new_password, bcrypt::DEFAULT_COST)?; + Some(hash) + } else { + None + }; + + user_repo.update(&oid, new_username, password_hash_opt.as_deref(), is_admin_val).await?; + + let updated_user = user_repo.find_by_id(&oid).await? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + Ok(HtmlTemplate(UserEditTemplate { + error: None, + success: Some("User updated successfully!".to_string()), + authenticated: true, + username: user.username.clone(), + user_to_edit: updated_user, + }) + .into_response()) +} + +pub async fn post_delete_user( + user: AuthenticatedUser, + State(user_repo): State, + Path(id_str): Path, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can delete users".to_string())); + } + + let oid = mongodb::bson::oid::ObjectId::parse_str(&id_str) + .map_err(|_| AppError::BadRequest("Invalid user ID format".to_string()))?; + + // Safety: Cannot delete self + if oid == user.user_id { + return Err(AppError::BadRequest("You cannot delete your own account while logged in".to_string())); + } + + user_repo.delete(&oid).await?; + + Ok(Redirect::to("/auth/users").into_response()) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 33428e8..f4fda62 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -20,4 +20,8 @@ where .route("/auth/login", get(handlers::get_login).post(handlers::post_login)) .route("/auth/register", get(handlers::get_register).post(handlers::post_register)) .route("/auth/logout", post(handlers::post_logout)) + .route("/auth/password", get(handlers::get_password).post(handlers::post_password)) + .route("/auth/users", get(handlers::get_users)) + .route("/auth/users/{id}/edit", get(handlers::get_edit_user).post(handlers::post_edit_user)) + .route("/auth/users/{id}/delete", post(handlers::post_delete_user)) } diff --git a/src/auth/models.rs b/src/auth/models.rs index afbd7ed..6e1b74c 100644 --- a/src/auth/models.rs +++ b/src/auth/models.rs @@ -9,6 +9,7 @@ pub struct User { pub id: Option, pub username: String, pub password_hash: String, + pub is_admin: bool, #[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")] pub created_at: chrono::DateTime, } @@ -17,5 +18,6 @@ pub struct User { pub struct Claims { pub sub: String, pub username: String, + pub is_admin: bool, pub exp: usize, } diff --git a/src/auth/repository.rs b/src/auth/repository.rs index 11f7ee0..a801da7 100644 --- a/src/auth/repository.rs +++ b/src/auth/repository.rs @@ -8,7 +8,12 @@ use crate::auth::models::User; #[async_trait::async_trait] pub trait UserRepository { async fn find_by_username(&self, username: &str) -> Result, AppError>; - async fn create(&self, username: &str, password_hash: &str) -> Result; + async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result, AppError>; + async fn find_all(&self) -> Result, AppError>; + async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result; + async fn update(&self, id: &mongodb::bson::oid::ObjectId, username: &str, password_hash: Option<&str>, is_admin: bool) -> Result<(), AppError>; + async fn update_password(&self, user_id: &mongodb::bson::oid::ObjectId, new_password_hash: &str) -> Result<(), AppError>; + async fn delete(&self, id: &mongodb::bson::oid::ObjectId) -> Result<(), AppError>; } #[derive(Clone)] @@ -32,13 +37,29 @@ impl UserRepository for MongoUserRepository { Ok(user) } - async fn create(&self, username: &str, password_hash: &str) -> Result { + async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result, AppError> { + let collection = self.db.collection::("users"); + let filter = doc! { "_id": id }; + let user = collection.find_one(filter).await?; + Ok(user) + } + + async fn find_all(&self) -> Result, AppError> { + use futures::TryStreamExt; + let collection = self.db.collection::("users"); + let cursor = collection.find(doc! {}).await?; + let users = cursor.try_collect::>().await?; + Ok(users) + } + + async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result { let collection = self.db.collection::("users"); let new_user = User { id: None, username: username.to_string(), password_hash: password_hash.to_string(), + is_admin, created_at: chrono::Utc::now(), }; @@ -49,4 +70,34 @@ impl UserRepository for MongoUserRepository { Ok(user) } + + async fn update(&self, id: &mongodb::bson::oid::ObjectId, username: &str, password_hash: Option<&str>, is_admin: bool) -> Result<(), AppError> { + let collection = self.db.collection::("users"); + let filter = doc! { "_id": id }; + let mut update_doc = doc! { + "username": username.to_string(), + "is_admin": is_admin, + }; + if let Some(hash) = password_hash { + update_doc.insert("password_hash", hash.to_string()); + } + let update = doc! { "$set": update_doc }; + collection.update_one(filter, update).await?; + Ok(()) + } + + async fn update_password(&self, user_id: &mongodb::bson::oid::ObjectId, new_password_hash: &str) -> Result<(), AppError> { + let collection = self.db.collection::("users"); + let filter = doc! { "_id": user_id }; + let update = doc! { "$set": { "password_hash": new_password_hash } }; + collection.update_one(filter, update).await?; + Ok(()) + } + + async fn delete(&self, id: &mongodb::bson::oid::ObjectId) -> Result<(), AppError> { + let collection = self.db.collection::("users"); + let filter = doc! { "_id": id }; + collection.delete_one(filter).await?; + Ok(()) + } } diff --git a/src/common/errors.rs b/src/common/errors.rs index aa4a06b..5e0336c 100644 --- a/src/common/errors.rs +++ b/src/common/errors.rs @@ -18,9 +18,15 @@ pub enum AppError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("Forbidden: {0}")] + Forbidden(String), + #[error("Bad Request: {0}")] BadRequest(String), + #[error("Not Found: {0}")] + NotFound(String), + #[error("Internal Server Error: {0}")] #[allow(dead_code)] Internal(String), @@ -42,7 +48,9 @@ impl IntoResponse for AppError { (StatusCode::UNAUTHORIZED, "Your session has expired or is invalid. Please log in again.") } AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.as_str()), + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.as_str()), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.as_str()), AppError::Internal(msg) => { tracing::error!("Internal Error: {}", msg); (StatusCode::INTERNAL_SERVER_ERROR, msg.as_str()) diff --git a/src/main.rs b/src/main.rs index 2451ad7..2f94dbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,42 @@ async fn main() -> Result<(), Box> { let task_repo = MongoTaskRepository::new(db.clone()); let dev_repo = MongoDeveloperRepository::new(db.clone()); + // Auto-provision initial administrator if users collection is empty + let users_count = db.collection::("users") + .count_documents(mongodb::bson::doc! {}) + .await?; + + if users_count == 0 { + use rand::{distributions::Alphanumeric, Rng}; + let random_password: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect(); + let password_hash = bcrypt::hash(&random_password, bcrypt::DEFAULT_COST)?; + + let admin_username = "admin"; + let admin_user = crate::auth::models::User { + id: None, + username: admin_username.to_string(), + password_hash, + is_admin: true, + created_at: chrono::Utc::now(), + }; + + db.collection::("users") + .insert_one(admin_user) + .await?; + + info!("\n\n\ + ======================================================\n\ + CREATED INITIAL ADMINISTRATOR ACCOUNT:\n\ + Username: {}\n\ + Password: {}\n\ + ======================================================\n\n", + admin_username, random_password); + } + // 5. Initialize shared AppState let state = AppState { config: config.clone(), diff --git a/templates/auth/edit_user.html b/templates/auth/edit_user.html new file mode 100644 index 0000000..190f890 --- /dev/null +++ b/templates/auth/edit_user.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% import "components/macros.html" as ui %} + +{% block title %}Edit User - Stick{% endblock %} + +{% block content %} +
+
+
+ +
+

Edit User

+

Modify credentials and permissions for {{ user_to_edit.username }}

+
+ + {% if let Some(err) = error %} +
+ + + + {{ err }} +
+ {% endif %} + + {% if let Some(msg) = success %} +
+ + + + {{ msg }} +
+ {% endif %} + +
+ {{ ui::text_input(id="username", name="username", label="Username", type="text", placeholder="Enter username", value=user_to_edit.username, required=true) }} + + {{ ui::text_input(id="password", name="password", label="Reset Password (Leave blank to keep unchanged)", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", required=false) }} + +
+ {% if user_to_edit.username == username %} + + + + + {% else %} + + + {% endif %} +
+ +
+ {{ ui::button(label="Save Changes", variant="indigo", type="submit", extra_class="w-full py-3.5 shadow-lg shadow-sky-500/10") }} +
+
+ + +
+
+{% endblock %} diff --git a/templates/auth/password.html b/templates/auth/password.html new file mode 100644 index 0000000..226cb2e --- /dev/null +++ b/templates/auth/password.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% import "components/macros.html" as ui %} + +{% block title %}Account Settings - Stick{% endblock %} + +{% block content %} +
+
+ + +
+
+
+
+

Change Password

+

Update your account password security

+
+ + {% if let Some(err) = error %} +
+ + + + {{ err }} +
+ {% endif %} + + {% if let Some(msg) = success %} +
+ + + + {{ msg }} +
+ {% endif %} + +
+ {{ ui::text_input(id="current_password", name="current_password", label="Current Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", required=true) }} + + {{ ui::text_input(id="new_password", name="new_password", label="New Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", required=true) }} + + {{ ui::text_input(id="confirm_password", name="confirm_password", label="Confirm New Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", required=true) }} + +
+ {{ ui::button(label="Update Password", variant="indigo", type="submit", extra_class="w-full py-3.5 shadow-lg shadow-sky-500/10") }} +
+
+
+ + +
+ + {% if is_admin %} + +
+
+
+
+

Admin Portal

+

Administrative tools and user provisioning

+
+ +
+
+
+ + + +
+
+

User Management

+

View, edit, or delete registered user accounts and create new administrators/users.

+ +
+
+ +
+
+ + + +
+
+

Developer Roster

+

Manage team developer profiles, update their technical skills, or clear their metadata.

+ +
+
+
+
+ +
+ Authorized Administrator Session +
+
+ {% endif %} + +
+
+{% endblock %} diff --git a/templates/auth/register.html b/templates/auth/register.html index c7f3020..cc25d7b 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -9,8 +9,8 @@
-

Create Account

-

Join us to start planning your tasks

+

Register User

+

Create a new developer or administrator account

{% if let Some(err) = error %} @@ -32,18 +32,22 @@ {% endif %}
- {{ ui::text_input(id="username", name="username", label="Username", type="text", placeholder="Choose a username", required=true) }} + {{ ui::text_input(id="username", name="username", label="Username", type="text", placeholder="Enter username", required=true) }} {{ ui::text_input(id="password", name="password", label="Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’", required=true) }} +
+ + +
+
- {{ ui::button(label="Sign Up", variant="primary", type="submit", extra_class="w-full py-3.5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg shadow-emerald-500/10 focus:ring-emerald-500") }} + {{ ui::button(label="Register User", variant="indigo", type="submit", extra_class="w-full py-3.5 shadow-lg shadow-indigo-500/10") }}
- Already have an account? - Log in here + ← Back to Users
diff --git a/templates/auth/users.html b/templates/auth/users.html new file mode 100644 index 0000000..c565e30 --- /dev/null +++ b/templates/auth/users.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% import "components/macros.html" as ui %} + +{% block title %}User Management - Stick{% endblock %} + +{% block content %} +
+ + +
+
+

User Management

+

Manage system access, toggle roles, and provision credentials

+
+
+ + Register New User + + + Total Users: {{ users.len() }} + +
+
+ + +
+
+ + + + + + + + + + + {% if users.is_empty() %} + + + + {% else %} + {% for user_item in users %} + + + + + + + {% endfor %} + {% endif %} + +
UsernameRoleCreated AtActions
+ No registered users found. +
+ {{ user_item.username }} + {% if user_item.username == username %} + You + {% endif %} + + {% if user_item.is_admin %} + + Administrator + + {% else %} + + Standard User + + {% endif %} + + {{ user_item.created_at.format("%B %d, %Y at %H:%M UTC") }} + +
+ + + + + + + {% if user_item.username != username %} +
+ +
+ {% else %} + + + + + + {% endif %} +
+
+
+
+ + +
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 1881b46..89103f0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -60,9 +60,9 @@ Developers
- + {{ username }} - +