adminx 2.0.1

A powerful, framework-neutral admin-panel framework for Rust: one resource definition served over Actix or Axum, backed by PostgreSQL/MySQL/SQLite (SeaORM) or MongoDB, with auto CRUD, a rendered admin UI, JWT auth and RBAC.
Documentation

adminx

One Resource definition → a complete admin panel. Any web framework, any database.

A framework-neutral admin-panel framework for Rust. Write a resource once and serve it over Actix Web or Axum, backed by PostgreSQL · MySQL · SQLite (SeaORM) or MongoDB. The logic lives in a neutral core; the frameworks and databases are thin, swappable adapters — switching either is a one-line change and your resource code never moves.

Per resource, with almost no boilerplate, you get:

  • 🧩  Auto CRUD — a REST/JSON API and a rendered HTML admin UI
  • 🔍  List filters — text, select, boolean, and date-range (collapsible sidebar)
  • 🔐  Auth + RBAC — JWT-in-cookie login, role-gated routes
  • 🔒  MFA — TOTP (authenticator apps) with one-time backup codes
  • ⚡  Custom actions, CSV/JSON export, pagination, sorting, soft-delete
  • 🌱  Seeding and admin-user creation from a CLI or from code
  • 🔄  The same resource code on Actix or Axum, over SQL or Mongo

Contents


Install

Depend on the single adminx facade and pick one framework (actix or axum) and one storage (seaorm or mongo) via features. Crates you don't select are never compiled.

[dependencies]
adminx      = { version = "2", features = ["axum", "seaorm"] }
tokio       = { version = "1", features = ["full"] }
axum        = "0.8"          # the framework you chose
serde_json  = "1"
async-trait = "0.1"
You want features = [...] web dep
Axum + Postgres/MySQL/SQLite ["axum", "seaorm"] axum = "0.8"
Actix + Postgres/MySQL/SQLite ["actix", "seaorm"] actix-web = "4"
Axum + MongoDB ["axum", "mongo"] axum = "0.8"
Actix + MongoDB ["actix", "mongo"] actix-web = "4"

You do not add sea-orm or mongodb yourself — the storage adapter wraps the driver. During local development use a path dep: adminx = { path = "../crates/adminx-suite/adminx", features = [...] }.


How to use

Three steps: define a resource, wire a main, open the panel.

1. Define a resource

A resource maps a table/collection to an admin screen. Four methods are required; everything else has a default.

use adminx::prelude::*;
use async_trait::async_trait;

#[derive(Clone)]
pub struct PostResource;

#[async_trait]
impl Resource for PostResource {
    fn resource_name(&self) -> &'static str { "Posts" }     // display name
    fn base_path(&self)     -> &'static str { "posts" }     // URL segment
    fn table_name(&self)    -> &'static str { "posts" }     // SQL table / Mongo collection
    fn clone_box(&self) -> Box<dyn Resource> { Box::new(self.clone()) }

    // Columns editable on create/edit forms:
    fn permit_keys(&self) -> Vec<&'static str> { vec!["title", "body", "published"] }
}

That already gives you a list view, detail page, create/edit forms, a JSON API, CSV/JSON export, and role-gated auth — all generated.

2. Wire main (Axum + SQLite here)

Put this in the same src/main.rs as the PostResource above. Cargo deps: adminx = { version = "2", features = ["axum", "seaorm"] }, plus tokio (features = ["full"]), axum = "0.8", async-trait, serde_json. SQLite needs no database server, so this runs as-is with cargo run.

