adminx
One
Resourcedefinition → 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
- How to use
- Examples
- How to seed
- Implement on every stack
- Usage examples
- Full example (single file)
- Reference
- The
Resourcetrait - Customizing the form
- Filters
- Custom actions
- Authentication & RBAC
- Protecting the panel with HTTP Basic Auth
- Multi-factor auth (MFA)
- The
adminxCLI - Admin-users table
- Managing admin users
- CSV / JSON export
- REST API surface
- HTML admin UI
- Environment variables
- Deployment
- Troubleshooting
- Status
- The
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.
[]
= { = "2", = ["axum", "seaorm"] }
= { = "1", = ["full"] }
= "0.8" # the framework you chose
= "1"
= "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-ormormongodbyourself — 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 *;
use async_trait;
;
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 *;
async
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:
Custom form + sidebar grouping:
With list filters (adds the collapsible filter sidebar — see Filters):
Role-gated — only these roles can reach it:
Mongo — collections are schemaless, and the key is _id:
// collection
// <-- 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.
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
Mongo — each line is a JSON command document (seeds.json):
MONGO_URL=mongodb://127.0.0.1:27017 MONGO_DB=mydb
# or pipe it
| MONGO_URL=...
From code, per backend
// SeaORM — SQL statements. Connects and runs them; returns rows affected.
seed.await?;
// Mongo — JSON command documents.
seed.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:
seed.await?;
adminx create-admin(below) seeds the admin user the same way — from the CLI orcreate_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 *;
async
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 *;
async
Axum + MongoDB
features = ["axum", "mongo"]. Mongo is schemaless (no DDL), key is _id:
use *;
async
Actix + MongoDB
features = ["actix", "mongo"]. Combine the Actix main (above) with Mongo setup:
use *;
async
Runnable references live in
projects/demos/axumtestsqlandprojects/demos/actixtestsql(SQL-only, self-contained), plusadminx-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
MONGO_URL=mongodb://127.0.0.1:27017 MONGO_DB=mydb \
EMAIL=admin@example.com PASSWORD=changeme
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):
&per_page=25&sort=-created_at # list
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:
[]
= "adminx-quickstart"
= "0.1.0"
= "2021"
[]
= { = "2", = ["axum", "seaorm"] }
= { = "1", = ["full"] }
= "0.8"
= "0.1"
= "1"
src/main.rs:
use *;
use async_trait;
;
async
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:
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.
| 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;
| 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.await?; // bcrypt-hashed
4. Log in. adminx adds GET/POST /adminx/login and GET /adminx/logout, and
enforces access automatically:
- Unauthenticated UI page →
303redirect 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:
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 ;
use ;
async
// Gate only the panel:
let panel = router.route_layer;
let app = new.nest;
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
pendingsession 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
| 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:
(
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:
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
| DATABASE_URL=postgres://user:pass@host/db
DATABASE_URL=postgres://user:pass@host/db \
EMAIL=admin@example.com PASSWORD=newpass
# Mongo
| MONGO_URL=mongodb://host:27017 MONGO_DB=db
MONGO_URL=mongodb://host:27017 MONGO_DB=db \
EMAIL=admin@example.com PASSWORD=newpass
Change a role (plain column, no hashing):
| DATABASE_URL=...
Locked out by MFA? Clear the user's MFA to force the setup prompt again:
# SQL
| DATABASE_URL=...
# Mongo
| MONGO_URL=... MONGO_DB=...
CSV / JSON export
Every list exports without extra code (and honours active filters):
GET /adminx/{base}/list?download=csvGET /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):
2. Place the binary + environment on the server:
Your
mainreads these — e.g. setAuthConfig { 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
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
}
}
&&
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
Join our growing community of Rust developers building admin panels with AdminX!
- 📖 Documentation
- 💬 Discussions
- 🐛 Issues
- 📧 Email: info@srotas.space
- 📧 Email: snmmaurya@gmail.com
- 📧 Email: deepxmaurya@gmail.com
📄 License
This project is licensed under the MIT License — see the LICENSE file for details.
🙏 Acknowledgments
- Web frameworks: Actix Web and Axum
- Storage: SeaORM (PostgreSQL / MySQL / SQLite) and MongoDB
- UI: TailwindCSS styling with Tera templates
- Auth: JWT via jsonwebtoken, TOTP via totp-rs
🗺️ 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
📦 Sample starter template
Made with ❤️ by Srotas Space
👥 Contributors
-
Snm Maurya - Creator & Lead Developer LinkedIn
-
Deepak Maurya - Core Developer & Contributor LinkedIn