actix-web-admin
A powerful admin panel library for Actix-web 4 applications that automatically generates CRUD interfaces with a pluggable authentication system.
Features
- 🚀 Auto-generated CRUD operations - Create, Read, Update, Delete interfaces
- 🔐 Pluggable authentication - Implement
UserStore trait (built-in JsonUserStore included)
- 🛠️ CLI tools -
admin-cli binary for user management, reusable cli module
- 🎨 Modern responsive UI - Clean, professional admin interface
- 🔍 Search & pagination - Built-in search and paginated lists
- 📝 Form validation - Automatic form generation with validation
- 🎯 Type-safe - Full Rust type safety with async trait support
- 📦 Easy integration - Implement one trait and you're done
Quick Start
Add to your Cargo.toml:
[dependencies]
actix-web-admin = "0.1.0"
actix-web = "4"
actix-session = { version = "0.9", features = ["cookie-session"] }
tokio = { version = "1", features = ["full"] }
Basic Example
use actix_web::{web, App, HttpServer};
use actix_session::storage::CookieSessionStore;
use actix_session::SessionMiddleware;
use actix_web_admin::auth::{UserStore, JsonUserStore};
use actix_web_admin::{AdminRegistry, AdminSite, AdminResource, init_templates};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use actix_web_admin::types::*;
struct Product { id: String, name: String, price: f64, active: bool }
struct ProductAdmin { db: Arc<Mutex<Vec<Product>>> }
#[async_trait]
impl AdminResource for ProductAdmin {
fn name(&self) -> &str { "Product" }
fn plural_name(&self) -> &str { "Products" }
fn slug(&self) -> &str { "products" }
fn icon(&self) -> &str { "📦" }
fn list_columns(&self) -> Vec<Column> {
vec![Column::text("name", "Name"), Column::number("price", "Price"), Column::boolean("active", "Active")]
}
fn form_fields(&self) -> Vec<FormField> {
vec![
FormField::text("name", "Name").required(),
FormField::number("price", "Price").required(),
]
}
async fn list(&self, query: ListQuery) -> Result<ListResult, AdminError> {
let db = self.db.lock().unwrap();
let rows: Vec<serde_json::Value> = db.iter().map(|p| serde_json::json!({"id": p.id, "name": p.name, "price": p.price, "active": p.active})).collect();
Ok(ListResult { rows, total: rows.len() as u64, page: query.page.unwrap_or(1), per_page: query.per_page.unwrap_or(10) })
}
async fn get(&self, id: &str) -> Result<serde_json::Value, AdminError> {
let db = self.db.lock().unwrap();
db.iter().find(|p| p.id == id).map(|p| serde_json::json!({"id": p.id, "name": p.name, "price": p.price, "active": p.active})).ok_or(AdminError::NotFound)
}
async fn create(&self, data: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, AdminError> {
let mut db = self.db.lock().unwrap();
let id = uuid::Uuid::new_v4().to_string();
let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let price = data.get("price").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()).unwrap_or(0.0);
let active = data.get("active").and_then(|v| v.as_bool()).unwrap_or(true);
db.push(Product { id: id.clone(), name, price, active });
Ok(serde_json::json!({ "id": id }))
}
async fn update(&self, id: &str, data: HashMap<String, serde_json::Value>) -> Result<serde_json::Value, AdminError> {
let mut db = self.db.lock().unwrap();
if let Some(p) = db.iter_mut().find(|p| p.id == id) {
if let Some(v) = data.get("name").and_then(|v| v.as_str()) { p.name = v.to_string(); }
if let Some(v) = data.get("price").and_then(|v| v.as_str()).and_then(|s| s.parse().ok()) { p.price = v; }
if let Some(v) = data.get("active").and_then(|v| v.as_bool()) { p.active = v; }
return Ok(serde_json::json!({ "id": id }));
}
Err(AdminError::NotFound)
}
async fn delete(&self, id: &str) -> Result<(), AdminError> {
let mut db = self.db.lock().unwrap();
let len_before = db.len();
db.retain(|p| p.id != id);
if db.len() < len_before { Ok(()) } else { Err(AdminError::NotFound) }
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let templates = init_templates();
let store = Arc::new(JsonUserStore::new("users.json"));
if store.find_by_username("admin").await.unwrap().is_none() {
store.create_user("admin", "admin@example.com", "Admin", "admin", true).await.unwrap();
}
let mut registry = AdminRegistry::new();
registry.register(ProductAdmin { db: Arc::new(Mutex::new(vec![])) });
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(templates.clone()))
.app_data(web::Data::new(store.clone() as Arc<dyn UserStore>))
.wrap(SessionMiddleware::new(
CookieSessionStore::default(),
actix_web::cookie::Key::generate()
))
.configure(|cfg| {
AdminSite::new("/admin").title("My Admin").with_user_store(store.clone()).mount(cfg, registry)
})
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
Run and visit http://localhost:8080/admin - login with admin/admin.
Authentication
The library uses a UserStore trait for authentication. A JsonUserStore backed by a JSON file is included:
use actix_web_admin::auth::{UserStore, JsonUserStore};
let store = Arc::new(JsonUserStore::new("users.json"));
store.create_user("admin", "admin@example.com", "Admin", "secret", true).await?;
Pass the store to both the app data and AdminSite:
.app_data(web::Data::new(store.clone() as Arc<dyn UserStore>))
.configure(|cfg| {
AdminSite::new("/admin")
.with_user_store(store.clone())
.mount(cfg, registry)
})
The SimpleAuth struct is deprecated — use UserStore instead.
CLI Tools
admin-cli binary
Install the crate and manage users from the command line:
cargo install actix-web-admin
admin-cli createsuperuser admin-cli deleteuser <username> admin-cli listusers admin-cli --file /path/to/users.json
Reusable cli module
Build your own CLI in ~10 lines by using the public cli module:
use actix_web_admin::cli;
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("createsuperuser") => cli::create_superuser_interactive(&args[2..]).await.unwrap(),
Some("deleteuser") => cli::delete_user(&args[2..]).await.unwrap(),
Some("listusers") => cli::list_users(&args[2..]).await.unwrap(),
_ => cli::print_help("my-cli"),
}
}
All CLI functions are async — call them from within a Tokio runtime (#[tokio::main] or #[actix_web::main]).
Configuration
AdminSite
AdminSite::new("/admin") .title("My Admin Panel") .with_user_store(store) .mount(cfg, registry)
Resources
Implement the AdminResource trait:
name() — Singular display name
plural_name() — Plural display name
slug() — URL slug (must be unique)
icon() — Emoji icon for dashboard
list_columns() — Columns for list view
form_fields() — Fields for create/edit forms
- CRUD:
list(), get(), create(), update(), delete()
Field Types
FormField::text("name", "Name").required()
FormField::number("price", "Price")
FormField::boolean("active", "Active")
FormField::select("category", "Category", vec![("a", "A"), ("b", "B")])
FormField::textarea("description", "Description", 4)
Routes
All routes use the configured prefix dynamically:
/admin/ # Dashboard
/admin/login # Login page
/admin/logout # Logout
/admin/{slug}/ # List view
/admin/{slug}/new # Create form
/admin/{slug}/{id} # Edit form
/admin/{slug}/{id}/delete # Delete action
The prefix is stored without a leading slash to avoid double-slash issues (e.g. admin not /admin).
Testing
cargo test
Security Best Practices
This library provides secure defaults (Argon2 hashing, session management, HTML escaping), but the following should be configured in your deployment:
CSRF Protection
Admin CRUD operations modify state via POST requests. Add a CSRF middleware to prevent cross-site request forgery:
actix-csrf = "0.3"
use actix_csrf::Csrf;
use actix_csrf::storage::CookieStore;
App::new()
.wrap(Csrf::new(CookieStore::default()))
Secure Cookies
Configure session cookies for production:
SessionMiddleware::builder(CookieSessionStore::default(), actix_web::cookie::Key::generate())
.cookie_secure(true) .cookie_http_only(true) .cookie_same_site(actix_web::cookie::SameSite::Lax) .build()
Rate Limiting
Prevent brute force attacks on the login endpoint:
actix-governor = "0.6"
use actix_governor::{Governor, GovernorConfigBuilder};
let governor_conf = GovernorConfigBuilder::default()
.per_second(5)
.burst_size(10)
.finish()
.unwrap();
App::new()
.wrap(Governor::new(&governor_conf))
HTTPS
Always use HTTPS in production:
HttpServer::new(move || { })
.bind_openssl("0.0.0.0:443", ssl_builder)?
.run().await
Or terminate TLS at a reverse proxy (nginx, Caddy, etc.).
Resource IDs
The default generate_id() produces sequential IDs. Use UUIDs or ULIDs if enumeration is a concern for your application.
let id = uuid::Uuid::new_v4().to_string();
License
AGPL-3.0 — See LICENSE for details.