use adminx::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Storage: connect + create tables (SQLite needs no server).
    let store = adminx::seaorm::connect("sqlite://admin.db?mode=rwc").await?;
    store.execute_sql("CREATE TABLE IF NOT EXISTS posts (\
        id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT, \
        published BOOLEAN NOT NULL DEFAULT 0)").await?;
    store.execute_sql("CREATE TABLE IF NOT EXISTS adminx_users (\
        id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, \
        encrypted_password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', \
        mfa_enabled BOOLEAN NOT NULL DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT)").await?;
    set_storage(Box::new(store));

    // Auth: sign cookies, seed an admin.
    configure_auth(AuthConfig {
        jwt_secret: std::env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".into()),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),
        secure_cookie: false,          // true behind HTTPS
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;

    // Register resources, mount the panel at /adminx.
    register_resource(Box::new(PostResource));

    let app = axum::Router::new().nest("/adminx", adminx::axum::router());
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

3. Open the panel

Visit http://localhost:8080/adminx and sign in with admin@example.com / changeme. First login prompts the (skippable) MFA setup.

Auth is optional: until configure_auth(..) is called, every page is public — handy for a quick look.


Examples

The same Resource trait scales from one line to a fully customized screen.

Minimal — one text input per permitted column:

#[async_trait]
impl Resource for Categories {
    fn resource_name(&self) -> &'static str { "Categories" }
    fn base_path(&self)     -> &'static str { "categories" }
    fn table_name(&self)    -> &'static str { "categories" }
    fn clone_box(&self) -> Box<dyn Resource> { Box::new(self.clone()) }
    fn permit_keys(&self) -> Vec<&'static str> { vec!["name", "slug"] }
}

Custom form + sidebar grouping:

#[async_trait]
impl Resource for Products {
    fn resource_name(&self) -> &'static str { "Products" }
    fn base_path(&self)     -> &'static str { "products" }
    fn table_name(&self)    -> &'static str { "products" }
    fn clone_box(&self) -> Box<dyn Resource> { Box::new(self.clone()) }
    fn menu_group(&self)    -> Option<&'static str> { Some("Shop") }   // sidebar section
    fn permit_keys(&self) -> Vec<&'static str> { vec!["name","sku","price_cents","active"] }

    fn form_structure(&self) -> Option<serde_json::Value> {
        Some(serde_json::json!({ "groups": [{ "title": "Product", "fields": [
            { "name": "name",        "field_type": "text",     "label": "Name" },
            { "name": "sku",         "field_type": "text",     "label": "SKU" },
            { "name": "price_cents", "field_type": "number",   "label": "Price (cents)" },
            { "name": "active",      "field_type": "checkbox", "label": "Active" }
        ]}]}))
    }
}

With list filters (adds the collapsible filter sidebar — see Filters):

fn filterable_fields(&self) -> Vec<FilterField> {
    vec![
        FilterField::text("name", "Name"),          // case-insensitive contains
        FilterField::boolean("active", "Active"),    // Yes / No
        FilterField::date_range("created_at", "Created"),
    ]
}

Role-gated — only these roles can reach it:

fn allowed_roles(&self) -> Vec<String> { vec!["admin".into(), "editor".into()] }

Mongo — collections are schemaless, and the key is _id:

fn table_name(&self)  -> &'static str { "posts" }   // collection
fn primary_key(&self) -> &'static str { "_id" }     // <-- Mongo only

How to seed

Populate tables/collections with starter data. Three ways, all idempotent when you write your statements that way (ON CONFLICT DO NOTHING, etc.).

From the CLI (recommended)

Install the CLI once, then seed by pointing DATABASE_URL (SQL) or MONGO_URL (Mongo) at your database. One statement per line; blank lines and -- / # comments are ignored.

cargo install adminx --features cli

SQL (seeds.sql):

INSERT INTO categories (name, slug) VALUES ('Books','books') ON CONFLICT (slug) DO NOTHING
INSERT INTO products (name, sku, price_cents, active) VALUES ('Rust Book','SKU-RB',3999,true) ON CONFLICT (sku) DO NOTHING
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mydb adminx seed --file seeds.sql

Mongo — each line is a JSON command document (seeds.json):

{"insert":"categories","documents":[{"name":"Books","slug":"books"}]}
{"insert":"products","documents":[{"name":"Rust Book","sku":"SKU-RB","active":true}]}
MONGO_URL=mongodb://127.0.0.1:27017 MONGO_DB=mydb adminx seed --file seeds.json
# or pipe it
echo '{"insert":"products","documents":[{"name":"Widget"}]}' | MONGO_URL=... adminx seed

From code, per backend

// SeaORM — SQL statements. Connects and runs them; returns rows affected.
adminx::seaorm::seed("postgres://user:pass@localhost/mydb", &[
    "INSERT INTO categories (name, slug) VALUES ('Books','books') ON CONFLICT DO NOTHING",
]).await?;

// Mongo — JSON command documents.
adminx::mongo::seed("mongodb://localhost:27017", "mydb", &[
    r#"{"insert":"categories","documents":[{"name":"Books","slug":"books"}]}"#,
]).await?;

From code, after set_storage (backend-neutral)

Once a backend is registered, adminx::seed runs against whichever one is active — SQL strings on SeaORM, JSON command docs on Mongo:

adminx::seed(&[
    "INSERT INTO categories (name, slug) VALUES ('Books','books') ON CONFLICT DO NOTHING",
]).await?;

adminx create-admin (below) seeds the admin user the same way — from the CLI or create_admin(email, password, role) in code.


Implement on every stack

The Resource is identical across all four combos — only main changes (and, for Mongo, primary_key() -> "_id"). Select the stack with Cargo features from Install.

Axum + SeaORM

features = ["axum", "seaorm"], dep axum = "0.8".

use adminx::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let store = adminx::seaorm::connect("postgres://user:pass@localhost/mydb").await?;
    // ... execute_sql(CREATE TABLE ...) as needed, or skip if tables exist ...
    set_storage(Box::new(store));

    configure_auth(AuthConfig {
        jwt_secret: std::env::var("JWT_SECRET").expect("set JWT_SECRET"),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),
        secure_cookie: true,
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;
    register_resource(Box::new(PostResource));

    let app = axum::Router::new().nest("/adminx", adminx::axum::router());
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Swap the URL for another SQL dialect — postgres://…, mysql://…, or sqlite://file.db?mode=rwc. If your tables already exist, skip execute_sql and use adminx::seaorm::init(url).await? (connect + register in one call).

Actix + SeaORM

features = ["actix", "seaorm"], dep actix-web = "4".

use adminx::prelude::*;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    adminx::seaorm::init("postgres://user:pass@localhost/mydb").await.unwrap();

    configure_auth(AuthConfig {
        jwt_secret: std::env::var("JWT_SECRET").expect("set JWT_SECRET"),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),
        secure_cookie: true,
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;
    register_resource(Box::new(PostResource));

    actix_web::HttpServer::new(|| {
        actix_web::App::new().service(adminx::actix::scope())   // mounts at /adminx
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Axum + MongoDB

features = ["axum", "mongo"]. Mongo is schemaless (no DDL), key is _id:

use adminx::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    adminx::mongo::init("mongodb://localhost:27017", "mydb").await?;   // connect + register

    configure_auth(AuthConfig {
        jwt_secret: std::env::var("JWT_SECRET").expect("set JWT_SECRET"),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),   // a Mongo collection
        secure_cookie: true,
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;
    register_resource(Box::new(PostResource));   // its primary_key() returns "_id"

    let app = axum::Router::new().nest("/adminx", adminx::axum::router());
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Actix + MongoDB

features = ["actix", "mongo"]. Combine the Actix main (above) with Mongo setup:

use adminx::prelude::*;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    adminx::mongo::init("mongodb://localhost:27017", "mydb").await.unwrap();

    configure_auth(AuthConfig {
        jwt_secret: std::env::var("JWT_SECRET").expect("set JWT_SECRET"),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),
        secure_cookie: true,
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;
    register_resource(Box::new(PostResource));   // primary_key() -> "_id"

    actix_web::HttpServer::new(|| {
        actix_web::App::new().service(adminx::actix::scope())
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Runnable references live in projects/demos/axumtestsql and projects/demos/actixtestsql (SQL-only, self-contained), plus adminx-demo (Axum + SQLite).


Usage examples

Create an admin user (CLI — backend chosen from the environment):

DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mydb \
  EMAIL=admin@example.com PASSWORD=changeme adminx create-admin

MONGO_URL=mongodb://127.0.0.1:27017 MONGO_DB=mydb \
  EMAIL=admin@example.com PASSWORD=changeme adminx create-admin

Filter the list (query string mirrors the UI sidebar):

/adminx/products/list?name=rust                 # text contains
/adminx/products/list?active=false              # boolean
/adminx/products/list?created_at_from=2024-01-01&created_at_to=2024-12-31

Export the current (optionally filtered) list:

/adminx/products/list?download=csv
/adminx/products/list?download=json&active=true

Use the JSON API (relative to /adminx):

curl /adminx/products/api?page=1&per_page=25&sort=-created_at   # list
curl -X POST /adminx/products/api  -d '{"name":"Widget","sku":"W1"}'
curl -X PUT  /adminx/products/api/5 -d '{"active":false}'
curl -X DELETE /adminx/products/api/5

Run a custom action on a record:

POST /adminx/orders/{id}/action/refund

Full example (single file)

A complete, copy-paste project — Axum + SQLite, with a filtered resource, seeded rows, and auth. No database server needed: cargo run, then open http://localhost:8080/adminx and sign in with admin@example.com / changeme.

Cargo.toml:

[package]
name = "adminx-quickstart"
version = "0.1.0"
edition = "2021"

[dependencies]
adminx      = { version = "2", features = ["axum", "seaorm"] }
tokio       = { version = "1", features = ["full"] }
axum        = "0.8"
async-trait = "0.1"
serde_json  = "1"

src/main.rs:

use adminx::prelude::*;
use async_trait::async_trait;

#[derive(Clone)]
struct Products;

#[async_trait]
impl Resource for Products {
    fn resource_name(&self) -> &'static str { "Products" }
    fn base_path(&self)     -> &'static str { "products" }
    fn table_name(&self)    -> &'static str { "products" }
    fn clone_box(&self) -> Box<dyn Resource> { Box::new(self.clone()) }
    fn menu_group(&self)    -> Option<&'static str> { Some("Shop") }
    fn permit_keys(&self) -> Vec<&'static str> { vec!["name", "sku", "active"] }

    fn filterable_fields(&self) -> Vec<FilterField> {
        vec![
            FilterField::text("name", "Name"),
            FilterField::boolean("active", "Active"),
            FilterField::date_range("created_at", "Created"),
        ]
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Storage + tables (SQLite: no server needed).
    let store = adminx::seaorm::connect("sqlite://quickstart.db?mode=rwc").await?;
    store.execute_sql("CREATE TABLE IF NOT EXISTS products (\
        id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sku TEXT UNIQUE, \
        active BOOLEAN NOT NULL DEFAULT 1, \
        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)").await?;
    store.execute_sql("CREATE TABLE IF NOT EXISTS adminx_users (\
        id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, \
        encrypted_password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', \
        mfa_enabled BOOLEAN NOT NULL DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT)").await?;
    set_storage(Box::new(store));

    // 2. Seed a couple of rows (idempotent via the UNIQUE sku).
    adminx::seed(&[
        "INSERT INTO products (name, sku) VALUES ('Rust Book','SKU-RB') ON CONFLICT DO NOTHING",
        "INSERT INTO products (name, sku) VALUES ('Desk Lamp','SKU-LAMP') ON CONFLICT DO NOTHING",
    ]).await?;

    // 3. Auth + seed an admin.
    configure_auth(AuthConfig {
        jwt_secret: "dev-secret-change-me".into(),
        token_ttl_secs: 86_400,
        admin_table: "adminx_users".into(),
        secure_cookie: false,           // local http
    });
    let _ = create_admin("admin@example.com", "changeme", "admin").await;

    // 4. Register resources + serve.
    register_resource(Box::new(Products));
    let app = axum::Router::new().nest("/adminx", adminx::axum::router());
    println!("adminx → http://localhost:8080/adminx  (admin@example.com / changeme)");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

Swap two lines to move to production: change the connect URL to postgres://… (and the CREATE TABLE to Postgres DDL, e.g. SERIAL PRIMARY KEY), or to Mongo with adminx::mongo::init(uri, db) and primary_key() -> "_id".


Reference

The Resource trait

Four methods are required; the rest have defaults you override to customize.

Method Required Default / purpose
resource_name() display name (e.g. "Posts")
base_path() URL segment (e.g. "posts")
table_name() SQL table / Mongo collection
clone_box() Box::new(self.clone())
primary_key() "id" (use "_id" for Mongo)
permit_keys() [] — columns settable on create/update
readonly_keys() ["id","created_at","updated_at"]
allowed_roles() ["admin"] — RBAC gate
menu_group() / menu() sidebar grouping / label
form_structure() custom form (else derived from permit_keys)
filterable_fields() [] — list filters (see below)
custom_actions() [] — id-scoped actions
soft_delete() true when "deleted" is permitted
list/get/create/update/delete full default CRUD via Storage
list_page/new_page/edit_page/view_page full default HTML pages

Overrides return the neutral ApiResponse, so they keep working on both frameworks.

Customizing the form

Without form_structure(), the create/edit form is one text input per permit_keys(). Provide one to control labels and field types:

fn form_structure(&self) -> Option<serde_json::Value> {
    Some(serde_json::json!({ "groups": [{ "title": "Post", "fields": [
        { "name": "title",     "field_type": "text",     "label": "Title" },
        { "name": "body",      "field_type": "textarea", "label": "Body" },
        { "name": "published", "field_type": "checkbox", "label": "Published" }
    ]}]}))
}

Field types: text, number, email, password, textarea, checkbox (any HTML input type works for the plain case).

Filters

Declare filterable_fields() and adminx renders a collapsible filter sidebar on the list page (hidden by default; a "Filters" button toggles it, and it opens automatically when a filter is active). Filters apply on both storage backends and also constrain CSV/JSON export.

fn filterable_fields(&self) -> Vec<FilterField> {
    vec![
        FilterField::text("name", "Name"),                 // case-insensitive contains
        FilterField::boolean("active", "Active"),           // Yes / No (exact)
        FilterField::select("status", "Status", vec![       // dropdown (exact)
            FilterOption::new("paid", "Paid"),
            FilterOption::new("pending", "Pending"),
        ]),
        FilterField::date_range("created_at", "Created"),   // from / to (>= and <=)
    ]
}
Kind Match Query params
text case-insensitive substring ?field=value
select / boolean exact ?field=value
date_range >= from AND <= to ?field_from=YYYY-MM-DD&field_to=YYYY-MM-DD

A bare to date (YYYY-MM-DD) covers the whole day (extended to 23:59:59). FilterField / FilterKind / FilterOption come from the prelude. On Mongo, contains becomes a case-insensitive $regex and a date range becomes {$gte,$lte}.

Custom actions

Id-scoped buttons on the detail page that POST to /{base}/{id}/action/{name}. Declare them by returning CustomActions from custom_actions(), each with its own async handler (CustomAction / ActionFuture are in the prelude); see adminx-core/src/actions.rs.

Authentication & RBAC

adminx has built-in login: a signed JWT (HS256) in an HttpOnly cookie — no server-side session, so it behaves identically on Actix and Axum. Set it up in four steps.

1. Create the admin table (see Admin-users table), or let adminx create-admin create it for you on SeaORM.

2. Configure auth once at startup:

configure_auth(AuthConfig {
    jwt_secret: std::env::var("JWT_SECRET").unwrap(),  // openssl rand -hex 32
    token_ttl_secs: 86_400,               // cookie/JWT lifetime (24h)
    admin_table: "adminx_users".into(),   // table (SQL) or collection (Mongo)
    secure_cookie: true,                  // HTTPS only — false for local http
});
Field Meaning
jwt_secret HS256 signing key — keep it secret; rotating it invalidates all sessions
token_ttl_secs how long a login stays valid, in seconds
admin_table where admin users live
secure_cookie true in production (HTTPS); false for local http://

3. Seed an admin (once) — in code or via the CLI:

create_admin("admin@example.com", "changeme", "admin").await?;   // bcrypt-hashed

4. Log in. adminx adds GET/POST /adminx/login and GET /adminx/logout, and enforces access automatically:

  • Unauthenticated UI page → 303 redirect to /adminx/login.
  • Unauthenticated API request → 401.
  • A resource is reachable only by principals holding one of its allowed_roles().

Roles (RBAC). Each resource declares who may open it; the role comes from the admin user's role column and travels in the JWT:

fn allowed_roles(&self) -> Vec<String> { vec!["admin".into(), "editor".into()] }

Auth is opt-in. Until configure_auth(..) is called, every page is public — handy while prototyping. Call it (and seed an admin) to lock things down.

Protecting the panel with HTTP Basic Auth

The JWT login above is the main gate. If you also want a coarse HTTP Basic prompt in front of the whole panel — e.g. to hide a staging deployment behind a browser username/password — wrap the mounted routes with framework middleware and read the credentials from an env var so it's easy to toggle.

Axum (add base64 = "0.22"):

use axum::{extract::Request, http::{header, StatusCode},
           middleware::{self, Next}, response::{IntoResponse, Response}};
use base64::{engine::general_purpose::STANDARD, Engine};

async fn basic_gate(req: Request, next: Next) -> Response {
    let want = std::env::var("PANEL_BASIC_AUTH").ok();   // "user:pass"; unset = off
    let ok = match &want {
        None => true,
        Some(creds) => req.headers().get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|h| h.strip_prefix("Basic "))
            .and_then(|b| STANDARD.decode(b).ok())
            .and_then(|d| String::from_utf8(d).ok())
            .is_some_and(|got| &got == creds),
    };
    if ok {
        next.run(req).await
    } else {
        (StatusCode::UNAUTHORIZED,
         [(header::WWW_AUTHENTICATE, r#"Basic realm="adminx""#)],
         "Unauthorized").into_response()
    }
}

// Gate only the panel:
let panel = adminx::axum::router().route_layer(middleware::from_fn(basic_gate));
let app = axum::Router::new().nest("/adminx", panel);

Actix — do the same header check in a Transform middleware and wrap the scope: App::new().service(adminx::actix::scope().wrap(YourBasicAuth)).

Multi-factor auth (MFA)

adminx ships TOTP two-factor auth (Google Authenticator, Authy, 1Password, …) on top of the password login. No extra config — just the three mfa_* columns in the admin-users table.

The JWT carries an MFA step — ok or pending (a pending session can reach only the MFA pages):

                        ┌─ mfa_enabled = false ─→  /adminx/mfa/setup   (skippable prompt)
POST /adminx/login  ───►┤
  (password ok)         └─ mfa_enabled = true  ─→  /adminx/mfa/verify  (enforced)
  • Not enabled → logged in, but nudged to /adminx/mfa/setup (QR + secret). Confirming a code enables MFA and shows 10 one-time backup codes once. The prompt is skippable.

  • Enabled → login yields a pending session that must submit a TOTP or a backup code at /adminx/mfa/verify; a used backup code is consumed.

  • Details: SHA-1 / 6 digits / 30 s step / ±1 skew; backup codes stored bcrypt-hashed; tokens issued before MFA existed decode as ok (backward compatible).

The adminx CLI

cargo install adminx --features cli
Command Purpose
adminx create-admin Create an admin user (idempotent). Flags -e/--email, -p/--password, -r/--role, or env EMAIL/PASSWORD/ROLE.
adminx seed --file <path> Run seed statements (SQL for SeaORM, JSON commands for Mongo). Reads stdin if --file is omitted.

Backend is chosen from the environment: DATABASE_URL → SeaORM, or MONGO_URL + MONGO_DB → Mongo. SeaORM create-admin auto-creates the adminx_users table (with MFA columns) if missing.

Admin-users table

The admin table/collection needs id, email, encrypted_password (bcrypt), role, plus three columns for MFA:

CREATE TABLE adminx_users (
    id                 SERIAL PRIMARY KEY,        -- INTEGER AUTOINCREMENT on SQLite
    email              TEXT NOT NULL UNIQUE,
    encrypted_password TEXT NOT NULL,             -- bcrypt hash
    role               TEXT NOT NULL DEFAULT 'admin',
    mfa_enabled        BOOLEAN NOT NULL DEFAULT false,  -- 0 on SQLite
    mfa_secret         TEXT,                      -- base32 TOTP secret
    mfa_backup_codes   TEXT                       -- JSON array of bcrypt-hashed codes
);

Already have the table? Add the MFA columns without recreating it:

ALTER TABLE adminx_users
    ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS mfa_secret TEXT,
    ADD COLUMN IF NOT EXISTS mfa_backup_codes TEXT;

On Mongo the collection is schemaless — no DDL needed.

Managing admin users

create_admin (and adminx create-admin) is idempotent — it skips an email that already exists. To change something afterwards, run raw statements against the live database with adminx seed (SQL, or Mongo command docs).

Reset a password — delete the user, then recreate (passwords are bcrypt-hashed, so a plain SQL UPDATE can't set one):

# SQL
echo "DELETE FROM adminx_users WHERE email='admin@example.com'" \
  | DATABASE_URL=postgres://user:pass@host/db adminx seed
DATABASE_URL=postgres://user:pass@host/db \
  EMAIL=admin@example.com PASSWORD=newpass adminx create-admin

# Mongo
echo '{"delete":"adminx_users","deletes":[{"q":{"email":"admin@example.com"},"limit":1}]}' \
  | MONGO_URL=mongodb://host:27017 MONGO_DB=db adminx seed
MONGO_URL=mongodb://host:27017 MONGO_DB=db \
  EMAIL=admin@example.com PASSWORD=newpass adminx create-admin

Change a role (plain column, no hashing):

echo "UPDATE adminx_users SET role='editor' WHERE email='admin@example.com'" \
  | DATABASE_URL=... adminx seed

Locked out by MFA? Clear the user's MFA to force the setup prompt again:

# SQL
echo "UPDATE adminx_users SET mfa_enabled=false, mfa_secret=NULL, mfa_backup_codes=NULL \
  WHERE email='admin@example.com'" | DATABASE_URL=... adminx seed

# Mongo
echo '{"update":"adminx_users","updates":[{"q":{"email":"admin@example.com"},"u":{"$set":{"mfa_enabled":false,"mfa_secret":null,"mfa_backup_codes":null}}}]}' \
  | MONGO_URL=... MONGO_DB=... adminx seed

CSV / JSON export

Every list exports without extra code (and honours active filters):

  • GET /adminx/{base}/list?download=csv
  • GET /adminx/{base}/list?download=json

Also available as buttons on the list page. Capped at 10,000 rows; CSV values are RFC-escaped.

REST API surface

Per resource, relative to the mount (/adminx):

Route Method Purpose
/{base}/api GET List — ?page=, ?per_page= (≤200), ?sort=col / ?sort=-col, plus filters
/{base}/api POST Create (JSON body)
/{base}/api/{id} GET / PUT / DELETE Get / update / delete
/{base}/{id}/action/{name} POST Custom action
/health GET DB connectivity probe

HTML admin UI

Every resource also gets a Tera-rendered UI (dark-mode aware, TailwindCSS), served identically by both adapters. Record data is autoescaped (XSS-safe).

Route Purpose
/ Dashboard (auto menu of registered resources)
/{base}/list Table + pagination + filters + View/Edit/Delete + Export
/{base}/new, /{base}/edit/{id} Create / edit form
/{base}/view/{id} Record detail + custom-action buttons
/login, /logout, /mfa/setup, /mfa/verify Auth + MFA pages

Environment variables

Conventions used by the demos and the CLI; your app decides what to read.

Var Purpose
DATABASE_URL SeaORM URL: postgres://…, mysql://…, sqlite://file.db?mode=rwc
MONGO_URL + MONGO_DB MongoDB connection + database
JWT_SECRET HS256 signing key (openssl rand -hex 32)
PORT listen port
EMAIL / PASSWORD / ROLE inputs for adminx create-admin
ADMINX_EMAIL / ADMINX_PASSWORD seeded admin (demo convention)
ADMINX_SECURE_COOKIE 1 when served over HTTPS

Deployment

adminx compiles into your app's single binary. Ship that binary, run it under systemd, and put nginx in front for TLS. Set secure_cookie: true and a strong JWT_SECRET in production.

1. Build the release binary (locally or on the server):

cargo build --release        # → target/release/myapp

2. Place the binary + environment on the server:

sudo mkdir -p /opt/myapp
sudo cp target/release/myapp /opt/myapp/
sudo tee /opt/myapp/app.env >/dev/null <<'ENV'
JWT_SECRET=REPLACE_WITH_openssl_rand_-hex_32
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/mydb
PORT=8080
ADMINX_SECURE_COOKIE=1
ENV
sudo chmod 600 /opt/myapp/app.env

Your main reads these — e.g. set AuthConfig { secure_cookie: true, .. } in production so the session cookie is HTTPS-only.

3. systemd unit/etc/systemd/system/myapp.service:

[Unit]
Description=My adminx app
After=network.target postgresql.service

[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/myapp
EnvironmentFile=/opt/myapp/app.env
ExecStart=/opt/myapp/myapp
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp        # check it's listening on 127.0.0.1:8080

4. nginx reverse proxy + TLS/etc/nginx/sites-available/myapp:

server {
    listen 80;
    server_name admin.example.com;
    return 301 https://$host$request_uri;      # force HTTPS
}

server {
    listen 443 ssl;
    server_name admin.example.com;

    ssl_certificate     /etc/letsencrypt/live/admin.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;   # tells the app it's on TLS
    }
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d admin.example.com     # obtain/renew the TLS cert

Your panel is now at https://admin.example.com/adminx. Update after a new build with cargo build --release, copy the binary, and sudo systemctl restart myapp.

Hardening tips: keep the app bound to 127.0.0.1 (only nginx faces the internet), add an HTTP Basic gate or an nginx allow/deny IP allow-list for staging, and rotate JWT_SECRET to invalidate all sessions. adminx-demo has a fuller AWS EC2 walkthrough in adminx-demo/README.md.

Troubleshooting

Symptom Cause / fix
"Invalid email or password" with the right password The admin was created in a different database than the app reads. Match DATABASE_URL / MONGO_DB to the running app.
Login loops or lands on /adminx/mfa/verify and you're stuck MFA is on and the authenticator is lost — clear it via SQL/Mongo (see Managing admin users).
Every page is public, never asks to log in configure_auth(..) wasn't called — auth is opt-in.
Login "succeeds" but bounces back to /login secure_cookie: true while on plain http:// — the browser drops the cookie. Use secure_cookie: false for local http.
Mongo: View/Edit links or updates target the wrong record Add fn primary_key(&self) -> &'static str { "_id" } to the resource.
SeaORM: "relation … does not exist" The table wasn't created — run your CREATE TABLE/seed. adminx doesn't migrate your app tables.
Publish: "no matching package named adminx-core" Publish dependencies first: adminx-core → adapters → adminx. Each must be on crates.io before the next resolves.

Status

Complete and tested: neutral core, SeaORM (PostgreSQL/MySQL/SQLite) + MongoDB, Actix + Axum (Axum 0.8), dynamic JSON CRUD, Tera HTML UI, JWT-cookie auth + RBAC, TOTP MFA with backup codes, list filters, custom actions, CSV/JSON export, seeding + admin CLI, pagination/sort, health, and the single-name adminx facade.

Roadmap: a switch to make MFA mandatory (today it's a skippable prompt), and backup-code regeneration from an account page.


🌟 Community

GitHub Discussions

Join our growing community of Rust developers building admin panels with AdminX!

📄 License

This project is licensed under the MIT License — see the LICENSE file for details.

🙏 Acknowledgments

🗺️ Roadmap

We are actively building AdminX step by step.
The roadmap includes phases like core CRUD foundation, extended resource features, authentication & RBAC, export/import, custom pages, UI themes, and optional extensions.

👉 See the full roadmap here: ROADMAP.md

Project Status Contributions Welcome

📦 Sample starter template

Made with ❤️ by Srotas Space


👥 Contributors

GitHub stars