feat: refactor and refine authentication system with decoupled user management and admin console
This commit is contained in:
@@ -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
|
||||||
Generated
+1
@@ -2554,6 +2554,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ tokio = { version = "1.52.3", features = ["full"] }
|
|||||||
tower-http = { version = "0.6.11", features = ["trace", "cors"] }
|
tower-http = { version = "0.6.11", features = ["trace", "cors"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
|
rand = "0.8.5"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## Technical Architecture
|
||||||
* **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.
|
### 1. Vertical Slice Layout
|
||||||
* **Styling**: [Tailwind CSS](https://tailwindcss.com/) - Modern utility-first styling.
|
Each feature slice is self-contained. For example, the authentication domain has the following structure:
|
||||||
* **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.
|
* `src/auth/models.rs`: BSON data structures and claims.
|
||||||
* **Password Hashing**: [bcrypt](https://github.com/Keats/rust-bcrypt) for secure password storage.
|
* `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
|
## Core Features Included
|
||||||
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.
|
|
||||||
|
### 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
|
```text
|
||||||
stick/
|
======================================================
|
||||||
├── Cargo.toml
|
CREATED INITIAL ADMINISTRATOR ACCOUNT:
|
||||||
├── .env.example
|
Username: admin
|
||||||
├── templates/ # Raw HTML template layout files
|
Password: [GeneratedPassword]
|
||||||
│ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### Prerequisites
|
||||||
* [Rust](https://www.rust-lang.org/tools/install) (v1.75+ required for native async traits)
|
* **Rust**: Toolchain v1.75+ (for native async traits).
|
||||||
* [MongoDB](https://www.mongodb.com/) running locally (port `27017`)
|
* **Node.js & npm**: Required to build Tailwind CSS.
|
||||||
|
* **MongoDB**: Running locally on `mongodb://localhost:27017`.
|
||||||
|
|
||||||
### 2. Configuration
|
### Local Setup
|
||||||
Copy the configuration example file and customize your settings:
|
1. Copy the environment configuration:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
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
|
### Running with Docker
|
||||||
Start the development server:
|
A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust binary, and bundle a lightweight Debian run container.
|
||||||
```bash
|
|
||||||
cargo run
|
1. Build the image:
|
||||||
```
|
```bash
|
||||||
The server will start listening at `http://127.0.0.1:3000`.
|
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
|
## Developer Guide: Adding a Feature Slice
|
||||||
When adding a new feature (e.g., `projects`):
|
|
||||||
|
|
||||||
1. Create `src/projects/` containing:
|
To add a new feature (e.g. `projects`):
|
||||||
* `models.rs` (BSON schemas)
|
|
||||||
* `repository.rs` (Database access)
|
1. Create a module folder: `src/projects/`.
|
||||||
* `handlers.rs` (Endpoints)
|
2. Define models in `models.rs` and database access functions in `repository.rs`.
|
||||||
* `mod.rs` (Usecase module entrypoint exposing a `pub fn router<S>() -> Router<S>`)
|
3. Add request handlers in `handlers.rs`.
|
||||||
2. Add its view templates under `templates/projects/`.
|
4. Create a router configuration in `src/projects/mod.rs` exposing a routing module setup:
|
||||||
3. Expose the repository and compile constraints in `src/main.rs`.
|
```rust
|
||||||
4. Merge the usecase router inside the main router builder:
|
pub fn router<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
Config: axum::extract::FromRef<S>,
|
||||||
|
MongoUserRepository: 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`:
|
||||||
```rust
|
```rust
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.merge(main_view::router())
|
.merge(main_view::router())
|
||||||
.merge(auth::router())
|
.merge(auth::router())
|
||||||
.merge(projects::router()) // Custom vertical router
|
.merge(projects::router()) // Merged domain router
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -12,6 +12,7 @@ use crate::auth::models::Claims;
|
|||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: ObjectId,
|
pub user_id: ObjectId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||||
@@ -46,6 +47,7 @@ where
|
|||||||
Ok(AuthenticatedUser {
|
Ok(AuthenticatedUser {
|
||||||
user_id,
|
user_id,
|
||||||
username: token_data.claims.username,
|
username: token_data.claims.username,
|
||||||
|
is_admin: token_data.claims.is_admin,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,7 @@ where
|
|||||||
Ok(Some(AuthenticatedUser {
|
Ok(Some(AuthenticatedUser {
|
||||||
user_id,
|
user_id,
|
||||||
username: token_data.claims.username,
|
username: token_data.claims.username,
|
||||||
|
is_admin: token_data.claims.is_admin,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+300
-18
@@ -1,6 +1,6 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, State},
|
extract::{Form, State, Path},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
@@ -117,6 +117,7 @@ pub async fn post_login(
|
|||||||
let claims = Claims {
|
let claims = Claims {
|
||||||
sub: user.id.expect("User document must have ID").to_hex(),
|
sub: user.id.expect("User document must have ID").to_hex(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
is_admin: user.is_admin,
|
||||||
exp,
|
exp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,24 +140,36 @@ pub async fn post_login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_register(
|
pub async fn get_register(
|
||||||
user_opt: Option<AuthenticatedUser>,
|
user: AuthenticatedUser,
|
||||||
) -> impl IntoResponse {
|
) -> Result<Response, AppError> {
|
||||||
if user_opt.is_some() {
|
if !user.is_admin {
|
||||||
return Redirect::to("/tasks").into_response();
|
return Err(AppError::Forbidden("Only administrators can access registration".to_string()));
|
||||||
}
|
}
|
||||||
HtmlTemplate(RegisterTemplate {
|
Ok(HtmlTemplate(RegisterTemplate {
|
||||||
error: None,
|
error: None,
|
||||||
success: None,
|
success: None,
|
||||||
authenticated: false,
|
authenticated: true,
|
||||||
username: "".to_string(),
|
username: user.username,
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterPayload {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub is_admin: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_register(
|
pub async fn post_register(
|
||||||
|
user: AuthenticatedUser,
|
||||||
State(user_repo): State<MongoUserRepository>,
|
State(user_repo): State<MongoUserRepository>,
|
||||||
Form(payload): Form<AuthPayload>,
|
Form(payload): Form<RegisterPayload>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
if !user.is_admin {
|
||||||
|
return Err(AppError::Forbidden("Only administrators can register new users".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let username = payload.username.trim();
|
let username = payload.username.trim();
|
||||||
let password = payload.password.trim();
|
let password = payload.password.trim();
|
||||||
|
|
||||||
@@ -164,8 +177,8 @@ pub async fn post_register(
|
|||||||
return Ok(HtmlTemplate(RegisterTemplate {
|
return Ok(HtmlTemplate(RegisterTemplate {
|
||||||
error: Some("Username and password cannot be empty".to_string()),
|
error: Some("Username and password cannot be empty".to_string()),
|
||||||
success: None,
|
success: None,
|
||||||
authenticated: false,
|
authenticated: true,
|
||||||
username: "".to_string(),
|
username: user.username,
|
||||||
})
|
})
|
||||||
.into_response());
|
.into_response());
|
||||||
}
|
}
|
||||||
@@ -176,8 +189,8 @@ pub async fn post_register(
|
|||||||
return Ok(HtmlTemplate(RegisterTemplate {
|
return Ok(HtmlTemplate(RegisterTemplate {
|
||||||
error: Some("Username already taken".to_string()),
|
error: Some("Username already taken".to_string()),
|
||||||
success: None,
|
success: None,
|
||||||
authenticated: false,
|
authenticated: true,
|
||||||
username: "".to_string(),
|
username: user.username,
|
||||||
})
|
})
|
||||||
.into_response());
|
.into_response());
|
||||||
}
|
}
|
||||||
@@ -186,13 +199,14 @@ pub async fn post_register(
|
|||||||
let hashed_password = hash(password, DEFAULT_COST)?;
|
let hashed_password = hash(password, DEFAULT_COST)?;
|
||||||
|
|
||||||
// Create user
|
// 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 {
|
Ok(HtmlTemplate(RegisterTemplate {
|
||||||
error: None,
|
error: None,
|
||||||
success: Some("Registration successful! You can now log in.".to_string()),
|
success: Some(format!("User '{}' registered successfully!", username)),
|
||||||
authenticated: false,
|
authenticated: true,
|
||||||
username: "".to_string(),
|
username: user.username,
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
@@ -205,3 +219,271 @@ pub async fn post_logout(jar: CookieJar) -> impl IntoResponse {
|
|||||||
let updated_jar = jar.add(cookie);
|
let updated_jar = jar.add(cookie);
|
||||||
(updated_jar, Redirect::to("/")).into_response()
|
(updated_jar, Redirect::to("/")).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "auth/password.html")]
|
||||||
|
struct PasswordTemplate {
|
||||||
|
error: Option<String>,
|
||||||
|
success: Option<String>,
|
||||||
|
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<MongoUserRepository>,
|
||||||
|
Form(payload): Form<PasswordPayload>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<crate::auth::models::User>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "auth/edit_user.html")]
|
||||||
|
pub struct UserEditTemplate {
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub success: Option<String>,
|
||||||
|
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<MongoUserRepository>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<MongoUserRepository>,
|
||||||
|
Path(id_str): Path<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<String>,
|
||||||
|
pub is_admin: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_edit_user(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
State(user_repo): State<MongoUserRepository>,
|
||||||
|
Path(id_str): Path<String>,
|
||||||
|
Form(payload): Form<EditUserPayload>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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<MongoUserRepository>,
|
||||||
|
Path(id_str): Path<String>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ where
|
|||||||
.route("/auth/login", get(handlers::get_login).post(handlers::post_login))
|
.route("/auth/login", get(handlers::get_login).post(handlers::post_login))
|
||||||
.route("/auth/register", get(handlers::get_register).post(handlers::post_register))
|
.route("/auth/register", get(handlers::get_register).post(handlers::post_register))
|
||||||
.route("/auth/logout", post(handlers::post_logout))
|
.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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub struct User {
|
|||||||
pub id: Option<ObjectId>,
|
pub id: Option<ObjectId>,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
|
pub is_admin: bool,
|
||||||
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
@@ -17,5 +18,6 @@ pub struct User {
|
|||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub is_admin: bool,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-2
@@ -8,7 +8,12 @@ use crate::auth::models::User;
|
|||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait UserRepository {
|
pub trait UserRepository {
|
||||||
async fn find_by_username(&self, username: &str) -> Result<Option<User>, AppError>;
|
async fn find_by_username(&self, username: &str) -> Result<Option<User>, AppError>;
|
||||||
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError>;
|
async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result<Option<User>, AppError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<User>, AppError>;
|
||||||
|
async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result<User, AppError>;
|
||||||
|
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)]
|
#[derive(Clone)]
|
||||||
@@ -32,13 +37,29 @@ impl UserRepository for MongoUserRepository {
|
|||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create(&self, username: &str, password_hash: &str) -> Result<User, AppError> {
|
async fn find_by_id(&self, id: &mongodb::bson::oid::ObjectId) -> Result<Option<User>, AppError> {
|
||||||
|
let collection = self.db.collection::<User>("users");
|
||||||
|
let filter = doc! { "_id": id };
|
||||||
|
let user = collection.find_one(filter).await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> Result<Vec<User>, AppError> {
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
let collection = self.db.collection::<User>("users");
|
||||||
|
let cursor = collection.find(doc! {}).await?;
|
||||||
|
let users = cursor.try_collect::<Vec<User>>().await?;
|
||||||
|
Ok(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, username: &str, password_hash: &str, is_admin: bool) -> Result<User, AppError> {
|
||||||
let collection = self.db.collection::<User>("users");
|
let collection = self.db.collection::<User>("users");
|
||||||
|
|
||||||
let new_user = User {
|
let new_user = User {
|
||||||
id: None,
|
id: None,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password_hash: password_hash.to_string(),
|
password_hash: password_hash.to_string(),
|
||||||
|
is_admin,
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,4 +70,34 @@ impl UserRepository for MongoUserRepository {
|
|||||||
|
|
||||||
Ok(user)
|
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::<User>("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::<User>("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::<User>("users");
|
||||||
|
let filter = doc! { "_id": id };
|
||||||
|
collection.delete_one(filter).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,15 @@ pub enum AppError {
|
|||||||
#[error("Unauthorized: {0}")]
|
#[error("Unauthorized: {0}")]
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
|
|
||||||
|
#[error("Forbidden: {0}")]
|
||||||
|
Forbidden(String),
|
||||||
|
|
||||||
#[error("Bad Request: {0}")]
|
#[error("Bad Request: {0}")]
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Not Found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
#[error("Internal Server Error: {0}")]
|
#[error("Internal Server Error: {0}")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
@@ -42,7 +48,9 @@ impl IntoResponse for AppError {
|
|||||||
(StatusCode::UNAUTHORIZED, "Your session has expired or is invalid. Please log in again.")
|
(StatusCode::UNAUTHORIZED, "Your session has expired or is invalid. Please log in again.")
|
||||||
}
|
}
|
||||||
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.as_str()),
|
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::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.as_str()),
|
||||||
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.as_str()),
|
||||||
AppError::Internal(msg) => {
|
AppError::Internal(msg) => {
|
||||||
tracing::error!("Internal Error: {}", msg);
|
tracing::error!("Internal Error: {}", msg);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, msg.as_str())
|
(StatusCode::INTERNAL_SERVER_ERROR, msg.as_str())
|
||||||
|
|||||||
+36
@@ -75,6 +75,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let task_repo = MongoTaskRepository::new(db.clone());
|
let task_repo = MongoTaskRepository::new(db.clone());
|
||||||
let dev_repo = MongoDeveloperRepository::new(db.clone());
|
let dev_repo = MongoDeveloperRepository::new(db.clone());
|
||||||
|
|
||||||
|
// Auto-provision initial administrator if users collection is empty
|
||||||
|
let users_count = db.collection::<crate::auth::models::User>("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::<crate::auth::models::User>("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
|
// 5. Initialize shared AppState
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "components/macros.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}Edit User - Stick{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-8 shadow-2xl relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-sky-500"></div>
|
||||||
|
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-extrabold text-slate-100 tracking-tight">Edit User</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Modify credentials and permissions for {{ user_to_edit.username }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if let Some(err) = error %}
|
||||||
|
<div class="mb-6 p-4 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-sm flex items-start gap-2.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 flex-shrink-0 mt-0.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ err }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if let Some(msg) = success %}
|
||||||
|
<div class="mb-6 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm flex items-start gap-2.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 flex-shrink-0 mt-0.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ msg }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="space-y-5" action="/auth/users/{{ user_to_edit.id.unwrap().to_hex() }}/edit" method="post">
|
||||||
|
{{ 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) }}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 py-1">
|
||||||
|
{% if user_to_edit.username == username %}
|
||||||
|
<!-- User is editing themselves: don't allow changing role to prevent lockout -->
|
||||||
|
<input type="hidden" name="is_admin" value="on">
|
||||||
|
<input type="checkbox" id="is_admin" class="w-4 h-4 rounded border-border bg-slate-900 text-indigo-600 opacity-50 cursor-not-allowed" checked disabled>
|
||||||
|
<label for="is_admin" class="text-xs font-semibold text-slate-500">Administrator <span class="text-[10px] text-sky-400/70 ml-1">(Cannot edit own role)</span></label>
|
||||||
|
{% else %}
|
||||||
|
<input type="checkbox" id="is_admin" name="is_admin" class="w-4 h-4 rounded border-border bg-[#0f172a] text-indigo-600 focus:ring-sky-500 focus:ring-offset-background" {% if user_to_edit.is_admin %}checked{% endif %}>
|
||||||
|
<label for="is_admin" class="text-xs font-semibold text-slate-400">Make Administrator</label>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ ui::button(label="Save Changes", variant="indigo", type="submit", extra_class="w-full py-3.5 shadow-lg shadow-sky-500/10") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-slate-400">
|
||||||
|
<a href="/auth/users" class="font-medium text-sky-400 hover:underline">← Back to Users</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "components/macros.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}Account Settings - Stick{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grow flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="w-full {% if is_admin %}max-w-4xl{% else %}max-w-md{% endif %} grid {% if is_admin %}grid-cols-1 md:grid-cols-2{% endif %} gap-8">
|
||||||
|
|
||||||
|
<!-- Change Password Card -->
|
||||||
|
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-8 shadow-2xl relative overflow-hidden flex flex-col justify-between">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-sky-500"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-extrabold text-slate-100 tracking-tight">Change Password</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Update your account password security</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if let Some(err) = error %}
|
||||||
|
<div class="mb-6 p-4 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-sm flex items-start gap-2.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 flex-shrink-0 mt-0.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ err }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if let Some(msg) = success %}
|
||||||
|
<div class="mb-6 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm flex items-start gap-2.5">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 flex-shrink-0 mt-0.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ msg }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="space-y-5" action="/auth/password" method="post">
|
||||||
|
{{ 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) }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ ui::button(label="Update Password", variant="indigo", type="submit", extra_class="w-full py-3.5 shadow-lg shadow-sky-500/10") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-slate-400">
|
||||||
|
<a href="/tasks" class="font-medium text-sky-400 hover:underline">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<!-- Admin Actions Card -->
|
||||||
|
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-8 shadow-2xl relative overflow-hidden flex flex-col justify-between">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-emerald-500"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-extrabold text-slate-100 tracking-tight">Admin Portal</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Administrative tools and user provisioning</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6 mt-10">
|
||||||
|
<div class="p-5 rounded-2xl bg-emerald-500/5 border border-emerald-500/10 flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-400 flex-shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.109A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-slate-200">User Management</h4>
|
||||||
|
<p class="text-xs text-slate-400 mt-1 leading-relaxed">View, edit, or delete registered user accounts and create new administrators/users.</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<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-emerald-600 hover:bg-emerald-500 text-white shadow-md shadow-emerald-500/10">
|
||||||
|
Manage Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5 rounded-2xl bg-indigo-500/5 border border-indigo-500/10 flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 flex-shrink-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.109A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-slate-200">Developer Roster</h4>
|
||||||
|
<p class="text-xs text-slate-400 mt-1 leading-relaxed">Manage team developer profiles, update their technical skills, or clear their metadata.</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/developers" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-secondary border border-border hover:bg-secondary/80 text-slate-200">
|
||||||
|
Manage Developers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-slate-500">
|
||||||
|
Authorized Administrator Session
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
<div class="absolute top-0 left-0 w-full h-1 bg-emerald-500"></div>
|
<div class="absolute top-0 left-0 w-full h-1 bg-emerald-500"></div>
|
||||||
|
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="text-3xl font-extrabold text-slate-100 tracking-tight">Create Account</h2>
|
<h2 class="text-3xl font-extrabold text-slate-100 tracking-tight">Register User</h2>
|
||||||
<p class="mt-2 text-sm text-slate-400">Join us to start planning your tasks</p>
|
<p class="mt-2 text-sm text-slate-400">Create a new developer or administrator account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if let Some(err) = error %}
|
{% if let Some(err) = error %}
|
||||||
@@ -32,18 +32,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form class="space-y-5" action="/auth/register" method="post">
|
<form class="space-y-5" action="/auth/register" method="post">
|
||||||
{{ 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::text_input(id="password", name="password", label="Password", type="password", placeholder="••••••••", required=true) }}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 py-1">
|
||||||
|
<input type="checkbox" id="is_admin" name="is_admin" class="w-4 h-4 rounded border-border bg-[#0f172a] text-indigo-600 focus:ring-sky-500 focus:ring-offset-background">
|
||||||
|
<label for="is_admin" class="text-xs font-semibold text-slate-400">Make Administrator</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{{ 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") }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-6 text-center text-sm text-slate-400">
|
<div class="mt-6 text-center text-sm text-slate-400">
|
||||||
Already have an account?
|
<a href="/auth/users" class="font-medium text-sky-400 hover:underline">← Back to Users</a>
|
||||||
<a href="/auth/login" class="font-medium text-emerald-400 hover:underline">Log in here</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "components/macros.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}User Management - Stick{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="grow py-12 px-4 sm:px-6 lg:px-8 max-w-5xl 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">User Management</h1>
|
||||||
|
<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/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>
|
||||||
|
<span class="text-xs font-semibold px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-slate-300">
|
||||||
|
Total Users: {{ users.len() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User List -->
|
||||||
|
<div class="bg-[#1e293b]/40 backdrop-blur-xl border border-slate-900 rounded-3xl p-6 shadow-xl overflow-hidden">
|
||||||
|
<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">
|
||||||
|
<th scope="col" class="px-6 py-4">Username</th>
|
||||||
|
<th scope="col" class="px-6 py-4">Role</th>
|
||||||
|
<th scope="col" class="px-6 py-4">Created At</th>
|
||||||
|
<th scope="col" class="px-6 py-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{% if users.is_empty() %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-slate-500">
|
||||||
|
No registered users found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
{% for user_item in users %}
|
||||||
|
<tr class="hover:bg-[#1e293b]/20 transition duration-150">
|
||||||
|
<td class="px-6 py-4.5 whitespace-nowrap font-medium text-slate-200">
|
||||||
|
{{ user_item.username }}
|
||||||
|
{% if user_item.username == username %}
|
||||||
|
<span class="ml-2 text-[10px] bg-sky-500/10 text-sky-400 border border-sky-500/20 px-2 py-0.5 rounded-full font-bold">You</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4.5 whitespace-nowrap">
|
||||||
|
{% if user_item.is_admin %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
Administrator
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-900 text-slate-400 border border-slate-800">
|
||||||
|
Standard User
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4.5 whitespace-nowrap text-slate-400 text-xs">
|
||||||
|
{{ user_item.created_at.format("%B %d, %Y at %H:%M UTC") }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4.5 whitespace-nowrap text-right text-xs font-medium">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<a href="/auth/users/{{ user_item.id.unwrap().to_hex() }}/edit" class="p-2 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 border border-sky-500/20 text-sky-400 transition" title="Edit User">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.83 20.013a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if user_item.username != username %}
|
||||||
|
<form action="/auth/users/{{ user_item.id.unwrap().to_hex() }}/delete" method="post" class="inline" onsubmit="return confirm('Are you sure you want to permanently delete user \'{{ user_item.username }}\'?');">
|
||||||
|
<button type="submit" class="p-2 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/20 text-rose-400 transition" title="Delete User">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.34 9m-4.72 0-.34-9m9.03-3.03-.58 17.5A2.25 2.25 0 0 1 17.11 21H6.9a2.25 2.25 0 0 1-2.18-2.13L4.1 6.57m3.07-7.94h14.98m-14.98 0A1.75 1.75 0 0 1 7.25 1.5H16.75A1.75 1.75 0 0 1 18 3m-12 0h12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="p-2 rounded-lg opacity-30 text-slate-600 border border-slate-800 cursor-not-allowed" title="You cannot delete yourself">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.34 9m-4.72 0-.34-9m9.03-3.03-.58 17.5A2.25 2.25 0 0 1 17.11 21H6.9a2.25 2.25 0 0 1-2.18-2.13L4.1 6.57m3.07-7.94h14.98m-14.98 0A1.75 1.75 0 0 1 7.25 1.5H16.75A1.75 1.75 0 0 1 18 3m-12 0h12" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
+2
-5
@@ -60,9 +60,9 @@
|
|||||||
Developers
|
Developers
|
||||||
</a>
|
</a>
|
||||||
<div class="h-4 w-px bg-secondary"></div>
|
<div class="h-4 w-px bg-secondary"></div>
|
||||||
<span class="text-xs font-semibold px-2.5 py-1 rounded-full bg-secondary border border-border text-sky-400">
|
<a href="/auth/password" class="text-xs font-semibold px-2.5 py-1.5 rounded-xl bg-secondary border border-border text-sky-400 hover:text-sky-300 hover:bg-secondary hover:border-sky-500/20 transition" title="Change Password">
|
||||||
{{ username }}
|
{{ username }}
|
||||||
</span>
|
</a>
|
||||||
<form action="/auth/logout" method="post" class="inline">
|
<form action="/auth/logout" method="post" class="inline">
|
||||||
<button type="submit" class="text-sm font-medium text-rose-400 hover:text-rose-300 transition py-2 px-3 rounded-lg hover:bg-rose-950/20">
|
<button type="submit" class="text-sm font-medium text-rose-400 hover:text-rose-300 transition py-2 px-3 rounded-lg hover:bg-rose-950/20">
|
||||||
Logout
|
Logout
|
||||||
@@ -72,9 +72,6 @@
|
|||||||
<a href="/auth/login" class="text-sm font-medium text-muted-foreground hover:text-white transition py-2 px-3 rounded-lg hover:bg-secondary">
|
<a href="/auth/login" class="text-sm font-medium text-muted-foreground hover:text-white transition py-2 px-3 rounded-lg hover:bg-secondary">
|
||||||
Log In
|
Log In
|
||||||
</a>
|
</a>
|
||||||
<a href="/auth/register" class="text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition px-4 py-2 rounded-xl shadow-md shadow-indigo-600/10">
|
|
||||||
Sign Up
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user