actix-web-admin 0.1.1

A powerful admin panel library for Actix-web 4 applications
Documentation

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              # interactive user creation
admin-cli deleteuser <username>         # delete a user
admin-cli listusers                     # list all users
admin-cli --file /path/to/users.json    # specify custom JSON path

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")     // URL prefix (no trailing slash)
    .title("My Admin Panel")  // Site title
    .with_user_store(store)   // UserStore implementation
    .mount(cfg, registry)     // Mount routes

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    # 23+ tests covering auth, middleware, handlers, CLI, resources

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"
// Wrap your App with CSRF protection
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)       // HTTPS only
    .cookie_http_only(true)    // Not accessible via JavaScript
    .cookie_same_site(actix_web::cookie::SameSite::Lax)  // CSRF mitigation
    .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.

// In your AdminResource implementation:
let id = uuid::Uuid::new_v4().to_string();

License

AGPL-3.0 — See LICENSE for details.