use std::sync::Arc;
use bytes::Bytes;
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use http_body_util::{BodyExt, Full};
use crate::error::Error;
use crate::http::{Request, Response};
use crate::orm::{Db, Model};
use crate::router::Router;
pub use crate::http::FormData;
pub mod admin_form_bridge;
pub mod admin_generator;
pub mod audit;
pub mod auto_form;
pub mod design;
pub mod entry_builder;
pub mod form;
pub mod intelligence;
pub mod layout;
pub mod persistence;
pub mod rbac;
pub mod relations;
pub mod schema_cache;
pub mod schema_introspect;
pub mod suggestions;
pub mod templating;
pub mod ui;
#[cfg(test)]
mod admin_intelligence_tests;
#[cfg(test)]
mod relations_tests;
#[cfg(test)]
mod suggestions_tests;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
I32,
I64,
String,
Bool,
DateTime,
}
#[derive(Debug, Clone, Copy)]
pub struct AdminField {
pub name: &'static str,
pub ty: FieldType,
pub editable: bool,
pub nullable: bool,
pub relation: Option<AdminRelation>,
}
#[derive(Debug, Clone, Copy)]
pub struct AdminRelation {
pub kind: crate::schema::RelationKind,
pub model: &'static str,
pub display_field: Option<&'static str>,
}
pub trait AdminModel: Model {
const ADMIN_NAME: &'static str;
const DISPLAY_NAME: &'static str;
const FIELDS: &'static [AdminField];
fn field_display(&self, name: &str) -> Option<String>;
fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
fn singular_name() -> &'static str {
Self::DISPLAY_NAME
}
}
#[derive(Debug, Clone)]
pub struct AdminEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub table: &'static str,
pub fields: &'static [AdminField],
pub core: bool,
}
pub const USER_FIELDS: &[AdminField] = &[
AdminField {
name: "id",
ty: FieldType::I64,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "email",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "password_hash",
ty: FieldType::String,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "is_active",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "role",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "created_at",
ty: FieldType::DateTime,
editable: false,
nullable: false,
relation: None,
},
];
pub(crate) const USER_ENTRY: AdminEntry = AdminEntry {
admin_name: "users",
display_name: "Users",
singular_name: "User",
table: "rustio_users",
fields: USER_FIELDS,
core: true,
};
const ADMIN_CSS_BUNDLE: &str = include_str!("../assets/admin.css");
const ADMIN_CSS_VER: usize = ADMIN_CSS_BUNDLE.len();
const ADMIN_FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#B84318"/><text x="16" y="22" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">R</text></svg>"##;
fn svg(path: &str) -> String {
format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{path}</svg>"#
)
}
fn icon_layers() -> String {
svg(
r#"<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/>"#,
)
}
fn icon_dashboard() -> String {
svg(
r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#,
)
}
fn icon_plus() -> String {
svg(r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#)
}
fn icon_search() -> String {
svg(r#"<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>"#)
}
fn icon_pencil() -> String {
svg(
r#"<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/>"#,
)
}
fn icon_trash() -> String {
svg(
r#"<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>"#,
)
}
fn icon_chevron_right() -> String {
svg(r#"<polyline points="9 18 15 12 9 6"/>"#)
}
fn icon_logout() -> String {
svg(
r#"<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>"#,
)
}
fn icon_shield_alert() -> String {
svg(
r#"<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>"#,
)
}
fn icon_triangle_alert() -> String {
svg(
r#"<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>"#,
)
}
fn icon_inbox() -> String {
svg(
r#"<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>"#,
)
}
fn icon_arrow_left() -> String {
svg(r#"<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>"#)
}
fn icon_activity() -> String {
svg(
r#"<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.95 0L9.24 2.18a.5.5 0 0 0-.95 0L5.94 10.54A2 2 0 0 1 4.01 12H2"/>"#,
)
}
fn icon_home() -> String {
svg(
r#"<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>"#,
)
}
fn icon_bell() -> String {
svg(
r#"<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>"#,
)
}
fn icon_mail() -> String {
svg(
r#"<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>"#,
)
}
type ModelRegistrar = Box<dyn FnOnce(Router, &Db, Arc<Vec<AdminEntry>>) -> Router + Send + Sync>;
pub struct Admin {
entries: Vec<AdminEntry>,
registrars: Vec<ModelRegistrar>,
}
impl Admin {
pub fn new() -> Self {
Self {
entries: vec![USER_ENTRY.clone()],
registrars: Vec::new(),
}
}
pub fn model<T: AdminModel>(mut self) -> Self {
self.entries.push(AdminEntry {
admin_name: T::ADMIN_NAME,
display_name: T::DISPLAY_NAME,
singular_name: T::singular_name(),
table: T::TABLE,
fields: T::FIELDS,
core: false,
});
self.registrars.push(Box::new(|router, db, entries| {
mount_model::<T>(router, db, entries)
}));
self
}
pub fn len(&self) -> usize {
self.entries.iter().filter(|e| !e.core).count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn entries(&self) -> &[AdminEntry] {
&self.entries
}
pub fn register(self, mut router: Router, db: &Db) -> Router {
let entries = Arc::new(self.entries);
let err_entries = entries.clone();
router = router.wrap(move |req, next| {
let entries = err_entries.clone();
async move {
let is_admin = req.uri().path().starts_with("/admin");
if !is_admin {
return next.run(req).await;
}
let path = req.uri().path();
if path == "/admin/assets/admin.css" || path == "/admin/assets/favicon.svg" {
return next.run(req).await;
}
let user_email = req
.ctx()
.get::<crate::auth::Identity>()
.map(|i| i.email.clone());
let csrf = req
.ctx()
.get::<crate::auth::CsrfToken>()
.map(|t| t.0.clone());
let res = next.run(req).await;
match res {
Err(Error::NotFound) => Ok(admin_not_found_response(
&entries,
user_email.as_deref(),
csrf.as_deref(),
)),
Err(Error::Internal(msg)) => {
let req_id = new_request_id();
eprintln!("admin 500 [{req_id}]: {msg}");
Ok(admin_server_error_response(
&entries,
user_email.as_deref(),
csrf.as_deref(),
&req_id,
))
}
other => other,
}
}
});
router = router.get("/admin/assets/admin.css", |_req, _params| async move {
Ok::<Response, Error>(admin_css_response())
});
router = router.get("/admin/assets/favicon.svg", |_req, _params| async move {
Ok::<Response, Error>(admin_favicon_response())
});
for &(path, content_type, bytes) in crate::admin::templating::BUNDLED_ASSETS {
let full_path = format!("/admin/static/{path}");
router = router.get(&full_path, move |_req, _params| async move {
Ok::<Response, Error>(bundled_asset_response(bytes, content_type))
});
}
let admin_new_registry = std::sync::Arc::new({
let mut reg = crate::admin::admin_form_bridge::AdminRegistry::new();
reg.register("users", crate::admin::layout::new_user_admin);
reg
});
let index_db = db.clone();
let index_registry = admin_new_registry.clone();
let index_entries = entries.clone();
router = router.get("/admin", move |req, _params| {
let db = index_db.clone();
let registry = index_registry.clone();
let legacy_entries = index_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = crate::admin::layout::dashboard_render(
&db,
®istry,
legacy_entries.as_slice(),
identity.as_ref(),
csrf.as_deref(),
)
.await;
Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
}
});
let login_db = db.clone();
router = router.post("/admin/login", move |req, _params| {
let db = login_db.clone();
async move { handle_login(req, &db).await }
});
router = router.get("/admin/login", |_req, _params| async move {
Ok::<Response, Error>(login_page(200, None, None))
});
let logout_db = db.clone();
router = router.post("/admin/logout", move |req, _params| {
let db = logout_db.clone();
async move { handle_logout(req, &db).await }
});
router = router.get("/admin/logout", move |req, _params| async move {
let signed_in = req.ctx().get::<crate::auth::Identity>().is_some();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
Ok::<Response, Error>(logout_confirmation_response(signed_in, csrf.as_deref()))
});
let pw_get_entries = entries.clone();
let pw_get_db = db.clone();
let pw_get_registry = admin_new_registry.clone();
router = router.get("/admin/password_change", move |req, _params| {
let legacy_entries = pw_get_entries.clone();
let db = pw_get_db.clone();
let registry = pw_get_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = crate::admin::layout::password_change_render(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
csrf.as_deref(),
None,
)
.await;
Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
}
});
let pw_post_entries = entries.clone();
let pw_post_db = db.clone();
let pw_post_registry = admin_new_registry.clone();
router = router.post("/admin/password_change", move |req, _params| {
let legacy_entries = pw_post_entries.clone();
let db = pw_post_db.clone();
let registry = pw_post_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
handle_password_change_post(req, &db, ®istry, &legacy_entries).await
}
});
let pw_done_entries = entries.clone();
let pw_done_db = db.clone();
let pw_done_registry = admin_new_registry.clone();
router = router.get("/admin/password_change/done", move |req, _params| {
let legacy_entries = pw_done_entries.clone();
let db = pw_done_db.clone();
let registry = pw_done_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = crate::admin::layout::password_change_done_render(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
csrf.as_deref(),
)
.await;
Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
}
});
let profile_entries = entries.clone();
let profile_db = db.clone();
let profile_registry = admin_new_registry.clone();
router = router.get("/admin/profile", move |req, _params| {
let legacy_entries = profile_entries.clone();
let db = profile_db.clone();
let registry = profile_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let user = match identity.as_ref() {
Some(id) => crate::auth::user::find_by_id(&db, id.user_id).await?,
None => None,
};
let html = crate::admin::layout::profile_render(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
user.as_ref(),
csrf.as_deref(),
)
.await;
Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
}
});
let actions_entries = entries.clone();
let actions_db = db.clone();
let actions_registry = admin_new_registry.clone();
router = router.get("/admin/actions", move |req, _params| {
let legacy_entries = actions_entries.clone();
let db = actions_db.clone();
let registry = actions_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let query = req.query();
let model_filter = query
.get("model")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let action_filter = query
.get("action")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let actions =
audit::recent(&db, 200, model_filter.as_deref(), action_filter.as_deref())
.await?;
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = crate::admin::layout::actions_render(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
csrf.as_deref(),
&actions,
model_filter.as_deref(),
action_filter.as_deref(),
)
.await;
Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
}
});
let sugg_get_entries = entries.clone();
let sugg_get_db = db.clone();
let sugg_get_registry = admin_new_registry.clone();
router = router.get("/admin/suggestions/:admin/:field", move |req, params| {
let legacy_entries = sugg_get_entries.clone();
let db = sugg_get_db.clone();
let registry = sugg_get_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let admin_name = params.get("admin").unwrap_or("").to_string();
let field = params.get("field").unwrap_or("").to_string();
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
Ok::<Response, Error>(
suggestion_review_response(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
csrf.as_deref(),
&admin_name,
&field,
None,
)
.await,
)
}
});
let sugg_post_entries = entries.clone();
let sugg_post_db = db.clone();
let sugg_post_registry = admin_new_registry.clone();
router = router.post("/admin/suggestions/:admin/:field", move |req, params| {
let legacy_entries = sugg_post_entries.clone();
let db = sugg_post_db.clone();
let registry = sugg_post_registry.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let admin_name = params.get("admin").unwrap_or("").to_string();
let field = params.get("field").unwrap_or("").to_string();
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let identity = crate::auth::identity(&ctx).cloned();
let csrf = ctx_csrf(&ctx).map(str::to_string);
Ok::<Response, Error>(
suggestion_apply_response(
&db,
®istry,
&legacy_entries,
identity.as_ref(),
csrf.as_deref(),
&admin_name,
&field,
)
.await,
)
}
});
router = router.post("/admin/schema/reload", move |req, _params| async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let redirect_url = match schema_cache::refresh() {
Ok(_) => "/admin?schema_reload=ok",
Err(_) => "/admin?schema_reload=err",
};
Ok::<Response, Error>(with_admin_headers(redirect(redirect_url)))
});
{
let db = db.clone();
let registry = admin_new_registry.clone();
let model_entries = entries.clone();
router =
router.get("/admin/:model", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = model_entries.clone();
async move {
admin_model_index_get(&db, ®istry, &legacy_entries, req, params).await
}
});
}
{
let db = db.clone();
let registry = admin_new_registry.clone();
let form_new_entries = entries.clone();
router = router.get("/admin/:model/new", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = form_new_entries.clone();
async move {
admin_model_form_get(&db, ®istry, &legacy_entries, req, params, None).await
}
});
}
{
let db = db.clone();
let registry = admin_new_registry.clone();
let form_edit_entries = entries.clone();
router = router.get("/admin/:model/:id/edit", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = form_edit_entries.clone();
async move {
let id = params.get("id").map(str::to_string);
admin_model_form_get(
&db,
®istry,
&legacy_entries,
req,
params,
id.as_deref(),
)
.await
}
});
}
{
let db = db.clone();
let registry = admin_new_registry.clone();
let create_entries = entries.clone();
router = router.post("/admin/:model/new", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = create_entries.clone();
async move {
admin_model_create_post(&db, ®istry, &legacy_entries, req, params).await
}
});
}
{
let db = db.clone();
let registry = admin_new_registry.clone();
let update_entries = entries.clone();
router = router.post("/admin/:model/:id/edit", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = update_entries.clone();
async move {
admin_model_update_post(&db, ®istry, &legacy_entries, req, params).await
}
});
}
{
let db = db.clone();
let registry = admin_new_registry.clone();
let delete_entries = entries.clone();
router = router.post("/admin/:model/:id/delete", move |req, params| {
let db = db.clone();
let registry = registry.clone();
let legacy_entries = delete_entries.clone();
async move {
admin_model_delete_post(&db, ®istry, &legacy_entries, req, params).await
}
});
}
for registrar in self.registrars {
router = registrar(router, db, entries.clone());
}
router
}
}
impl Default for Admin {
fn default() -> Self {
Self::new()
}
}
pub fn register<T>(router: Router, db: &Db) -> Router
where
T: AdminModel + Model,
{
Admin::new().model::<T>().register(router, db)
}
fn mount_model<T>(mut router: Router, db: &Db, entries: Arc<Vec<AdminEntry>>) -> Router
where
T: AdminModel + Model,
{
let base = format!("/admin/{}", T::ADMIN_NAME);
let create_path = format!("{base}/create");
let edit_path = format!("{base}/:id/edit");
let delete_path = format!("{base}/:id/delete");
let history_path = format!("{base}/:id/history");
let bulk_path = format!("{base}/bulk_action");
let list_db = db.clone();
let list_entries = entries.clone();
router = router.get(&base, move |req, _params| {
let db = list_db.clone();
let entries = list_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let query = req.query();
let q = query
.get("q")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let status = query
.get("status")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let priority = query
.get("priority")
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let sort = query
.get("sort")
.map(str::trim)
.filter(|s| !s.is_empty())
.filter(|s| SORT_OPTIONS.iter().any(|(v, _)| *v == *s))
.map(String::from);
let visible_columns: Vec<&'static str> = default_list_columns::<T>();
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
let all_items = T::all(&db).await?;
let total = all_items.len();
let status_options = distinct_values::<T>(&all_items, "status");
let priority_options = distinct_values::<T>(&all_items, "priority");
let registry = current_registry();
let relation_filter_states = if registry.is_empty() {
Vec::new()
} else {
build_relation_filters::<T>(&db, ®istry, &query).await
};
let mut filtered: Vec<&T> = all_items
.iter()
.filter(|item| {
if let Some(qs) = &q {
if !matches_query::<T>(item, qs) {
return false;
}
}
if let Some(s) = &status {
let v = item.field_display("status").unwrap_or_default();
if &v != s {
return false;
}
}
for rel in &relation_filter_states {
if let Some(wanted) = rel.current_value {
let actual = item
.field_display(&rel.field_name)
.and_then(|s| s.parse::<i64>().ok());
if actual != Some(wanted) {
return false;
}
}
}
if let Some(p) = &priority {
let v = item.field_display("priority").unwrap_or_default();
if &v != p {
return false;
}
}
true
})
.collect();
match sort.as_deref() {
Some("oldest") | Some("id_asc") => filtered.sort_by_key(|i| i.id()),
Some("id_desc") => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
Some("newest") | None => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
_ => {}
}
let filters = ListFilters {
q: q.as_deref(),
status: status.as_deref(),
status_options: &status_options,
priority: priority.as_deref(),
priority_options: &priority_options,
sort: sort.as_deref(),
relation_filters: &relation_filter_states,
visible_columns: &visible_columns,
};
let fk_labels = if registry.is_empty() {
FkLabels::new()
} else {
fetch_fk_labels::<T>(&db, &filtered, ®istry).await
};
let cell_ctx = CellCtx {
registry: ®istry,
fk_labels: &fk_labels,
};
Ok::<Response, Error>(list_response::<T>(
shell, &filtered, total, filters, &cell_ctx,
))
}
});
let create_entries = entries.clone();
let create_form_db = db.clone();
router = router.get(&create_path, move |req, _params| {
let entries = create_entries.clone();
let db = create_form_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
let cell_ctx = CellCtx::empty();
let inverse_counts = std::collections::HashMap::new();
let registry = current_registry();
let form_options = if registry.is_empty() {
FormRelationOptions::new()
} else {
fetch_form_relation_options::<T>(&db, ®istry).await
};
Ok::<Response, Error>(form_response::<T>(
shell,
FormMode::Create,
&cell_ctx,
&inverse_counts,
&form_options,
))
}
});
let create_db = db.clone();
router = router.post(&create_path, move |req, _params| {
let db = create_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let user_id = ctx
.get::<crate::auth::Identity>()
.map(|i| i.user_id)
.unwrap_or(0);
let item = T::from_form(&form, None)?;
let primary = primary_string_value::<T>(&item);
let new_id = item.create(&db).await?;
audit::record(
&db,
audit::LogEntry {
user_id,
action_type: audit::ActionType::Create,
model_name: T::ADMIN_NAME,
object_id: new_id,
ip_address: peer_ip.as_deref(),
summary: audit_summary(
audit::ActionType::Create,
T::singular_name(),
new_id,
&primary,
),
},
)
.await?;
Ok::<Response, Error>(with_admin_headers(redirect(&format!(
"/admin/{}",
T::ADMIN_NAME
))))
}
});
let edit_db = db.clone();
let edit_entries = entries.clone();
router = router.get(&edit_path, move |req, params| {
let db = edit_db.clone();
let entries = edit_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
let registry = current_registry();
let items_ref: Vec<&T> = vec![&item];
let fk_labels = if registry.is_empty() {
FkLabels::new()
} else {
fetch_fk_labels::<T>(&db, &items_ref, ®istry).await
};
let inverse_counts = if registry.is_empty() {
std::collections::HashMap::new()
} else {
fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await
};
let cell_ctx = CellCtx {
registry: ®istry,
fk_labels: &fk_labels,
};
let form_options = if registry.is_empty() {
FormRelationOptions::new()
} else {
fetch_form_relation_options::<T>(&db, ®istry).await
};
Ok::<Response, Error>(form_response::<T>(
shell,
FormMode::Edit { id, item: &item },
&cell_ctx,
&inverse_counts,
&form_options,
))
}
});
let update_db = db.clone();
router = router.post(&edit_path, move |req, params| {
let db = update_db.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let user_id = ctx
.get::<crate::auth::Identity>()
.map(|i| i.user_id)
.unwrap_or(0);
let item = T::from_form(&form, Some(id))?;
let primary = primary_string_value::<T>(&item);
item.update(&db).await?;
audit::record(
&db,
audit::LogEntry {
user_id,
action_type: audit::ActionType::Update,
model_name: T::ADMIN_NAME,
object_id: id,
ip_address: peer_ip.as_deref(),
summary: audit_summary(
audit::ActionType::Update,
T::singular_name(),
id,
&primary,
),
},
)
.await?;
Ok::<Response, Error>(with_admin_headers(redirect(&format!(
"/admin/{}",
T::ADMIN_NAME
))))
}
});
let delete_confirm_db = db.clone();
let delete_confirm_entries = entries.clone();
router = router.get(&delete_path, move |req, params| {
let db = delete_confirm_db.clone();
let entries = delete_confirm_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
Ok::<Response, Error>(delete_confirmation_response::<T>(shell, id, &item))
}
});
let delete_db = db.clone();
let delete_entries = entries.clone();
router = router.post(&delete_path, move |req, params| {
let db = delete_db.clone();
let entries = delete_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let user_id = ctx
.get::<crate::auth::Identity>()
.map(|i| i.user_id)
.unwrap_or(0);
let primary = match T::find(&db, id).await? {
Some(item) => primary_string_value::<T>(&item),
None => String::new(),
};
let registry = current_registry();
if !registry.is_empty() {
let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
let blockers: Vec<(&relations::InverseRelation, i64)> = registry
.has_many(T::singular_name())
.iter()
.filter_map(|inv| {
let key = format!("{}.{}", inv.source_model, inv.source_field);
counts
.get(&key)
.copied()
.filter(|n| *n > 0)
.map(|n| (inv, n))
})
.collect();
if !blockers.is_empty() {
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
return Ok::<Response, Error>(render_delete_blocked_page::<T>(
&shell, id, &primary, &blockers,
));
}
}
if let Err(e) = T::delete(&db, id).await {
if is_foreign_key_violation(&e) {
let registry = current_registry();
let counts = fetch_inverse_counts(&db, T::singular_name(), id, ®istry).await;
let blockers: Vec<(&relations::InverseRelation, i64)> = registry
.has_many(T::singular_name())
.iter()
.filter_map(|inv| {
let key = format!("{}.{}", inv.source_model, inv.source_field);
counts
.get(&key)
.copied()
.filter(|n| *n > 0)
.map(|n| (inv, n))
})
.collect();
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
return Ok::<Response, Error>(render_delete_blocked_page::<T>(
&shell, id, &primary, &blockers,
));
}
return Err(e);
}
audit::record(
&db,
audit::LogEntry {
user_id,
action_type: audit::ActionType::Delete,
model_name: T::ADMIN_NAME,
object_id: id,
ip_address: peer_ip.as_deref(),
summary: audit_summary(
audit::ActionType::Delete,
T::singular_name(),
id,
&primary,
),
},
)
.await?;
Ok::<Response, Error>(with_admin_headers(redirect(&format!(
"/admin/{}",
T::ADMIN_NAME
))))
}
});
let history_db = db.clone();
let history_entries = entries.clone();
router = router.get(&history_path, move |req, params| {
let db = history_db.clone();
let entries = history_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let id = parse_id_param(¶ms)?;
let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
let actions = audit::for_object(&db, T::ADMIN_NAME, id).await?;
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
Ok::<Response, Error>(object_history_response::<T>(shell, id, &item, &actions))
}
});
let bulk_db = db.clone();
let bulk_entries = entries.clone();
router = router.post(&bulk_path, move |req, _params| {
let db = bulk_db.clone();
let entries = bulk_entries.clone();
async move {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let user_id = ctx
.get::<crate::auth::Identity>()
.map(|i| i.user_id)
.unwrap_or(0);
let action = form.get("action").unwrap_or("").trim().to_string();
let selected_raw = form.get("_selected").unwrap_or("").to_string();
let ids: Vec<i64> = selected_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<i64>().ok())
.collect();
let confirmed = form.get("_confirm").map(|v| v == "yes").unwrap_or(false);
if ids.is_empty() || action.is_empty() {
return Ok::<Response, Error>(with_admin_headers(redirect(&format!(
"/admin/{}",
T::ADMIN_NAME
))));
}
if action != "delete" {
return Err(Error::BadRequest(
format!("Unknown bulk action `{action}`",),
));
}
if !confirmed {
let mut items: Vec<(i64, String)> = Vec::with_capacity(ids.len());
for id in &ids {
if let Some(item) = T::find(&db, *id).await? {
let primary = primary_string_value::<T>(&item);
items.push((*id, primary));
}
}
let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
return Ok::<Response, Error>(bulk_delete_confirmation_response::<T>(
&shell, &items,
));
}
for id in &ids {
let primary = match T::find(&db, *id).await? {
Some(item) => primary_string_value::<T>(&item),
None => continue, };
T::delete(&db, *id).await?;
let mut summary =
audit_summary(audit::ActionType::Delete, T::singular_name(), *id, &primary);
summary.push_str(" (via bulk action)");
audit::record(
&db,
audit::LogEntry {
user_id,
action_type: audit::ActionType::Delete,
model_name: T::ADMIN_NAME,
object_id: *id,
ip_address: peer_ip.as_deref(),
summary,
},
)
.await?;
}
Ok::<Response, Error>(with_admin_headers(redirect(&format!(
"/admin/{}",
T::ADMIN_NAME
))))
}
});
router
}
fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
params
.get("id")
.and_then(|s| s.parse::<i64>().ok())
.ok_or_else(|| Error::BadRequest(String::from("invalid id")))
}
fn primary_string_value<T: AdminModel>(item: &T) -> String {
T::FIELDS
.iter()
.find(|f| f.editable && matches!(f.ty, FieldType::String))
.and_then(|f| item.field_display(f.name))
.filter(|s| !s.is_empty())
.unwrap_or_default()
}
fn audit_summary(action: audit::ActionType, singular: &str, id: i64, primary: &str) -> String {
let verb = action.label();
if primary.is_empty() {
format!("{verb} {singular} #{id}")
} else {
format!("{verb} {singular} #{id}: {primary}")
}
}
pub const MAX_FORM_BODY_BYTES: usize = crate::http::MAX_REQUEST_BODY_BYTES;
pub const CSRF_FIELD: &str = "_csrf";
fn ctx_csrf(ctx: &crate::context::Context) -> Option<&str> {
ctx.get::<crate::auth::CsrfToken>().map(|t| t.0.as_str())
}
fn ctx_user_email(ctx: &crate::context::Context) -> Option<&str> {
ctx.get::<crate::auth::Identity>().map(|i| i.email.as_str())
}
fn csrf_input(csrf: Option<&str>) -> String {
match csrf {
Some(token) if !token.is_empty() => format!(
r#"<input type="hidden" name="{name}" value="{value}">"#,
name = CSRF_FIELD,
value = escape_html(token),
),
_ => String::new(),
}
}
fn require_csrf(ctx: &crate::context::Context, form: &FormData) -> Result<(), Error> {
let expected = ctx
.get::<crate::auth::CsrfToken>()
.map(|t| t.0.as_str())
.unwrap_or("");
let provided = form.get(CSRF_FIELD).unwrap_or("");
if !crate::auth::csrf::verify_token(expected, provided) {
return Err(Error::Forbidden);
}
Ok(())
}
fn with_admin_headers(mut resp: Response) -> Response {
use hyper::header::HeaderValue;
let h = resp.headers_mut();
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
h.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
if crate::auth::in_production() {
h.insert(
"strict-transport-security",
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
);
}
resp
}
async fn read_form(req: Request) -> Result<FormData, Error> {
let (_, body, _) = req.into_parts();
read_form_from_parts(body).await
}
async fn read_form_from_parts(body: hyper::body::Incoming) -> Result<FormData, Error> {
let limited = http_body_util::Limited::new(body, MAX_FORM_BODY_BYTES);
let collected = limited.collect().await.map_err(|e| {
if e.downcast_ref::<http_body_util::LengthLimitError>()
.is_some()
{
Error::PayloadTooLarge
} else {
Error::BadRequest(e.to_string())
}
})?;
let bytes = collected.to_bytes();
let body_str = std::str::from_utf8(&bytes).map_err(|e| Error::BadRequest(e.to_string()))?;
Ok(FormData::parse(body_str))
}
fn redirect(to: &str) -> Response {
hyper::Response::builder()
.status(303)
.header("location", to)
.body(Full::new(Bytes::new()))
.expect("valid redirect")
}
fn admin_css_response() -> Response {
use hyper::header::HeaderValue;
let body = ADMIN_CSS_BUNDLE.as_bytes();
let etag = {
let len = body.len();
let head = u32::from_le_bytes([
*body.first().unwrap_or(&0),
*body.get(1).unwrap_or(&0),
*body.get(2).unwrap_or(&0),
*body.get(3).unwrap_or(&0),
]);
let tail = u32::from_le_bytes([
*body.get(len.saturating_sub(4)).unwrap_or(&0),
*body.get(len.saturating_sub(3)).unwrap_or(&0),
*body.get(len.saturating_sub(2)).unwrap_or(&0),
*body.get(len.saturating_sub(1)).unwrap_or(&0),
]);
format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
};
let mut resp = hyper::Response::builder()
.status(200)
.header("content-type", "text/css; charset=utf-8")
.header("cache-control", "no-cache, must-revalidate")
.header("etag", etag)
.body(Full::new(Bytes::from_static(ADMIN_CSS_BUNDLE.as_bytes())))
.expect("valid css response");
let h = resp.headers_mut();
h.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
resp
}
fn env_chip_html() -> String {
if crate::auth::in_production() {
r#"<span class="rio-env-chip is-prod">production</span>"#.to_string()
} else {
r#"<span class="rio-env-chip">development</span>"#.to_string()
}
}
fn bundled_asset_response(bytes: &'static [u8], content_type: &'static str) -> Response {
use hyper::header::HeaderValue;
let etag = {
let len = bytes.len();
let head = u32::from_le_bytes([
*bytes.first().unwrap_or(&0),
*bytes.get(1).unwrap_or(&0),
*bytes.get(2).unwrap_or(&0),
*bytes.get(3).unwrap_or(&0),
]);
let tail = u32::from_le_bytes([
*bytes.get(len.saturating_sub(4)).unwrap_or(&0),
*bytes.get(len.saturating_sub(3)).unwrap_or(&0),
*bytes.get(len.saturating_sub(2)).unwrap_or(&0),
*bytes.get(len.saturating_sub(1)).unwrap_or(&0),
]);
format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
};
let mut resp = hyper::Response::builder()
.status(200)
.header("content-type", content_type)
.header("cache-control", "public, max-age=3600")
.header("etag", etag)
.body(Full::new(Bytes::from_static(bytes)))
.expect("valid static asset response");
resp.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
resp
}
fn admin_favicon_response() -> Response {
use hyper::header::HeaderValue;
let mut resp = hyper::Response::builder()
.status(200)
.header("content-type", "image/svg+xml")
.header("cache-control", "public, max-age=86400")
.body(Full::new(Bytes::from_static(ADMIN_FAVICON_SVG.as_bytes())))
.expect("valid favicon response");
resp.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
resp
}
struct Shell<'a> {
entries: &'a [AdminEntry],
active: Option<&'a str>,
user_email: Option<&'a str>,
csrf: Option<&'a str>,
}
impl<'a> Shell<'a> {
fn from_ctx(
entries: &'a [AdminEntry],
active: Option<&'a str>,
ctx: &'a crate::context::Context,
) -> Self {
Self {
entries,
active,
user_email: ctx_user_email(ctx),
csrf: ctx_csrf(ctx),
}
}
}
type Crumb<'a> = (&'a str, Option<&'a str>);
fn render_breadcrumbs(crumbs: &[Crumb<'_>]) -> String {
if crumbs.is_empty() {
return String::new();
}
let sep = format!(
r#"<span class="rio-crumb-sep">{}</span>"#,
icon_chevron_right()
);
let mut out = String::from(r#"<nav class="rio-breadcrumbs" aria-label="Breadcrumb">"#);
for (i, (label, href)) in crumbs.iter().enumerate() {
let is_last = i == crumbs.len() - 1;
if i > 0 {
out.push_str(&sep);
}
match (is_last, href) {
(true, _) => {
out.push_str(&format!(
r#"<span class="rio-crumb-current" aria-current="page">{}</span>"#,
escape_html(label),
));
}
(false, Some(h)) => {
out.push_str(&format!(
r#"<a href="{}">{}</a>"#,
escape_html(h),
escape_html(label),
));
}
(false, None) => {
out.push_str(&escape_html(label));
}
}
}
out.push_str("</nav>");
out
}
const NAV_ACTIONS: &str = "__actions";
fn humanise_model_label(name: &str) -> String {
if name == "Staffs" {
return "Staff".to_string();
}
if name == "Diagnosis" {
return "Diagnoses".to_string();
}
let mut out = String::with_capacity(name.len() + 4);
for (i, ch) in name.chars().enumerate() {
if i > 0 && ch.is_ascii_uppercase() {
out.push(' ');
}
out.push(ch);
}
out
}
fn render_sidebar(shell: &Shell<'_>) -> String {
let design = design::Design::global();
let user_facing: Vec<&AdminEntry> = shell.entries.iter().filter(|e| !e.core).collect();
let mut models_html = String::new();
if !user_facing.is_empty() {
models_html.push_str(r#"<div class="rio-nav">"#);
models_html.push_str(r#"<div class="rio-nav-section">Models</div>"#);
for e in &user_facing {
let active_cls = if shell.active == Some(e.admin_name) {
"rio-nav-link is-active"
} else {
"rio-nav-link"
};
models_html.push_str(&format!(
r#"<a class="{cls}" href="/admin/{name}">{icon}<span>{label}</span></a>"#,
cls = active_cls,
name = escape_html(e.admin_name),
icon = icon_layers(),
label = escape_html(&humanise_model_label(e.display_name)),
));
}
models_html.push_str("</div>");
}
let dashboard_active = if shell.active.is_none() {
"rio-nav-link is-active"
} else {
"rio-nav-link"
};
let actions_active = if shell.active == Some(NAV_ACTIONS) {
"rio-nav-link is-active"
} else {
"rio-nav-link"
};
let logout_form = if shell.csrf.is_some() {
format!(
r#"<form class="rio-sidebar-logout" method="post" action="/admin/logout">
{csrf}
<button type="submit">{icon}<span>Sign out</span></button>
</form>"#,
csrf = csrf_input(shell.csrf),
icon = icon_logout(),
)
} else {
String::new()
};
let email = shell.user_email.unwrap_or("");
let avatar_initial = email
.chars()
.next()
.map(|c| c.to_ascii_uppercase().to_string())
.unwrap_or_else(|| String::from("·"));
let user_block = if shell.user_email.is_some() {
format!(
r#"<a class="rio-sidebar-user" href="/admin/profile" title="Your profile">
<span class="rio-avatar">{avatar}</span>
<span class="rio-user-email">{email}</span>
</a>"#,
avatar = escape_html(&avatar_initial),
email = escape_html(email),
)
} else {
String::new()
};
format!(
r#"<aside class="rio-sidebar">
<div class="rio-sidebar-inner">
<a class="rio-brand" href="/admin">
<span class="rio-brand-mark">{logo}</span>
<span class="rio-brand-meta">
<span class="rio-brand-name">{project}</span>
<span class="rio-brand-label">Admin</span>
</span>
</a>
<nav class="rio-nav">
<a class="{dash}" href="/admin">{dash_icon}<span>Dashboard</span></a>
<a class="{actions}" href="/admin/actions">{actions_icon}<span>Recent actions</span></a>
</nav>
{models}
<div class="rio-sidebar-footer">
{user}
{logout}
</div>
</div>
</aside>"#,
logo = escape_html(&design.logo_initial),
project = escape_html(&design.project_name),
dash = dashboard_active,
dash_icon = icon_dashboard(),
actions = actions_active,
actions_icon = icon_activity(),
models = models_html,
user = user_block,
logout = logout_form,
)
}
#[allow(clippy::too_many_arguments)]
fn render_shell_page(
shell: &Shell<'_>,
status: u16,
document_title: &str,
page_title: &str,
page_subtitle: Option<&str>,
breadcrumbs: &[Crumb<'_>],
actions: &str,
body: &str,
) -> Response {
let design = design::Design::global();
let sidebar = render_sidebar(shell);
let crumbs = render_breadcrumbs(breadcrumbs);
let env_chip = env_chip_html();
let topbar_actions = match shell.csrf {
Some(csrf) => format!(
r#"<div class="rio-topbar-actions">
{env}
<a class="rio-topbar-icon" href="/admin" title="Home" aria-label="Home">{home}</a>
<button class="rio-topbar-icon" type="button" title="Notifications" aria-label="Notifications">{bell}<span class="rio-topbar-dot"></span></button>
<button class="rio-topbar-icon" type="button" title="Messages" aria-label="Messages">{mail}</button>
<form class="rio-topbar-logout" method="post" action="/admin/logout">
<input type="hidden" name="_csrf" value="{csrf_val}">
<button type="submit" title="Sign out">{logout}<span>Logout</span></button>
</form>
</div>"#,
env = env_chip,
home = icon_home(),
bell = icon_bell(),
mail = icon_mail(),
logout = icon_logout(),
csrf_val = escape_html(csrf),
),
None => format!(
r#"<div class="rio-topbar-actions">{env}</div>"#,
env = env_chip
),
};
let subtitle_html = page_subtitle
.map(|s| format!(r#"<p class="rio-page-subtitle">{}</p>"#, escape_html(s)))
.unwrap_or_default();
let actions_block = if actions.is_empty() {
String::new()
} else {
format!(r#"<div class="rio-page-actions">{actions}</div>"#)
};
let theme_style = format!(
"\n:root {{\n --rio-primary: {p};\n --rio-primary-hover: {ph};\n --rio-accent: {a};\n --rio-accent-hover: {ah};\n}}\n",
p = escape_css_color(&design.primary_color),
ph = escape_css_color(&design.primary_color),
a = escape_css_color(&design.accent_color),
ah = escape_css_color(&design.accent_color),
);
let density_class = match design.density {
design::Density::Comfortable => "",
design::Density::Compact => " rio-density-compact",
};
let body_html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{doc_title} · {project}</title>
<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
<style>{theme}</style>
</head>
<body class="rio-body{density}">
<div class="rio-app">
{sidebar}
<main class="rio-main">
<div class="rio-container">
<header class="rio-topbar">
{crumbs}
{topbar_actions}
</header>
<div class="rio-page-header">
<div>
<h1 class="rio-page-title">{page_title}</h1>
{subtitle}
</div>
{actions}
</div>
{body}
</div>
</main>
</div>
<script>
// Admin Intelligence Layer (0.7.0) — minimal JS for PII toggle.
// Click a .rio-pii-toggle to reveal / hide the adjacent masked value.
document.addEventListener("click", function(e){{
var btn = e.target.closest ? e.target.closest(".rio-pii-toggle") : null;
if(!btn) return;
// The masked <span> is the button's previous sibling by construction.
var span = btn.previousElementSibling;
if(!span || !span.classList.contains("rio-pii")) return;
if(span.getAttribute("data-hidden") === "1"){{
span.textContent = span.getAttribute("data-value") || "";
span.setAttribute("data-hidden","0");
btn.textContent = "hide";
}} else {{
span.textContent = span.getAttribute("data-mask") || "";
span.setAttribute("data-hidden","1");
btn.textContent = "show";
}}
}});
</script>
</body>
</html>"#,
doc_title = escape_html(document_title),
project = escape_html(&design.project_name),
theme = theme_style,
density = density_class,
sidebar = sidebar,
crumbs = crumbs,
topbar_actions = topbar_actions,
page_title = escape_html(page_title),
subtitle = subtitle_html,
actions = actions_block,
body = body,
css_ver = ADMIN_CSS_VER,
);
let resp = hyper::Response::builder()
.status(status)
.header("content-type", "text/html; charset=utf-8")
.header(
"cache-control",
"no-store, no-cache, must-revalidate, max-age=0",
)
.header("pragma", "no-cache")
.header("expires", "0")
.body(Full::new(Bytes::from(body_html)))
.expect("valid response");
with_admin_headers(resp)
}
fn escape_css_color(s: &str) -> &str {
if s.contains([';', '{', '}', '<', '\\']) {
"#0f172a"
} else {
s
}
}
pub fn register_generated(
registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
cfg: crate::admin::admin_generator::AdminModelConfig,
) {
let slug = cfg.slug;
registry.register(slug, move || {
crate::admin::admin_generator::from_config(cfg.clone())
});
}
pub async fn register_from_table(
db: &Db,
registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
table: &str,
) -> Result<(), Error> {
let cfg = crate::admin::schema_introspect::generate_from_table(db, table).await?;
register_generated(registry, cfg);
Ok(())
}
fn empty_state_hint<T: AdminModel>(context: Option<&crate::ai::ContextConfig>) -> Option<String> {
let ctx = context?;
let schema = ctx.industry_schema()?;
let model_has_convention = schema
.required_fields
.iter()
.any(|f| T::FIELDS.iter().any(|af| af.name == f.as_str()));
if !model_has_convention {
return None;
}
let country_phrase = match ctx.country.as_deref() {
Some(cc) if cc.eq_ignore_ascii_case("SE") => "In Sweden, ",
Some(cc) if cc.eq_ignore_ascii_case("NO") => "In Norway, ",
_ => "",
};
let industry = ctx.industry.as_deref().unwrap_or("");
let singular_lower = T::singular_name().to_lowercase();
let fields_list = schema.required_fields.join(", ");
Some(format!(
"{country}{industry} {singular}s usually include {fields}.",
country = country_phrase,
industry = industry,
singular = singular_lower,
fields = fields_list,
))
}
struct ListFilters<'a> {
q: Option<&'a str>,
status: Option<&'a str>,
status_options: &'a [String],
priority: Option<&'a str>,
priority_options: &'a [String],
sort: Option<&'a str>,
relation_filters: &'a [RelationFilterState],
visible_columns: &'a [&'static str],
}
struct RelationFilterState {
field_name: String,
label: String,
current_value: Option<i64>,
mode: RelationFilterMode,
}
enum RelationFilterMode {
Dropdown { options: Vec<(i64, String)> },
Numeric { too_many: bool },
}
impl ListFilters<'_> {
fn is_active(&self) -> bool {
self.q.is_some()
|| self.status.is_some()
|| self.priority.is_some()
|| self.sort.is_some()
|| self
.relation_filters
.iter()
.any(|r| r.current_value.is_some())
}
}
const SORT_OPTIONS: &[(&str, &str)] = &[
("newest", "Newest first"),
("oldest", "Oldest first"),
("id_asc", "ID ↑"),
("id_desc", "ID ↓"),
];
type FkLabels = std::collections::HashMap<String, std::collections::HashMap<i64, String>>;
type FormRelationOptions = std::collections::HashMap<String, Vec<(i64, String)>>;
struct CellCtx<'a> {
registry: &'a relations::RelationRegistry,
fk_labels: &'a FkLabels,
}
impl CellCtx<'_> {
fn empty() -> CellCtx<'static> {
static EMPTY_REG: std::sync::OnceLock<relations::RelationRegistry> =
std::sync::OnceLock::new();
static EMPTY_LABELS: std::sync::OnceLock<FkLabels> = std::sync::OnceLock::new();
CellCtx {
registry: EMPTY_REG.get_or_init(relations::RelationRegistry::empty),
fk_labels: EMPTY_LABELS.get_or_init(std::collections::HashMap::new),
}
}
}
fn current_registry() -> relations::RelationRegistry {
match schema_cache::snapshot() {
Some(c) => relations::RelationRegistry::from_schema(&c.schema),
None => relations::RelationRegistry::empty(),
}
}
type InverseCounts = std::collections::HashMap<String, i64>;
async fn fetch_inverse_counts(
db: &Db,
target_model: &str,
target_id: i64,
registry: &relations::RelationRegistry,
) -> InverseCounts {
use sqlx::Row;
let mut out: InverseCounts = std::collections::HashMap::new();
for inv in registry.has_many(target_model) {
let sql = format!(
"SELECT COUNT(*) AS rio_count FROM \"{table}\" WHERE \"{col}\" = ?",
table = inv.source_table,
col = inv.source_field,
);
let row = match sqlx::query(&sql).bind(target_id).fetch_one(db.pool()).await {
Ok(r) => r,
Err(_) => continue,
};
let count: i64 = row.try_get::<i64, _>("rio_count").unwrap_or_default();
out.insert(format!("{}.{}", inv.source_model, inv.source_field), count);
}
out
}
async fn build_relation_filters<T: AdminModel>(
db: &Db,
registry: &relations::RelationRegistry,
query: &FormData,
) -> Vec<RelationFilterState> {
use sqlx::Row;
let mut out: Vec<RelationFilterState> = Vec::new();
let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
for resolved in registry.belongs_to_of(T::singular_name()) {
let current_value = query
.get(&resolved.source_field)
.and_then(|v| v.parse::<i64>().ok());
let mode = match &resolved.target_display_field {
None => RelationFilterMode::Numeric { too_many: false },
Some(display_col) => {
let sql = format!(
"SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
col = display_col,
table = resolved.target_table,
lim = cap + 1,
);
let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
Ok(r) => r,
Err(_) => {
out.push(RelationFilterState {
field_name: resolved.source_field.clone(),
label: resolved.target_model.clone(),
current_value,
mode: RelationFilterMode::Numeric { too_many: false },
});
continue;
}
};
if rows.len() > cap {
RelationFilterMode::Numeric { too_many: true }
} else {
let options: Vec<(i64, String)> = rows
.into_iter()
.map(|row| {
let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
let label: String = row
.try_get::<String, _>("rio_label")
.or_else(|_| {
row.try_get::<i64, _>("rio_label").map(|n| n.to_string())
})
.or_else(|_| {
row.try_get::<i32, _>("rio_label").map(|n| n.to_string())
})
.unwrap_or_default();
(id, label)
})
.collect();
RelationFilterMode::Dropdown { options }
}
}
};
out.push(RelationFilterState {
field_name: resolved.source_field.clone(),
label: resolved.target_model.clone(),
current_value,
mode,
});
}
out
}
async fn fetch_form_relation_options<T: AdminModel>(
db: &Db,
registry: &relations::RelationRegistry,
) -> FormRelationOptions {
use sqlx::Row;
let mut out: FormRelationOptions = std::collections::HashMap::new();
let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
for resolved in registry.belongs_to_of(T::singular_name()) {
let Some(display_col) = &resolved.target_display_field else {
continue;
};
let sql = format!(
"SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
col = display_col,
table = resolved.target_table,
lim = cap + 1,
);
let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
Ok(r) => r,
Err(_) => continue,
};
if rows.len() > cap {
continue;
}
let options: Vec<(i64, String)> = rows
.into_iter()
.map(|row| {
let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
let label: String = row
.try_get::<String, _>("rio_label")
.or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
.or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
.unwrap_or_default();
(id, label)
})
.collect();
out.insert(resolved.source_field.clone(), options);
}
out
}
async fn fetch_fk_labels<T: AdminModel>(
db: &Db,
items: &[&T],
registry: &relations::RelationRegistry,
) -> FkLabels {
use sqlx::Row;
let mut out: FkLabels = std::collections::HashMap::new();
let source_model = T::singular_name();
for f in T::FIELDS {
let Some(resolved) = registry.belongs_to(source_model, f.name) else {
continue;
};
let Some(display_col) = &resolved.target_display_field else {
continue;
};
let mut ids: Vec<i64> = items
.iter()
.filter_map(|it| it.field_display(f.name))
.filter_map(|s| s.parse::<i64>().ok())
.collect();
ids.sort_unstable();
ids.dedup();
if ids.is_empty() {
continue;
}
let placeholders: Vec<&'static str> = ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" WHERE id IN ({ph})",
col = display_col,
table = resolved.target_table,
ph = placeholders.join(","),
);
let mut q = sqlx::query(&sql);
for id in &ids {
q = q.bind(id);
}
let rows = match q.fetch_all(db.pool()).await {
Ok(r) => r,
Err(_) => continue,
};
let mut map: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
for row in rows {
let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
let label: String = row
.try_get::<String, _>("rio_label")
.or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
.or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
.or_else(|_| row.try_get::<bool, _>("rio_label").map(|b| b.to_string()))
.unwrap_or_default();
map.insert(id, label);
}
out.insert(f.name.to_string(), map);
}
out
}
fn list_response<T: AdminModel>(
shell: Shell<'_>,
items: &[&T],
total: usize,
filters: ListFilters<'_>,
cell_ctx: &CellCtx<'_>,
) -> Response {
let count = items.len();
let singular = T::singular_name();
let plural = T::DISPLAY_NAME;
let admin_name = T::ADMIN_NAME;
let page_actions = format!(
r#"<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{icon}<span>Add {singular}</span></a>"#,
name = escape_html(admin_name),
singular = escape_html(singular),
icon = icon_plus(),
);
let body = if total == 0 {
let hint_html = match empty_state_hint::<T>(intelligence::context_global()) {
Some(h) => format!(r#"<p class="rio-empty-hint">{}</p>"#, escape_html(&h)),
None => String::new(),
};
format!(
r#"<div class="rio-card">
<div class="rio-empty">
<div class="rio-empty-icon">{icon}</div>
<h3>Start by adding your first {singular_lower}</h3>
<p>This table is empty. Create the first record to get started.</p>
{hint}
<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
</div>
</div>"#,
icon = icon_inbox(),
name = escape_html(admin_name),
plus = icon_plus(),
singular_lower = escape_html(&singular.to_lowercase()),
hint = hint_html,
)
} else {
let toolbar = render_list_toolbar::<T>(&filters, count, total);
let chips = render_active_filter_chips(&filters, admin_name);
if items.is_empty() {
format!(
r#"<div class="rio-table-wrap">
{toolbar}
{chips}
<div class="rio-empty">
<div class="rio-empty-icon">{icon}</div>
<h3>No records match these filters</h3>
<p>Try a different search term, clear the filters, or add a new {singular_lower}.</p>
<div class="rio-empty-actions">
<a class="rio-btn" href="/admin/{name}">{reset}<span>Clear filters</span></a>
<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
</div>
</div>
</div>"#,
icon = icon_search(),
singular_lower = escape_html(&singular.to_lowercase()),
name = escape_html(admin_name),
reset = icon_arrow_left(),
plus = icon_plus(),
)
} else {
let visible_fields: Vec<&AdminField> = T::FIELDS
.iter()
.filter(|f| filters.visible_columns.contains(&f.name))
.collect();
let has_hidden_fields = T::FIELDS.len() > visible_fields.len();
let hidden_fields: Vec<&AdminField> = T::FIELDS
.iter()
.filter(|f| !filters.visible_columns.contains(&f.name))
.collect();
let colspan_total = visible_fields.len() + 3;
let headers: String = visible_fields
.iter()
.map(|f| {
format!(
r#"<th data-col="{name}">{label}</th>"#,
name = escape_html(f.name),
label = escape_html(&humanise(f.name)),
)
})
.collect();
let expand_header = if has_hidden_fields {
r#"<th class="rio-cell-expand" aria-label="Expand"></th>"#.to_string()
} else {
String::new()
};
let rows: String = items
.iter()
.map(|item| {
let cells: String = visible_fields
.iter()
.map(|f| {
let cell = render_cell::<T>(f, *item, cell_ctx);
inject_data_col(&cell, f.name)
})
.collect();
let id = item.id();
let row_actions = format!(
r#"<td class="rio-cell-actions">
<div class="rio-row-actions">
<a class="rio-btn rio-btn-sm" href="/admin/{name}/{id}/edit">{pencil}<span>Edit</span></a>
<a class="rio-btn rio-btn-sm rio-btn-danger-ghost" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete</span></a>
</div>
</td>"#,
name = escape_html(admin_name),
id = id,
pencil = icon_pencil(),
trash = icon_trash(),
);
let checkbox = format!(
r#"<td class="rio-cell-check"><input type="checkbox" class="rio-bulk-row" value="{id}" aria-label="Select row {id}"></td>"#,
);
if !has_hidden_fields {
return format!("<tr>{checkbox}{cells}{row_actions}</tr>");
}
let expand_cell = format!(
r#"<td class="rio-cell-expand"><button type="button" class="rio-expand-btn" data-expand-toggle aria-expanded="false" aria-label="Expand row {id}">▸</button></td>"#,
);
let detail_fields: String = hidden_fields
.iter()
.map(|f| {
format!(
r#"<div class="rio-expand-field"><dt>{label}</dt><dd>{value}</dd></div>"#,
label = escape_html(&humanise(f.name)),
value = render_cell_inner::<T>(f, *item, cell_ctx),
)
})
.collect();
let expand_row = format!(
r#"<tr class="rio-row-expand" data-row-id="{id}" hidden><td colspan="{colspan}" class="rio-cell-expand-panel"><dl class="rio-expand-details">{fields}</dl></td></tr>"#,
id = id,
colspan = colspan_total,
fields = detail_fields,
);
format!(
r#"<tr class="rio-row-main" data-row-id="{id}">{expand_cell}{checkbox}{cells}{row_actions}</tr>{expand_row}"#,
)
})
.collect();
let csrf = csrf_input(shell.csrf);
let bulk_bar = format!(
r#"<div class="rio-bulk-bar">
<label class="rio-bulk-label" for="rio-bulk-action">Action</label>
<select class="rio-select" id="rio-bulk-action" name="action">
<option value="">-- Select an action --</option>
<option value="delete">Delete selected {plural_lower}</option>
</select>
<button type="submit" class="rio-btn">Go</button>
<span class="rio-bulk-count" data-rio-bulk-count>0 selected</span>
</div>"#,
plural_lower = escape_html(&plural.to_lowercase()),
);
format!(
r#"<div class="rio-table-wrap">
{toolbar}
{chips}
<form method="post" action="/admin/{name}/bulk_action" class="rio-bulk-form">
{csrf}
<input type="hidden" name="_selected" value="">
{bulk_bar}
<table class="rio-table">
<thead><tr>{expand_header}<th class="rio-cell-check"><input type="checkbox" class="rio-bulk-all" aria-label="Select all"></th>{headers}<th aria-label="Actions"></th></tr></thead>
<tbody>{rows}</tbody>
</table>
</form>
<script>
(function(){{
var form=document.querySelector('.rio-bulk-form');
if(form){{
var all=form.querySelector('.rio-bulk-all');
var rows=form.querySelectorAll('.rio-bulk-row');
var count=form.querySelector('[data-rio-bulk-count]');
var hidden=form.querySelector('input[name="_selected"]');
function collect(){{var ids=[];rows.forEach(function(cb){{if(cb.checked)ids.push(cb.value);}});return ids;}}
function update(){{var ids=collect();if(hidden)hidden.value=ids.join(',');if(count)count.textContent=ids.length+' selected';}}
if(all)all.addEventListener('change',function(){{rows.forEach(function(cb){{cb.checked=all.checked;}});update();}});
rows.forEach(function(cb){{cb.addEventListener('change',update);}});
form.addEventListener('submit',function(e){{update();var ids=collect();var act=form.querySelector('[name="action"]');if(!ids.length||!act.value){{e.preventDefault();alert('Select one or more rows and an action, then click Go.');}}}});
update();
}}
// Columns toggle (Change 2) — outside-click closes the <details>,
// and checkbox changes flip `display: none` on the matching <th>
// and every matching <td> via `data-col` attribute. Checkbox and
// actions columns carry no `data-col`, so they're never touched.
document.addEventListener('click',function(e){{
var d=document.querySelector('details.rio-cols-ctl[open]');
if(!d)return;
if(d.contains(e.target))return;
d.open=false;
}});
document.addEventListener('change',function(e){{
var cb=e.target&&e.target.closest?e.target.closest('.rio-cols-check'):null;
if(!cb)return;
var col=cb.getAttribute('data-col');
if(!col)return;
var esc=(window.CSS&&CSS.escape)?CSS.escape(col):col;
document.querySelectorAll('[data-col="'+esc+'"]').forEach(function(cell){{
cell.style.display=cb.checked?'':'none';
}});
}});
// More filters panel toggle (Change 3) — plain hidden-attribute
// flip. Button carries `data-more-filters-toggle` and an
// `aria-controls` pointing at the panel id. No outside-click
// handler, no animation.
document.addEventListener('click',function(e){{
var btn=e.target&&e.target.closest?e.target.closest('[data-more-filters-toggle]'):null;
if(!btn)return;
var id=btn.getAttribute('aria-controls');
var panel=id?document.getElementById(id):null;
if(!panel)return;
var open=!panel.hasAttribute('hidden');
if(open){{
panel.setAttribute('hidden','');
btn.setAttribute('aria-expanded','false');
}}else{{
panel.removeAttribute('hidden');
btn.setAttribute('aria-expanded','true');
}}
}});
// Row expansion toggle (Change 5) — the button lives in the first
// column of each `.rio-row-main`, the paired `.rio-row-expand` is
// its `nextElementSibling`. Flip the `hidden` attribute + chevron
// glyph + aria-expanded; nothing else.
document.addEventListener('click',function(e){{
var btn=e.target&&e.target.closest?e.target.closest('[data-expand-toggle]'):null;
if(!btn)return;
var main=btn.closest('tr');
if(!main)return;
var panel=main.nextElementSibling;
if(!panel||!panel.classList.contains('rio-row-expand'))return;
var open=!panel.hasAttribute('hidden');
if(open){{
panel.setAttribute('hidden','');
btn.setAttribute('aria-expanded','false');
btn.textContent='\u25B8';
}}else{{
panel.removeAttribute('hidden');
btn.setAttribute('aria-expanded','true');
btn.textContent='\u25BE';
}}
}});
}})();
</script>
</div>"#,
name = escape_html(admin_name),
expand_header = expand_header,
)
}
};
let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), (plural, None)];
render_shell_page(
&shell,
200,
plural,
plural,
Some(&format!(
"Browse, search, and manage {}.",
plural.to_lowercase()
)),
crumbs,
&page_actions,
&body,
)
}
fn render_relation_filter_control(state: &RelationFilterState) -> String {
let field = escape_html(&state.field_name);
let label = escape_html(&state.label);
match &state.mode {
RelationFilterMode::Dropdown { options } => {
let placeholder = format!("All {}", pluralise_label(&state.label));
let options_html: String = std::iter::once(format!(
r#"<option value="">{}</option>"#,
escape_html(&placeholder),
))
.chain(options.iter().map(|(id, display)| {
let selected = state.current_value == Some(*id);
let mark = if selected { " selected" } else { "" };
format!(
r#"<option value="{id}"{mark}>{display}</option>"#,
id = id,
mark = mark,
display = escape_html(display),
)
}))
.collect();
format!(
r#"<select class="rio-select" name="{field}" aria-label="Filter by {label}">{options_html}</select>"#,
)
}
RelationFilterMode::Numeric { too_many } => {
let current = state
.current_value
.map(|v| v.to_string())
.unwrap_or_default();
let hint = if *too_many {
format!(
r#"<span class="rio-field-hint">Too many options for a dropdown — enter the {label} ID directly.</span>"#,
label = label,
)
} else {
format!(
r#"<span class="rio-field-hint">No display field declared for {label} — enter the ID directly.</span>"#,
label = label,
)
};
format!(
r#"<label class="rio-field" style="display:inline-flex; gap:var(--rio-s-1); align-items:center; margin:0">\
<span class="rio-field-label">{label} ID</span>\
<input class="rio-input" type="number" name="{field}" value="{current}" style="width:140px" aria-label="Filter by {label} id">\
{hint}\
</label>"#,
label = label,
field = field,
current = escape_html(¤t),
hint = hint,
)
}
}
}
fn render_columns_control<T: AdminModel>(filters: &ListFilters<'_>) -> String {
let rows: String = T::FIELDS
.iter()
.map(|f| {
let is_visible = filters.visible_columns.contains(&f.name);
let is_id = f.name == "id";
let checked = if is_visible { " checked" } else { "" };
let disabled = if is_id { " disabled" } else { "" };
let mut tags: Vec<&'static str> = Vec::new();
if is_visible {
tags.push("primary");
}
if f.relation.is_some() {
tags.push("relation");
}
let tag_html = if tags.is_empty() {
String::new()
} else {
format!(" <small>{}</small>", tags.join(" · "))
};
format!(
r#"<label class="rio-cols-panel-row"><input type="checkbox" class="rio-cols-check" data-col="{name}"{checked}{disabled}><span>{label}{tags}</span></label>"#,
name = escape_html(f.name),
label = escape_html(&humanise(f.name)),
tags = tag_html,
)
})
.collect();
format!(
r#"<details class="rio-cols-ctl"><summary class="rio-btn">Columns</summary><div class="rio-cols-panel">{rows}</div></details>"#,
)
}
fn build_list_url(admin_name: &str, params: &[(String, String)]) -> String {
if params.is_empty() {
return format!("/admin/{}", admin_name);
}
let query: String = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
format!("/admin/{}?{}", admin_name, query)
}
fn current_filter_params(filters: &ListFilters<'_>) -> Vec<(String, String)> {
let mut params: Vec<(String, String)> = Vec::new();
if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
params.push(("q".to_string(), q.to_string()));
}
if let Some(s) = filters.status {
params.push(("status".to_string(), s.to_string()));
}
if let Some(p) = filters.priority {
params.push(("priority".to_string(), p.to_string()));
}
for r in filters.relation_filters {
if let Some(id) = r.current_value {
params.push((r.field_name.clone(), id.to_string()));
}
}
if let Some(sort) = filters.sort {
params.push(("sort".to_string(), sort.to_string()));
}
params
}
fn render_active_filter_chips(filters: &ListFilters<'_>, admin_name: &str) -> String {
let all_params = current_filter_params(filters);
let remove_url = |exclude_key: &str| -> String {
let kept: Vec<(String, String)> = all_params
.iter()
.filter(|(k, _)| k != exclude_key)
.cloned()
.collect();
build_list_url(admin_name, &kept)
};
let mut chips: Vec<String> = Vec::new();
if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
chips.push(format!(
r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Search:</span> <span class="admin-filter-chip-value">"{value}"</span> <a href="{href}" aria-label="Remove search filter">×</a></span>"#,
value = escape_html(q),
href = escape_html(&remove_url("q")),
));
}
if let Some(s) = filters.status {
chips.push(format!(
r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Status:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove status filter">×</a></span>"#,
value = escape_html(&humanise_enum_value(s)),
href = escape_html(&remove_url("status")),
));
}
if let Some(p) = filters.priority {
chips.push(format!(
r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Priority:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove priority filter">×</a></span>"#,
value = escape_html(p),
href = escape_html(&remove_url("priority")),
));
}
for r in filters.relation_filters {
if let Some(id) = r.current_value {
let display = match &r.mode {
RelationFilterMode::Dropdown { options } => options
.iter()
.find(|(opt_id, _)| *opt_id == id)
.map(|(_, name)| name.clone())
.unwrap_or_else(|| format!("#{}", id)),
RelationFilterMode::Numeric { .. } => format!("#{}", id),
};
chips.push(format!(
r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">{label}:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove {label} filter">×</a></span>"#,
label = escape_html(&r.label),
value = escape_html(&display),
href = escape_html(&remove_url(&r.field_name)),
));
}
}
if chips.is_empty() {
return String::new();
}
format!(
r#"<div class="admin-filter-chips">{chips}<a class="admin-filter-clear-all" href="/admin/{name}">Clear all</a></div>"#,
chips = chips.join(""),
name = escape_html(admin_name),
)
}
fn render_more_filters_panel(
status_select: &str,
priority_select: &str,
secondary_relations_html: &str,
) -> String {
if status_select.is_empty() && priority_select.is_empty() && secondary_relations_html.is_empty()
{
return String::new();
}
format!(
r#"<div class="admin-list-more-filters" id="more-filters-panel" hidden><div class="admin-filter-grid">{status}{priority}{relations}</div></div>"#,
status = status_select,
priority = priority_select,
relations = secondary_relations_html,
)
}
fn render_list_toolbar<T: AdminModel>(
filters: &ListFilters<'_>,
shown: usize,
total: usize,
) -> String {
let admin_name = T::ADMIN_NAME;
let plural = T::DISPLAY_NAME;
let q_value = filters.q.map(escape_html).unwrap_or_default();
let status_select = if !filters.status_options.is_empty() {
let options: String =
std::iter::once(r#"<option value="">All statuses</option>"#.to_string())
.chain(filters.status_options.iter().map(|v| {
let selected = if filters.status.map(|s| s == v).unwrap_or(false) {
" selected"
} else {
""
};
format!(
r#"<option value="{v}"{selected}>{label}</option>"#,
v = escape_html(v),
label = escape_html(&humanise_enum_value(v)),
)
}))
.collect();
format!(
r#"<select class="rio-select" name="status" aria-label="Filter by status">{options}</select>"#,
)
} else {
String::new()
};
let priority_select = if !filters.priority_options.is_empty() {
let mut sorted_priorities: Vec<&String> = filters.priority_options.iter().collect();
sorted_priorities.sort_by(|a, b| {
let na: Option<i64> = a.parse().ok();
let nb: Option<i64> = b.parse().ok();
match (na, nb) {
(Some(x), Some(y)) => x.cmp(&y),
_ => a.cmp(b),
}
});
let options: String =
std::iter::once(r#"<option value="">All priorities</option>"#.to_string())
.chain(sorted_priorities.iter().map(|v| {
let selected = if filters.priority.map(|p| p == v.as_str()).unwrap_or(false) {
" selected"
} else {
""
};
format!(
r#"<option value="{v}"{selected}>Priority {v}</option>"#,
v = escape_html(v),
)
}))
.collect();
format!(
r#"<select class="rio-select" name="priority" aria-label="Filter by priority">{options}</select>"#,
)
} else {
String::new()
};
let (primary_relation_html, secondary_relations_html): (String, String) = {
let mut iter = filters.relation_filters.iter();
let first = iter
.next()
.map(render_relation_filter_control)
.unwrap_or_default();
let rest: String = iter.map(render_relation_filter_control).collect();
(first, rest)
};
let secondary_active_count: usize = filters.status.is_some() as usize
+ filters.priority.is_some() as usize
+ filters
.relation_filters
.iter()
.skip(1)
.filter(|r| r.current_value.is_some())
.count();
let more_filters_panel_html =
render_more_filters_panel(&status_select, &priority_select, &secondary_relations_html);
let more_filters_btn = if more_filters_panel_html.is_empty() {
String::new()
} else {
let label = if secondary_active_count == 0 {
"More filters".to_string()
} else {
format!("More filters ({secondary_active_count})")
};
format!(
r#"<button type="button" class="rio-btn" data-more-filters-toggle aria-controls="more-filters-panel" aria-expanded="false">{label}</button>"#,
)
};
let reset_btn = if filters.is_active() {
format!(
r#"<a class="rio-btn rio-btn-ghost" href="/admin/{name}">Reset</a>"#,
name = escape_html(admin_name),
)
} else {
String::new()
};
let sort_select = {
let current = filters.sort.unwrap_or("newest");
let options: String = SORT_OPTIONS
.iter()
.map(|(value, label)| {
let sel = if *value == current { " selected" } else { "" };
format!(
r#"<option value="{v}"{sel}>{l}</option>"#,
v = escape_html(value),
l = escape_html(label),
)
})
.collect();
format!(
r#"<select class="rio-select rio-select-sort" name="sort" aria-label="Sort records">{options}</select>"#,
)
};
let count_label = if filters.is_active() {
format!("Showing {shown} of {total}")
} else if total == 1 {
"1 record".to_string()
} else {
format!("{total} records")
};
let intent_badge = filters
.q
.filter(|q| !q.is_empty())
.map(|q| match intelligence::classify_search(q) {
intelligence::SearchIntent::Text(_) => String::new(),
other => format!(
r#"<span class="rio-search-intent">Interpreted as: {}</span>"#,
escape_html(other.label()),
),
})
.unwrap_or_default();
let columns_control = render_columns_control::<T>(filters);
format!(
r#"<form class="rio-table-toolbar" method="get" action="/admin/{name}" role="search" aria-label="Search {plural}">
<div class="rio-search">
{search_icon}
<input type="search" name="q" value="{q}" placeholder="Search {plural_lower}…" aria-label="Search text">
{intent}
</div>
{primary_relation}
{sort}
<div class="rio-toolbar-actions">
<button type="submit" class="rio-btn rio-btn-primary">{submit_icon}<span>Search</span></button>
{more_filters_btn}
{reset}
{columns}
</div>
<div class="rio-count">{count}</div>
{more_filters_panel}
</form>"#,
name = escape_html(admin_name),
plural = escape_html(plural),
plural_lower = escape_html(&plural.to_lowercase()),
search_icon = icon_search(),
q = q_value,
intent = intent_badge,
primary_relation = primary_relation_html,
sort = sort_select,
submit_icon = icon_search(),
more_filters_btn = more_filters_btn,
reset = reset_btn,
columns = columns_control,
count = escape_html(&count_label),
more_filters_panel = more_filters_panel_html,
)
}
fn matches_query<T: AdminModel>(item: &T, needle: &str) -> bool {
let needle = needle.to_lowercase();
if item.id().to_string().contains(&needle) {
return true;
}
for f in T::FIELDS.iter() {
if matches!(f.ty, FieldType::String) {
if let Some(v) = item.field_display(f.name) {
if v.to_lowercase().contains(&needle) {
return true;
}
}
}
}
false
}
fn distinct_values<T: AdminModel>(items: &[T], field_name: &str) -> Vec<String> {
if !T::FIELDS.iter().any(|f| f.name == field_name) {
return Vec::new();
}
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for item in items {
if let Some(v) = item.field_display(field_name) {
if !v.is_empty() {
set.insert(v);
}
}
}
set.into_iter().collect()
}
fn status_pill_class(value: &str) -> &'static str {
match value {
"done" | "complete" | "completed" | "finished" | "resolved" => "rio-pill rio-pill-emerald",
"active" | "approved" | "published" | "live" => "rio-pill rio-pill-emerald",
"pending" | "todo" | "queued" | "open" | "new" => "rio-pill rio-pill-amber",
"in_progress" | "doing" | "working" | "review" | "in_review" => "rio-pill rio-pill-indigo",
"archived" | "inactive" | "closed" | "cancelled" | "canceled" => "rio-pill rio-pill-slate",
"blocked" | "failed" | "rejected" | "error" => "rio-pill rio-pill-rose",
_ => "rio-pill rio-pill-slate",
}
}
fn inject_data_col(cell: &str, col: &str) -> String {
let trimmed_offset = cell.len() - cell.trim_start().len();
let rest = &cell[trimmed_offset..];
if !rest.starts_with("<td") {
return cell.to_string();
}
let (leading_ws, after_ws) = cell.split_at(trimmed_offset);
let after_td = &after_ws[3..];
format!(
r#"{leading_ws}<td data-col="{col}"{after_td}"#,
col = escape_html(col),
)
}
fn render_cell<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
let value = item.field_display(f.name).unwrap_or_default();
if f.name == "id" {
return format!(r#"<td class="rio-cell-id">#{}</td>"#, escape_html(&value));
}
if value.is_empty() && f.nullable {
return r#"<td class="rio-cell-muted">—</td>"#.to_string();
}
if let Some(resolved) = ctx.registry.belongs_to(T::singular_name(), f.name) {
if let Ok(id) = value.parse::<i64>() {
let label = ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
let admin = escape_html(&resolved.target_admin_name);
return match (label, &resolved.target_display_field) {
(Some(name), _) => format!(
r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></td>"#,
admin = admin,
id = id,
name = escape_html(name),
),
(None, Some(_)) => format!(
r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
admin = admin,
id = id,
),
(None, None) => format!(
r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
admin = admin,
id = id,
),
};
}
}
let ctx = intelligence::context_global();
let ui = intelligence::field_ui_metadata(f, ctx);
if ui.sensitive && !value.is_empty() {
let masked = intelligence::mask_pii(&value);
return format!(
r#"<td class="rio-cell-muted">\
<span class="rio-pii" data-value="{real}" data-mask="{mask}" data-hidden="1">{mask}</span>\
<button class="rio-pii-toggle" type="button" aria-label="Reveal value">show</button>\
</td>"#,
real = escape_html(&value),
mask = escape_html(&masked),
);
}
if matches!(f.ty, FieldType::Bool) {
let (cls, label) = match value.as_str() {
"true" => ("rio-pill rio-pill-emerald", "active"),
"false" => ("rio-pill rio-pill-slate", "inactive"),
other => ("rio-pill rio-pill-slate", other),
};
return format!(
r#"<td><span class="{cls}">{}</span></td>"#,
escape_html(label)
);
}
if f.name == "status" && matches!(f.ty, FieldType::String) {
let cls = status_pill_class(&value);
let label = value.replace('_', " ");
return format!(
r#"<td><span class="{cls}">{}</span></td>"#,
escape_html(&label)
);
}
if matches!(f.ty, FieldType::I32 | FieldType::I64) {
return format!(r#"<td class="rio-cell-num">{}</td>"#, escape_html(&value));
}
let is_primary = f.name != "id"
&& T::FIELDS
.iter()
.find(|x| x.name != "id")
.map(|first| first.name == f.name)
.unwrap_or(false);
let cls = if is_primary {
"rio-cell-primary"
} else {
"rio-cell-muted"
};
format!(r#"<td class="{cls}">{}</td>"#, escape_html(&value))
}
fn render_cell_inner<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
let cell = render_cell::<T>(f, item, ctx);
let start = cell.find('>').map(|i| i + 1).unwrap_or(0);
let end = cell.rfind("</td>").unwrap_or(cell.len());
cell[start..end].to_string()
}
enum FormMode<'a, T: AdminModel> {
Create,
Edit { id: i64, item: &'a T },
}
fn form_response<T: AdminModel>(
shell: Shell<'_>,
mode: FormMode<'_, T>,
cell_ctx: &CellCtx<'_>,
inverse_counts: &InverseCounts,
form_options: &FormRelationOptions,
) -> Response {
let plural = T::DISPLAY_NAME;
let singular = T::singular_name();
let admin_name = T::ADMIN_NAME;
let (heading, doc_title, subtitle, action, back_label) = match &mode {
FormMode::Create => (
format!("New {singular}"),
format!("New {singular}"),
format!("Create a new {} record.", singular.to_lowercase()),
format!("/admin/{admin_name}/create"),
format!("Back to {}", plural.to_lowercase()),
),
FormMode::Edit { id, .. } => (
format!("Edit {singular}"),
format!("Edit {singular} #{id}"),
format!("Update this {} record.", singular.to_lowercase()),
format!("/admin/{admin_name}/{id}/edit"),
format!("Back to {}", plural.to_lowercase()),
),
};
let fields: String = T::FIELDS
.iter()
.filter(|f| f.editable)
.map(|f| {
render_field_block::<T>(
f,
match &mode {
FormMode::Create => None,
FormMode::Edit { item, .. } => Some(*item),
},
cell_ctx,
form_options,
)
})
.collect();
let meta_block = match &mode {
FormMode::Create => String::new(),
FormMode::Edit { id, item } => render_meta::<T>(*id, item),
};
let inverse_panel = match &mode {
FormMode::Create => String::new(),
FormMode::Edit { id, .. } => {
render_inverse_panel::<T>(cell_ctx.registry, inverse_counts, *id)
}
};
let danger_zone = match &mode {
FormMode::Create => String::new(),
FormMode::Edit { id, .. } => format!(
r#"<section class="rio-danger-zone">
<div class="rio-danger-copy">
<h3 class="rio-danger-title">{warn}<span>Delete this {singular}</span></h3>
<p class="rio-danger-hint">Permanently removes this record. Rows that reference it with <code>ON DELETE CASCADE</code> will also be deleted.</p>
</div>
<a class="rio-btn rio-btn-danger" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete record</span></a>
</section>"#,
warn = icon_triangle_alert(),
singular = escape_html(&singular.to_lowercase()),
name = escape_html(admin_name),
id = id,
trash = icon_trash(),
),
};
let csrf_hidden = csrf_input(shell.csrf);
let body = format!(
r#"{meta}
<form class="rio-card rio-form" method="post" action="{action}" autocomplete="off">
{csrf}
<div class="rio-form-section">
<h2 class="rio-form-section-title">Details</h2>
<p class="rio-form-section-hint">Fields marked optional accept an empty value.</p>
{fields}
</div>
<div class="rio-form-footer">
<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back_icon}<span>{back_label}</span></a>
<div class="rio-footer-actions">
<a class="rio-btn" href="/admin/{name}">Cancel</a>
<button class="rio-btn rio-btn-primary" type="submit">Save</button>
</div>
</div>
</form>
{inverse}
{danger}"#,
meta = meta_block,
action = escape_html(&action),
csrf = csrf_hidden,
fields = fields,
name = escape_html(admin_name),
back_icon = icon_arrow_left(),
back_label = escape_html(&back_label),
inverse = inverse_panel,
danger = danger_zone,
);
let plural_href = format!("/admin/{admin_name}");
let crumbs: Vec<Crumb<'_>> = match &mode {
FormMode::Create => vec![
("Admin", Some("/admin")),
(plural, Some(plural_href.as_str())),
("New", None),
],
FormMode::Edit { .. } => vec![
("Admin", Some("/admin")),
(plural, Some(plural_href.as_str())),
("Edit", None),
],
};
let page_actions = match &mode {
FormMode::Create => String::new(),
FormMode::Edit { id, .. } => format!(
r#"<a class="rio-btn" href="/admin/{name}/{id}/history">History</a>"#,
name = escape_html(admin_name),
id = id,
),
};
render_shell_page(
&shell,
200,
&doc_title,
&heading,
Some(&subtitle),
&crumbs,
&page_actions,
&body,
)
}
fn render_field_block<T: AdminModel>(
f: &AdminField,
item: Option<&T>,
cell_ctx: &CellCtx<'_>,
form_options: &FormRelationOptions,
) -> String {
let name = escape_html(f.name);
let mut ui = intelligence::field_ui_metadata(f, intelligence::context_global());
if f.relation.is_some() {
ui.hint = None;
ui.label = field_label(f);
}
let input = render_field::<T>(f, item, ui.placeholder.as_deref(), form_options);
if matches!(f.ty, FieldType::Bool) {
return format!(
r#"<div class="rio-field rio-field-row-checkbox">
{input}
<label for="_{name}">{label}</label>
</div>"#,
input = input,
name = name,
label = escape_html(&ui.label),
);
}
let optional_mark = if f.nullable {
r#"<span class="rio-field-optional">optional</span>"#.to_string()
} else {
String::new()
};
let sensitive_mark = if ui.sensitive {
let note = ui
.sensitivity_note
.as_deref()
.unwrap_or("Personal data — handle with care.");
format!(
r#"<span class="rio-field-sensitive" title="{note}">🔒 PII</span>"#,
note = escape_html(note),
)
} else {
String::new()
};
let hint_html = match ui.hint.as_deref() {
Some(h) => format!(r#"<p class="rio-field-hint">{}</p>"#, escape_html(h),),
None => String::new(),
};
let relation_hint = render_relation_hint::<T>(f, item, cell_ctx);
format!(
r#"<div class="rio-field">
<label for="_{name}">{label}{optional}{sensitive}</label>
{input}
{rel}
{hint}
</div>"#,
name = name,
label = escape_html(&ui.label),
optional = optional_mark,
sensitive = sensitive_mark,
rel = relation_hint,
hint = hint_html,
)
}
fn render_relation_hint<T: AdminModel>(
f: &AdminField,
item: Option<&T>,
cell_ctx: &CellCtx<'_>,
) -> String {
let Some(item) = item else {
return String::new();
};
if f.relation.is_none() {
return String::new();
}
let Some(resolved) = cell_ctx.registry.belongs_to(T::singular_name(), f.name) else {
return String::new();
};
let Some(value) = item.field_display(f.name) else {
return String::new();
};
let Ok(id) = value.parse::<i64>() else {
return String::new();
};
let label = cell_ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
let admin = escape_html(&resolved.target_admin_name);
match (label, &resolved.target_display_field) {
(Some(name), _) => format!(
r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></p>"#,
admin = admin,
id = id,
name = escape_html(name),
),
(None, _) => format!(
r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">#{id}</a></p>"#,
admin = admin,
id = id,
),
}
}
fn is_foreign_key_violation(e: &Error) -> bool {
matches!(e, Error::Internal(msg) if msg.contains("FOREIGN KEY constraint failed"))
}
fn render_delete_blocked_page<T: AdminModel>(
shell: &Shell<'_>,
target_id: i64,
target_primary: &str,
blockers: &[(&relations::InverseRelation, i64)],
) -> Response {
let singular = T::singular_name();
let admin_name = T::ADMIN_NAME;
let plural = T::DISPLAY_NAME;
let subject = if target_primary.is_empty() {
format!("{singular} #{target_id}")
} else {
format!("{target_primary} (#{target_id})")
};
let rows: String = blockers
.iter()
.map(|(inv, count)| {
let filter_url = format!(
"/admin/{}?{}={}",
inv.source_admin_name,
urlencoding_light(&inv.source_field),
target_id,
);
format!(
r#"<li class="rio-dashboard-alert"><div><strong>{label}</strong> — referenced by <strong>{count}</strong> row{plural_s} via <code>{field}</code></div><a class="rio-btn rio-btn-sm" href="{url}">Open {label_lower}</a></li>"#,
label = escape_html(&inv.source_display_name),
label_lower = escape_html(&inv.source_display_name.to_lowercase()),
field = escape_html(&inv.source_field),
count = count,
plural_s = if *count == 1 { "" } else { "s" },
url = escape_html(&filter_url),
)
})
.collect();
let back_href = format!("/admin/{}", admin_name);
let body = format!(
r#"<section class="rio-card">
<div class="rio-card-header">
<h2 class="rio-card-title">Cannot delete {subject}</h2>
<p class="rio-card-subtitle">Other records reference this one. Remove or reassign them first, then retry the delete.</p>
</div>
<ul class="rio-dashboard-alerts" style="list-style:none; margin:0; padding:var(--rio-card-pad)">
{rows}
</ul>
<div class="rio-form-footer">
<a class="rio-btn" href="{back}">Back to {plural_lower}</a>
</div>
</section>"#,
subject = escape_html(&subject),
rows = rows,
back = escape_html(&back_href),
plural_lower = escape_html(&plural.to_lowercase()),
);
let plural_href = back_href.clone();
let crumbs: Vec<Crumb<'_>> = vec![
("Admin", Some("/admin")),
(plural, Some(plural_href.as_str())),
("Delete blocked", None),
];
let doc_title = format!("Cannot delete {subject}");
render_shell_page(
shell,
409,
&doc_title,
"Delete blocked",
Some("Remove the dependent references first, then retry."),
&crumbs,
"",
&body,
)
}
fn render_inverse_panel<T: AdminModel>(
registry: &relations::RelationRegistry,
counts: &InverseCounts,
target_id: i64,
) -> String {
let inverses = registry.has_many(T::singular_name());
if inverses.is_empty() {
return String::new();
}
let cards: String = inverses
.iter()
.map(|inv| {
let key = format!("{}.{}", inv.source_model, inv.source_field);
let count = counts.get(&key).copied().unwrap_or(0);
let label = inv.source_display_name.to_string();
let filter_url = format!(
"/admin/{}?{}={}",
inv.source_admin_name,
urlencoding_light(&inv.source_field),
target_id,
);
format!(
r#"<li><a href="{url}" class="rio-suggestion-card"><div><strong>{label}</strong> <span class="rio-cell-id">({count})</span></div><div class="rio-cell-muted">via {field}</div></a></li>"#,
url = escape_html(&filter_url),
label = escape_html(&label),
count = count,
field = escape_html(&inv.source_field),
)
})
.collect();
format!(
r#"<section class="rio-card rio-related">
<div class="rio-card-header">
<h2 class="rio-card-title">Related</h2>
<p class="rio-card-subtitle">Incoming references to this record.</p>
</div>
<ul class="rio-related-grid">
{cards}
</ul>
</section>"#,
cards = cards,
)
}
fn urlencoding_light(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
' ' => out.push_str("%20"),
'&' => out.push_str("%26"),
'=' => out.push_str("%3D"),
'#' => out.push_str("%23"),
'?' => out.push_str("%3F"),
_ => out.push(ch),
}
}
out
}
fn humanise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut next_upper = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
next_upper = true;
} else if next_upper {
out.push(ch.to_ascii_uppercase());
next_upper = false;
} else {
out.push(ch);
}
}
out
}
fn humanise_enum_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut first = true;
for ch in s.chars() {
if ch == '_' {
out.push(' ');
} else if first {
out.push(ch.to_ascii_uppercase());
first = false;
} else {
out.push(ch.to_ascii_lowercase());
}
}
out
}
fn pluralise_label(s: &str) -> String {
let lower = s.to_lowercase();
if lower.ends_with('s')
|| lower.ends_with('x')
|| lower.ends_with("ch")
|| lower.ends_with("sh")
{
format!("{lower}es")
} else if lower.ends_with('y')
&& !lower.ends_with("ay")
&& !lower.ends_with("ey")
&& !lower.ends_with("iy")
&& !lower.ends_with("oy")
&& !lower.ends_with("uy")
{
format!("{}ies", &lower[..lower.len() - 1])
} else {
format!("{lower}s")
}
}
const DEFAULT_VISIBLE_COLUMNS: usize = 5;
const NAME_LIKE_FIELDS: &[&str] = &["name", "full_name", "title", "email"];
pub(crate) fn is_primary_column(f: &AdminField) -> bool {
if f.name == "id" {
return true;
}
if f.relation.is_some() && f.name.ends_with("_id") {
return true;
}
if matches!(f.ty, FieldType::Bool) && f.name.starts_with("is_") {
return true;
}
if matches!(f.name, "status" | "state" | "priority") {
return true;
}
false
}
pub(crate) fn default_list_columns<T: AdminModel>() -> Vec<&'static str> {
let fields = T::FIELDS;
let mut picked: Vec<&'static str> = Vec::with_capacity(DEFAULT_VISIBLE_COLUMNS);
let mut name_rule_used = false;
for f in fields {
if picked.len() >= DEFAULT_VISIBLE_COLUMNS {
break;
}
let hits_name_rule = !name_rule_used && NAME_LIKE_FIELDS.contains(&f.name);
if is_primary_column(f) || hits_name_rule {
picked.push(f.name);
if hits_name_rule {
name_rule_used = true;
}
}
}
picked
}
fn field_label(f: &AdminField) -> String {
let base = humanise(f.name);
if f.relation.is_some() {
base.strip_suffix(" Id").map(str::to_string).unwrap_or(base)
} else {
base
}
}
fn render_meta<T: AdminModel>(id: i64, item: &T) -> String {
let mut items = vec![format!(
r#"<div class="rio-meta-item">
<span class="rio-meta-label">ID</span>
<span class="rio-meta-value">#{id}</span>
</div>"#,
)];
for f in T::FIELDS.iter() {
if f.editable || f.name == "id" {
continue;
}
let value = item.field_display(f.name).unwrap_or_default();
let shown = if value.is_empty() {
"—".to_string()
} else {
value
};
items.push(format!(
r#"<div class="rio-meta-item">
<span class="rio-meta-label">{label}</span>
<span class="rio-meta-value">{value}</span>
</div>"#,
label = escape_html(&humanise(f.name)),
value = escape_html(&shown),
));
}
format!(r#"<div class="rio-meta">{}</div>"#, items.join(""))
}
fn render_field<T: AdminModel>(
f: &AdminField,
item: Option<&T>,
placeholder: Option<&str>,
form_options: &FormRelationOptions,
) -> String {
let current = item
.and_then(|i| i.field_display(f.name))
.unwrap_or_default();
let n = escape_html(f.name);
let v = escape_html(¤t);
let required = if !f.nullable && !matches!(f.ty, FieldType::Bool) {
" required"
} else {
""
};
let placeholder_attr = match placeholder {
Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, escape_html(p)),
_ => String::new(),
};
if matches!(f.ty, FieldType::I32 | FieldType::I64) {
if let Some(options) = form_options.get(f.name) {
let none_opt = if f.nullable {
r#"<option value="">— none —</option>"#
} else {
r#"<option value="" disabled selected>Select…</option>"#
};
let options_html: String = options
.iter()
.map(|(id, label)| {
let selected = if current == id.to_string() {
" selected"
} else {
""
};
format!(
r#"<option value="{id}"{selected}>{label}</option>"#,
id = id,
selected = selected,
label = escape_html(label),
)
})
.collect();
let none_opt = if !current.is_empty() && !f.nullable {
r#"<option value="" disabled>Select…</option>"#
} else {
none_opt
};
return format!(
r#"<select class="rio-input rio-select" id="_{n}" name="{n}"{required}>{none}{opts}</select>"#,
n = n,
required = required,
none = none_opt,
opts = options_html,
);
}
}
match f.ty {
FieldType::Bool => format!(
r#"<input class="rio-checkbox" id="_{n}" type="checkbox" name="{n}" {checked}>"#,
checked = if current == "true" { "checked" } else { "" },
),
FieldType::I32 | FieldType::I64 => {
format!(
r#"<input class="rio-input" id="_{n}" type="number" name="{n}" value="{v}"{required}{placeholder_attr}>"#
)
}
FieldType::String => {
format!(
r#"<input class="rio-input" id="_{n}" type="text" name="{n}" value="{v}"{required}{placeholder_attr}>"#
)
}
FieldType::DateTime => {
format!(
r#"<input class="rio-input" id="_{n}" type="datetime-local" name="{n}" value="{v}"{required}{placeholder_attr}>"#
)
}
}
}
fn delete_confirmation_response<T: AdminModel>(shell: Shell<'_>, id: i64, item: &T) -> Response {
let singular = T::singular_name();
let plural = T::DISPLAY_NAME;
let admin_name = T::ADMIN_NAME;
let summary = T::FIELDS
.iter()
.find(|f| f.editable && matches!(f.ty, FieldType::String))
.and_then(|f| item.field_display(f.name))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("#{id}"));
let csrf_hidden = csrf_input(shell.csrf);
let ctx = intelligence::context_global();
let has_pii = T::FIELDS
.iter()
.any(|f| intelligence::field_ui_metadata(f, ctx).sensitive);
let pii_banner = if has_pii {
let note = if ctx.is_some_and(|c| c.requires_gdpr()) {
"This record contains personal data (GDPR). Deletion is typically irreversible — verify you have the right to erase."
} else {
"This record contains fields flagged as personal data. Review before proceeding."
};
format!(
r#"<div class="rio-alert rio-alert-error">{icon}<div><strong>Sensitive data.</strong> {note}</div></div>"#,
icon = icon_shield_alert(),
note = escape_html(note),
)
} else {
String::new()
};
let body = format!(
r#"<div class="rio-card">
<div class="rio-card-body">
{pii_banner}
<div class="rio-alert rio-alert-warn">
{warn}
<div>
<strong>This action cannot be undone.</strong>
Deleting this record removes it permanently. Rows that reference it via a foreign key with <code>ON DELETE CASCADE</code> will be deleted too.
</div>
</div>
<p>You are about to delete <strong>{singular}</strong>:</p>
<div class="rio-meta">
<div class="rio-meta-item">
<span class="rio-meta-label">ID</span>
<span class="rio-meta-value">#{id}</span>
</div>
<div class="rio-meta-item">
<span class="rio-meta-label">Summary</span>
<span class="rio-meta-value">{summary}</span>
</div>
</div>
</div>
<div class="rio-form-footer">
<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Back to {plural_lower}</span></a>
<div class="rio-footer-actions">
<a class="rio-btn" href="/admin/{name}/{id}/edit">Cancel</a>
<form class="rio-inline-form" method="post" action="/admin/{name}/{id}/delete">
{csrf}
<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Delete {singular}</span></button>
</form>
</div>
</div>
</div>"#,
warn = icon_triangle_alert(),
singular = escape_html(singular),
id = id,
summary = escape_html(&summary),
name = escape_html(admin_name),
back = icon_arrow_left(),
plural_lower = escape_html(&plural.to_lowercase()),
csrf = csrf_hidden,
trash = icon_trash(),
);
let plural_href = format!("/admin/{admin_name}");
let crumbs: &[Crumb<'_>] = &[
("Admin", Some("/admin")),
(plural, Some(&plural_href)),
("Delete", None),
];
render_shell_page(
&shell,
200,
&format!("Delete {singular}"),
&format!("Delete {singular}?"),
Some("Confirm you want to remove this record."),
crumbs,
"",
&body,
)
}
fn bulk_delete_confirmation_response<T: AdminModel>(
shell: &Shell<'_>,
items: &[(i64, String)],
) -> Response {
let singular = T::singular_name();
let plural = T::DISPLAY_NAME;
let admin_name = T::ADMIN_NAME;
let csrf_hidden = csrf_input(shell.csrf);
let count = items.len();
let count_label = if count == 1 {
format!("1 {}", singular.to_lowercase())
} else {
format!("{count} {}", plural.to_lowercase())
};
let selected_csv: String = items
.iter()
.map(|(id, _)| id.to_string())
.collect::<Vec<_>>()
.join(",");
let rows: String = items
.iter()
.map(|(id, primary)| {
let label = if primary.is_empty() {
format!("#{id}")
} else {
format!("#{id} · {primary}")
};
format!(
r#"<li class="rio-bulk-item">{label}</li>"#,
label = escape_html(&label),
)
})
.collect();
let body = format!(
r#"<div class="rio-card">
<div class="rio-card-body">
<div class="rio-alert rio-alert-warn">
{warn}
<div>
<strong>This action cannot be undone.</strong>
You are about to delete <strong>{count_label}</strong>. Each record removed here is logged individually in <a href="/admin/actions">Recent actions</a>.
</div>
</div>
<p>Review the list, then confirm:</p>
<ul class="rio-bulk-list">{rows}</ul>
</div>
<form method="post" action="/admin/{name}/bulk_action" class="rio-form-footer">
{csrf}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="_selected" value="{selected}">
<input type="hidden" name="_confirm" value="yes">
<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Cancel</span></a>
<div class="rio-footer-actions">
<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Yes, delete {count_label}</span></button>
</div>
</form>
</div>"#,
warn = icon_triangle_alert(),
count_label = escape_html(&count_label),
rows = rows,
name = escape_html(admin_name),
csrf = csrf_hidden,
selected = escape_html(&selected_csv),
back = icon_arrow_left(),
trash = icon_trash(),
);
let plural_href = format!("/admin/{admin_name}");
let crumbs: &[Crumb<'_>] = &[
("Admin", Some("/admin")),
(plural, Some(&plural_href)),
("Delete selected", None),
];
render_shell_page(
shell,
200,
&format!("Delete selected {}", plural.to_lowercase()),
&format!("Delete selected {}?", plural.to_lowercase()),
Some("Confirm you want to remove these records."),
crumbs,
"",
&body,
)
}
fn new_request_id() -> String {
use rand::RngCore as _;
let mut buf = [0u8; 6];
rand::rngs::OsRng.fill_bytes(&mut buf);
let mut s = String::with_capacity(12);
for b in buf {
s.push_str(&format!("{b:02x}"));
}
s
}
fn error_shell<'a>(
entries: &'a [AdminEntry],
email: Option<&'a str>,
csrf: Option<&'a str>,
) -> Shell<'a> {
Shell {
entries,
active: None,
user_email: email,
csrf,
}
}
fn admin_not_found_response(
_entries: &[AdminEntry],
_email: Option<&str>,
csrf: Option<&str>,
) -> Response {
let design = design::Design::global();
let env = crate::admin::templating::env();
let body = match env.get_template("auth/not_found.html").and_then(|tmpl| {
tmpl.render(minijinja::context! {
design => minijinja::context! {
project_name => design.project_name.as_str(),
logo_initial => design.logo_initial.as_str(),
},
csrf_token => csrf.unwrap_or(""),
})
}) {
Ok(html) => html,
Err(err) => {
eprintln!("admin not-found template render failed: {err}");
format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>404 Not Found · {p}</title></head><body style=\"font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center\"><p>404 Not Found</p><h1>We couldn't find that page.</h1><p><a href=\"/admin\">Back to dashboard</a></p></body></html>",
p = escape_html(&design.project_name),
)
}
};
let resp = hyper::Response::builder()
.status(404)
.header("content-type", "text/html; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.expect("valid response");
with_admin_headers(resp)
}
fn admin_server_error_response(
entries: &[AdminEntry],
email: Option<&str>,
csrf: Option<&str>,
request_id: &str,
) -> Response {
let shell = error_shell(entries, email, csrf);
let when = Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
let body = format!(
r#"<div class="rio-card">
<div class="rio-card-body">
<div class="rio-alert rio-alert-error">
{icon}
<div>
<strong>Something went wrong.</strong>
The admin could not complete your request. The detail has been logged server-side; the summary below is what to share when reporting.
</div>
</div>
<div class="rio-meta">
<div class="rio-meta-item">
<span class="rio-meta-label">Request ID</span>
<span class="rio-meta-value"><code>{rid}</code></span>
</div>
<div class="rio-meta-item">
<span class="rio-meta-label">Timestamp</span>
<span class="rio-meta-value">{when}</span>
</div>
</div>
<div class="rio-error-actions">
<a class="rio-btn" href="/admin">{back}<span>Back to dashboard</span></a>
</div>
</div>
</div>"#,
icon = icon_triangle_alert(),
rid = escape_html(request_id),
when = escape_html(&when),
back = icon_arrow_left(),
);
let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), ("Server error", None)];
render_shell_page(
&shell,
500,
"500 Server Error",
"500 · Server error",
Some("The admin could not complete your request."),
crumbs,
"",
&body,
)
}
async fn admin_model_index_get(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
req: Request,
params: crate::router::Params,
) -> Result<Response, Error> {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let model_slug = params.get("model").unwrap_or("").to_string();
enum ResolvedModel {
New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
Legacy(crate::admin::layout::LegacyEntryModel),
}
let resolved = if let Some(model) = registry.get(&model_slug) {
ResolvedModel::New(model)
} else if let Some(entry) = legacy_entries
.iter()
.find(|e| !e.core && e.admin_name == model_slug)
{
ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
} else {
return Err(Error::NotFound);
};
let q_map = req.query().into_map();
let id = q_map.get("id").filter(|s| !s.is_empty()).cloned();
let query = q_map
.get("q")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(String::from);
let page = q_map
.get("page")
.and_then(|p| p.parse::<i64>().ok())
.filter(|p| *p > 0)
.unwrap_or(1);
let sort = q_map.get("sort").filter(|s| !s.is_empty()).cloned();
let dir = q_map.get("dir").filter(|s| !s.is_empty()).cloned();
let filters: std::collections::HashMap<String, String> = q_map
.iter()
.filter(|(k, v)| {
!v.is_empty()
&& k.as_str() != "q"
&& k.as_str() != "page"
&& k.as_str() != "id"
&& k.as_str() != "sort"
&& k.as_str() != "dir"
&& k.as_str() != "advanced"
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let _ = q_map
.get("advanced")
.map(|s| !s.is_empty())
.unwrap_or(false);
let _ = id;
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = match &resolved {
ResolvedModel::New(model) => {
crate::admin::layout::list_render(
db,
registry,
legacy_entries,
&**model,
None,
query.as_deref(),
page,
&filters,
sort.as_deref(),
dir.as_deref(),
identity.as_ref(),
csrf.as_deref(),
)
.await
}
ResolvedModel::Legacy(model) => {
let source = model.source_entry().clone();
crate::admin::layout::list_render(
db,
registry,
legacy_entries,
model,
Some(&source),
query.as_deref(),
page,
&filters,
sort.as_deref(),
dir.as_deref(),
identity.as_ref(),
csrf.as_deref(),
)
.await
}
};
Ok(with_admin_headers(crate::http::html(html)))
}
async fn admin_model_form_get(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
req: Request,
params: crate::router::Params,
editing_id: Option<&str>,
) -> Result<Response, Error> {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let model_slug = params.get("model").unwrap_or("").to_string();
enum ResolvedModel {
New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
Legacy(crate::admin::layout::LegacyEntryModel),
}
let resolved = if let Some(model) = registry.get(&model_slug) {
ResolvedModel::New(model)
} else if let Some(entry) = legacy_entries
.iter()
.find(|e| !e.core && e.admin_name == model_slug)
{
ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
} else {
return Err(Error::NotFound);
};
let identity = crate::auth::identity(req.ctx()).cloned();
let csrf = ctx_csrf(req.ctx()).map(str::to_string);
let html = match &resolved {
ResolvedModel::New(model) => {
crate::admin::layout::form_render(
db,
registry,
legacy_entries,
&**model,
None,
editing_id,
identity.as_ref(),
csrf.as_deref(),
None,
)
.await
}
ResolvedModel::Legacy(model) => {
let source = model.source_entry().clone();
crate::admin::layout::form_render(
db,
registry,
legacy_entries,
model,
Some(&source),
editing_id,
identity.as_ref(),
csrf.as_deref(),
None,
)
.await
}
};
Ok(with_admin_headers(crate::http::html(html)))
}
fn resolve_form_model(
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
slug: &str,
) -> Result<FormResolvedModel, Error> {
if let Some(model) = registry.get(slug) {
return Ok(FormResolvedModel::New(model));
}
if let Some(entry) = legacy_entries
.iter()
.find(|e| !e.core && e.admin_name == slug)
{
return Ok(FormResolvedModel::Legacy(
crate::admin::layout::LegacyEntryModel::new(entry),
));
}
Err(Error::NotFound)
}
enum FormResolvedModel {
New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
Legacy(crate::admin::layout::LegacyEntryModel),
}
impl FormResolvedModel {
fn as_ui_model(&self) -> &dyn crate::admin::admin_form_bridge::AdminUiModel {
match self {
FormResolvedModel::New(m) => &**m,
FormResolvedModel::Legacy(m) => m,
}
}
fn legacy_source(&self) -> Option<&AdminEntry> {
match self {
FormResolvedModel::New(_) => None,
FormResolvedModel::Legacy(m) => Some(m.source_entry()),
}
}
}
fn build_mutation_data(
model: &dyn crate::admin::admin_form_bridge::AdminUiModel,
form: &FormData,
) -> std::collections::HashMap<String, String> {
let pk = model.primary_key();
let mut out = std::collections::HashMap::new();
for field in model.fields() {
if field.name == pk || field.readonly {
continue;
}
let value = form.get(field.name).unwrap_or("");
out.insert(field.name.to_string(), value.to_string());
}
out
}
async fn admin_model_create_post(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
req: Request,
params: crate::router::Params,
) -> Result<Response, Error> {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let model_slug = params.get("model").unwrap_or("").to_string();
let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let data = build_mutation_data(resolved.as_ui_model(), &form);
match crate::admin::persistence::insert_record(db, resolved.as_ui_model().table_name(), &data)
.await
{
Ok(_) => Ok(with_admin_headers(redirect(&format!(
"/admin/{model_slug}"
)))),
Err(e) => {
let identity = crate::auth::identity(&ctx).cloned();
let csrf = ctx_csrf(&ctx).map(str::to_string);
let error_msg = format!("Could not create: {e}");
let source = resolved.legacy_source().cloned();
let html = crate::admin::layout::form_render(
db,
registry,
legacy_entries,
resolved.as_ui_model(),
source.as_ref(),
None,
identity.as_ref(),
csrf.as_deref(),
Some(&error_msg),
)
.await;
Ok(with_admin_headers(crate::http::html(html)))
}
}
}
async fn admin_model_update_post(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
req: Request,
params: crate::router::Params,
) -> Result<Response, Error> {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let model_slug = params.get("model").unwrap_or("").to_string();
let id = params.get("id").unwrap_or("").to_string();
if id.is_empty() {
return Err(Error::BadRequest("missing id".into()));
}
let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let data = build_mutation_data(resolved.as_ui_model(), &form);
match crate::admin::persistence::update_record(
db,
resolved.as_ui_model().table_name(),
&id,
&data,
)
.await
{
Ok(_) => Ok(with_admin_headers(redirect(&format!(
"/admin/{model_slug}"
)))),
Err(e) => {
let identity = crate::auth::identity(&ctx).cloned();
let csrf = ctx_csrf(&ctx).map(str::to_string);
let error_msg = format!("Could not update: {e}");
let source = resolved.legacy_source().cloned();
let html = crate::admin::layout::form_render(
db,
registry,
legacy_entries,
resolved.as_ui_model(),
source.as_ref(),
Some(&id),
identity.as_ref(),
csrf.as_deref(),
Some(&error_msg),
)
.await;
Ok(with_admin_headers(crate::http::html(html)))
}
}
}
async fn admin_model_delete_post(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
req: Request,
params: crate::router::Params,
) -> Result<Response, Error> {
if let Err(resp) = admin_guard(req.ctx()) {
return Ok(resp);
}
let model_slug = params.get("model").unwrap_or("").to_string();
let id = params.get("id").unwrap_or("").to_string();
if id.is_empty() {
return Err(Error::BadRequest("missing id".into()));
}
let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
crate::admin::persistence::bulk_delete(
db,
resolved.as_ui_model().table_name(),
std::slice::from_ref(&id),
)
.await?;
Ok(with_admin_headers(redirect(&format!(
"/admin/{model_slug}"
))))
}
#[allow(clippy::result_large_err)]
fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
match crate::auth::require_admin(ctx) {
Ok(_) => Ok(()),
Err(Error::Unauthorized) => Err(login_page(401, None, None)),
Err(Error::Forbidden) => Err(forbidden_page(ctx_csrf(ctx))),
Err(other) => Err(other.into_response()),
}
}
fn login_page(status: u16, email: Option<&str>, error: Option<&str>) -> Response {
let design = design::Design::global();
let env = crate::admin::templating::env();
let body = match env.get_template("auth/login.html").and_then(|tmpl| {
tmpl.render(minijinja::context! {
design => minijinja::context! {
project_name => design.project_name.as_str(),
logo_initial => design.logo_initial.as_str(),
},
email => email.unwrap_or(""),
error => error,
})
}) {
Ok(html) => html,
Err(err) => {
eprintln!("admin login template render failed: {err}");
login_page_fallback(&design.project_name, email, error)
}
};
let resp = hyper::Response::builder()
.status(status)
.header("content-type", "text/html; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.expect("valid response");
with_admin_headers(resp)
}
fn login_page_fallback(project_name: &str, email: Option<&str>, error: Option<&str>) -> String {
let project = escape_html(project_name);
let email = email.map(escape_html).unwrap_or_default();
let error_block = match error {
Some(msg) => format!(r#"<p style="color:#b91c1c">{}</p>"#, escape_html(msg)),
None => String::new(),
};
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sign in · {project}</title></head><body style="font-family:system-ui;max-width:20rem;margin:4rem auto;padding:0 1rem">
<h1>Sign in</h1><p>{project}</p>{error_block}
<form method="post" action="/admin/login">
<p><label>Email<br><input type="email" name="email" value="{email}" autofocus required></label></p>
<p><label>Password<br><input type="password" name="password" required></label></p>
<p><button type="submit">Sign in</button></p>
</form></body></html>"#,
)
}
fn forbidden_page(csrf: Option<&str>) -> Response {
let design = design::Design::global();
let env = crate::admin::templating::env();
let body = match env.get_template("auth/forbidden.html").and_then(|tmpl| {
tmpl.render(minijinja::context! {
design => minijinja::context! {
project_name => design.project_name.as_str(),
logo_initial => design.logo_initial.as_str(),
},
csrf_token => csrf.unwrap_or(""),
})
}) {
Ok(html) => html,
Err(err) => {
eprintln!("admin forbidden template render failed: {err}");
forbidden_page_fallback(&design.project_name, csrf)
}
};
let resp = hyper::Response::builder()
.status(403)
.header("content-type", "text/html; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.expect("valid response");
with_admin_headers(resp)
}
fn forbidden_page_fallback(project_name: &str, csrf: Option<&str>) -> String {
let project = escape_html(project_name);
let csrf_input_html = match csrf {
Some(token) if !token.is_empty() => format!(
r#"<input type="hidden" name="_csrf" value="{}">"#,
escape_html(token)
),
_ => String::new(),
};
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>403 Forbidden · {project}</title></head><body style="font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center">
<p>403 Forbidden</p><h1>You're signed in, but you don't have admin access.</h1>
<form method="post" action="/admin/logout">{csrf_input_html}<button type="submit">Sign out</button></form>
</body></html>"#,
)
}
fn logout_confirmation_response(signed_in: bool, csrf: Option<&str>) -> Response {
let d = design::Design::global();
let theme_style = format!(
"\n:root {{\n --rio-primary: {p};\n --rio-accent: {a};\n}}\n",
p = escape_css_color(&d.primary_color),
a = escape_css_color(&d.accent_color),
);
let card_body = if signed_in {
let csrf_hidden = csrf_input(csrf);
format!(
r#"<h1 class="rio-auth-title">Sign out</h1>
<p class="rio-auth-subtitle">You're about to sign out of the admin.</p>
<form method="post" action="/admin/logout">
{csrf}
<button class="rio-btn rio-btn-primary rio-btn-block" type="submit">Sign out</button>
</form>
<p class="rio-auth-footer"><a href="/admin">Cancel and return to the admin</a></p>"#,
csrf = csrf_hidden,
)
} else {
String::from(
r#"<h1 class="rio-auth-title">You have signed out</h1>
<p class="rio-auth-subtitle">Thanks for your time. Sessions are already revoked server-side.</p>
<a class="rio-btn rio-btn-primary rio-btn-block" href="/admin">Sign in again</a>"#,
)
};
let body = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign out · {project}</title>
<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
<style>{theme}</style>
</head>
<body>
<div class="rio-auth-shell">
<div class="rio-auth-card">
<div class="rio-auth-logo">
<span class="rio-brand-mark">{logo}</span>
<span class="rio-brand-meta">
<span class="rio-brand-name">{project}</span>
<span class="rio-brand-label">Admin</span>
</span>
</div>
{card_body}
</div>
</div>
</body>
</html>"#,
project = escape_html(&d.project_name),
theme = theme_style,
logo = escape_html(&d.logo_initial),
card_body = card_body,
css_ver = ADMIN_CSS_VER,
);
let resp = hyper::Response::builder()
.status(200)
.header("content-type", "text/html; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.expect("valid response");
with_admin_headers(resp)
}
fn object_history_response<T: AdminModel>(
shell: Shell<'_>,
id: i64,
item: &T,
actions: &[audit::AdminAction],
) -> Response {
let plural = T::DISPLAY_NAME;
let singular = T::singular_name();
let admin_name = T::ADMIN_NAME;
let summary = T::FIELDS
.iter()
.find(|f| f.editable && matches!(f.ty, FieldType::String))
.and_then(|f| item.field_display(f.name))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("#{id}"));
let inner = if actions.is_empty() {
format!(
r#"<div class="rio-empty">
<div class="rio-empty-icon">{icon}</div>
<h3>No change history yet</h3>
<p>Every add, change, or delete made through the admin will appear here. This record has no entries yet — the most likely reason is that it predates the audit log, or no one has edited it through the admin.</p>
</div>"#,
icon = icon_inbox(),
)
} else {
render_actions_timeline(actions, false)
};
let body = format!(
r#"<div class="rio-card">
<div class="rio-card-header">
<div>
<h2 class="rio-card-title">Change history — {singular_hdr} {summary}</h2>
<p class="rio-card-subtitle">Every add / change / delete that happened to this record, newest first.</p>
</div>
<a class="rio-btn" href="/admin/{name}/{id}/edit">Back to record</a>
</div>
{inner}
</div>"#,
singular_hdr = escape_html(singular),
summary = escape_html(&summary),
name = escape_html(admin_name),
id = id,
inner = inner,
);
let plural_href = format!("/admin/{admin_name}");
let edit_href = format!("/admin/{admin_name}/{id}/edit");
let crumbs: &[Crumb<'_>] = &[
("Admin", Some("/admin")),
(plural, Some(&plural_href)),
(singular, Some(&edit_href)),
("History", None),
];
render_shell_page(
&shell,
200,
&format!("History — {singular} {summary}"),
"Change history",
Some("Every add / change / delete that happened to this record."),
crumbs,
"",
&body,
)
}
#[allow(clippy::too_many_arguments)]
async fn suggestion_review_response(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
identity: Option<&crate::auth::Identity>,
csrf: Option<&str>,
admin_name: &str,
field: &str,
error: Option<&str>,
) -> Response {
let ctx = intelligence::context_global();
let effective = entry_builder::entries_effective(legacy_entries);
let Some(suggestion) =
suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
else {
return admin_not_found_response(legacy_entries, None, csrf);
};
let plan_result = match run_planner(&suggestion.prompt, ctx) {
Ok(pr) => pr,
Err(msg) => {
return suggestion_error_response(
db,
registry,
legacy_entries,
identity,
csrf,
&suggestion,
&msg,
)
.await;
}
};
let review = match crate::ai::review_plan(
plan_result.schema_ref(),
&plan_result.plan_result.plan,
ctx,
) {
Ok(r) => r,
Err(e) => {
return suggestion_error_response(
db,
registry,
legacy_entries,
identity,
csrf,
&suggestion,
&format!("review layer refused: {e}"),
)
.await;
}
};
let can_apply = matches!(review.validation, crate::ai::ValidationOutcome::Valid)
&& review.risk != crate::ai::RiskLevel::Critical;
let step_descriptions: Vec<String> = plan_result
.plan_result
.plan
.steps
.iter()
.map(|p| match p {
crate::ai::Primitive::AddField(a) => format!(
"+ Add field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
escape_html(&a.field.name),
escape_html(&a.field.ty),
if a.field.nullable { ", nullable" } else { "" },
escape_html(&a.model),
),
other => escape_html(&format!("{other:?}")),
})
.collect();
let schema_diff_html =
render_schema_diff(plan_result.schema_ref(), &plan_result.plan_result.plan);
let (risk_label, risk_class) = match review.risk {
crate::ai::RiskLevel::Low => ("Low", "success"),
crate::ai::RiskLevel::Medium => ("Medium", "warning"),
crate::ai::RiskLevel::High => ("High", "danger"),
crate::ai::RiskLevel::Critical => ("Critical", "danger"),
};
let (validation_ok, validation_message) = match &review.validation {
crate::ai::ValidationOutcome::Valid => (true, None),
crate::ai::ValidationOutcome::Invalid { step, reason } => (
false,
Some(format!(
"Plan fails at step {step}: {reason}. Regenerate the schema or adjust the plan before applying."
)),
),
};
let confidence_class = match suggestion.confidence.as_str() {
"High" => "success",
"Medium" => "warning",
_ => "secondary",
};
let view = crate::admin::layout::SuggestionReviewView {
model: suggestion.model_display.clone(),
field: suggestion.field.clone(),
industry: ctx
.and_then(|c| c.industry.as_deref())
.unwrap_or("")
.to_string(),
confidence_label: suggestion.confidence.as_str().to_string(),
confidence_class: confidence_class.to_string(),
apply_url: suggestion.url_path(),
can_apply,
step_descriptions,
schema_diff_html,
explanation: plan_result.plan_result.explanation.clone(),
risk_label: risk_label.to_string(),
risk_class: risk_class.to_string(),
adds_fields: review.impact.adds_fields as u32,
destructive: review.impact.destructive,
validation_ok,
validation_message,
warnings: review.warnings.clone(),
error: error.map(str::to_string),
};
let html = crate::admin::layout::suggestion_review_render(
db,
registry,
legacy_entries,
identity,
csrf,
view,
)
.await;
with_admin_headers(crate::http::html(html))
}
#[allow(clippy::too_many_arguments)]
async fn suggestion_apply_response(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
identity: Option<&crate::auth::Identity>,
csrf: Option<&str>,
admin_name: &str,
field: &str,
) -> Response {
let ctx = intelligence::context_global();
let effective = entry_builder::entries_effective(legacy_entries);
let Some(suggestion) =
suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
else {
return admin_not_found_response(legacy_entries, None, csrf);
};
let plan_result = match run_planner(&suggestion.prompt, ctx) {
Ok(pr) => pr,
Err(msg) => {
return suggestion_review_response(
db,
registry,
legacy_entries,
identity,
csrf,
admin_name,
field,
Some(&msg),
)
.await;
}
};
let doc = match crate::ai::build_plan_document(
plan_result.schema_ref(),
&suggestion.prompt,
&plan_result.plan_result,
ctx,
) {
Ok(d) => d,
Err(e) => {
return suggestion_review_response(
db,
registry,
legacy_entries,
identity,
csrf,
admin_name,
field,
Some(&format!("plan document rejected: {e}")),
)
.await;
}
};
if doc.risk == crate::ai::RiskLevel::Critical {
return suggestion_review_response(
db,
registry,
legacy_entries,
identity,
csrf,
admin_name,
field,
Some("Plan risk is Critical — the safe executor refuses to apply it."),
)
.await;
}
let options = crate::ai::ExecuteOptions::default();
let result =
match crate::ai::execute_plan_document(std::path::Path::new("."), &doc, &options, ctx) {
Ok(r) => r,
Err(e) => {
return suggestion_review_response(
db,
registry,
legacy_entries,
identity,
csrf,
admin_name,
field,
Some(&format!("executor refused: {e}")),
)
.await;
}
};
schema_cache::refresh_best_effort();
let change_lines: Vec<String> = doc.plan.steps.iter().map(describe_applied_step).collect();
let files: Vec<crate::admin::layout::AppliedFileView> = result
.generated_files
.iter()
.map(|f| {
let kind = if f.ends_with(".sql") {
"Created migration"
} else if f.ends_with(".rs") {
"Updated"
} else {
"Wrote"
};
crate::admin::layout::AppliedFileView {
kind: kind.to_string(),
path: f.clone(),
}
})
.collect();
let applied = crate::admin::layout::SuggestionAppliedView {
change_lines,
files,
};
let html = crate::admin::layout::suggestion_applied_render(
db,
registry,
legacy_entries,
identity,
csrf,
applied,
)
.await;
with_admin_headers(crate::http::html(html))
}
#[allow(clippy::too_many_arguments)]
async fn suggestion_error_response(
db: &Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
identity: Option<&crate::auth::Identity>,
csrf: Option<&str>,
suggestion: &suggestions::Suggestion,
msg: &str,
) -> Response {
let ctx = intelligence::context_global();
let confidence_class = match suggestion.confidence.as_str() {
"High" => "success",
"Medium" => "warning",
_ => "secondary",
};
let view = crate::admin::layout::SuggestionReviewView {
model: suggestion.model_display.clone(),
field: suggestion.field.clone(),
industry: ctx
.and_then(|c| c.industry.as_deref())
.unwrap_or("")
.to_string(),
confidence_label: suggestion.confidence.as_str().to_string(),
confidence_class: confidence_class.to_string(),
apply_url: suggestion.url_path(),
can_apply: false,
step_descriptions: Vec::new(),
schema_diff_html: String::new(),
explanation: String::new(),
risk_label: "?".into(),
risk_class: "secondary".into(),
adds_fields: 0,
destructive: false,
validation_ok: false,
validation_message: None,
warnings: Vec::new(),
error: Some(msg.to_string()),
};
let html = crate::admin::layout::suggestion_review_render(
db,
registry,
legacy_entries,
identity,
csrf,
view,
)
.await;
with_admin_headers(crate::http::html(html))
}
fn render_schema_diff(schema: &crate::schema::Schema, plan: &crate::ai::Plan) -> String {
use std::collections::BTreeSet;
let mut touched: Vec<String> = Vec::new();
for step in &plan.steps {
if let crate::ai::Primitive::AddField(a) = step {
if !touched.contains(&a.model) {
touched.push(a.model.clone());
}
}
}
if touched.is_empty() {
return String::new();
}
let mut out = String::new();
for model_name in &touched {
let Some(model) = schema.models.iter().find(|m| &m.name == model_name) else {
continue;
};
let before: Vec<(String, String)> = model
.fields
.iter()
.map(|f| {
let ty = if f.nullable {
format!("Option<{}>", f.ty)
} else {
f.ty.clone()
};
(f.name.clone(), ty)
})
.collect();
let before_names: BTreeSet<&str> = before.iter().map(|(n, _)| n.as_str()).collect();
let mut added: Vec<(String, String)> = Vec::new();
for step in &plan.steps {
if let crate::ai::Primitive::AddField(a) = step {
if a.model == *model_name && !before_names.contains(a.field.name.as_str()) {
let ty = if a.field.nullable {
format!("Option<{}>", a.field.ty)
} else {
a.field.ty.clone()
};
added.push((a.field.name.clone(), ty));
}
}
}
out.push_str(&format!(
r#"<div class="rio-schema-diff"><h3>Model <code>{}</code></h3><pre>"#,
escape_html(model_name),
));
for (name, ty) in &before {
out.push_str(&format!(" {}: {}\n", escape_html(name), escape_html(ty),));
}
for (name, ty) in &added {
out.push_str(&format!(
"<span class=\"rio-schema-diff-add\">+ {}: {}</span>\n",
escape_html(name),
escape_html(ty),
));
}
out.push_str("</pre></div>");
}
out
}
fn describe_applied_step(p: &crate::ai::Primitive) -> String {
match p {
crate::ai::Primitive::AddField(a) => format!(
"Added field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
escape_html(&a.field.name),
escape_html(&a.field.ty),
if a.field.nullable { ", nullable" } else { "" },
escape_html(&a.model),
),
crate::ai::Primitive::RenameField(r) => format!(
"Renamed <code>{}.{}</code> to <code>{}</code>",
escape_html(&r.model),
escape_html(&r.from),
escape_html(&r.to),
),
crate::ai::Primitive::RenameModel(r) => format!(
"Renamed model <code>{}</code> to <code>{}</code>",
escape_html(&r.from),
escape_html(&r.to),
),
other => escape_html(&format!("{:?}", other)),
}
}
struct PlannerCallResult {
plan_result: crate::ai::PlanResult,
schema: crate::schema::Schema,
}
impl PlannerCallResult {
fn schema_ref(&self) -> &crate::schema::Schema {
&self.schema
}
}
fn run_planner(
prompt: &str,
context: Option<&crate::ai::ContextConfig>,
) -> Result<PlannerCallResult, String> {
let schema_path = std::path::Path::new("rustio.schema.json");
let schema_json = std::fs::read_to_string(schema_path)
.map_err(|e| format!("could not read rustio.schema.json: {e}"))?;
let schema = crate::schema::Schema::parse(&schema_json)
.map_err(|e| format!("rustio.schema.json parse error: {e}"))?;
let plan_result = crate::ai::generate_plan(
&schema,
context,
crate::ai::PlanRequest::new(prompt.to_string()),
)
.map_err(|e| format!("planner refused: {e}"))?;
Ok(PlannerCallResult {
plan_result,
schema,
})
}
fn render_actions_timeline(actions: &[audit::AdminAction], show_object_link: bool) -> String {
if actions.is_empty() {
return String::new();
}
let rows: String = actions
.iter()
.map(|a| {
let action = audit::ActionType::parse(&a.action_type);
let (pill_class, label) = match action {
Some(at) => (at.pill_class(), at.label()),
None => ("rio-pill rio-pill-slate", "Action"),
};
let when = a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
let who = a
.user_email
.clone()
.unwrap_or_else(|| format!("user #{}", a.user_id));
let ip = match &a.ip_address {
Some(ip) if !ip.is_empty() => {
format!(r#"<span class="rio-audit-ip">{}</span>"#, escape_html(ip))
}
_ => String::new(),
};
let object_link = if show_object_link {
format!(
r#"<a class="rio-audit-object" href="/admin/{name}/{id}/history">{name} #{id}</a>"#,
name = escape_html(&a.model_name),
id = a.object_id,
)
} else {
String::new()
};
format!(
r#"<li class="rio-audit-item">
<div class="rio-audit-head">
<span class="{pill}">{label}</span>
{object_link}
<span class="rio-audit-when">{when}</span>
</div>
<p class="rio-audit-summary">{summary}</p>
<div class="rio-audit-meta">
<span class="rio-audit-who">{who}</span>
{ip}
</div>
</li>"#,
pill = pill_class,
label = label,
object_link = object_link,
when = escape_html(&when),
summary = escape_html(&a.summary),
who = escape_html(&who),
ip = ip,
)
})
.collect();
format!(r#"<ul class="rio-audit-timeline">{rows}</ul>"#)
}
fn build_session_cookie(name: &str, token: &str, max_age: i64) -> String {
build_session_cookie_impl(name, token, max_age, crate::auth::in_production())
}
fn build_session_cookie_impl(name: &str, token: &str, max_age: i64, secure: bool) -> String {
let secure = if secure { "; Secure" } else { "" };
format!("{name}={token}; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age={max_age}")
}
async fn handle_login(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
use crate::auth;
let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
let form = read_form(req).await?;
let email = form.get("email").unwrap_or("").trim().to_string();
let password = form.get("password").unwrap_or("").to_string();
if email.is_empty() || password.is_empty() {
return Ok(login_page(
400,
Some(&email),
Some("Email and password are both required."),
));
}
let email_key = auth::normalise_email(&email);
let rate_key = auth::LoginRateLimiter::compose_key(&email_key, peer_ip.as_deref());
if let Err(remaining) = auth::LoginRateLimiter::global().check(&rate_key) {
return Ok(login_page(
429,
Some(&email),
Some(&format!(
"Too many failed attempts. Try again in {}s.",
remaining.as_secs().max(1),
)),
));
}
let generic = "Invalid email or password.";
let user = auth::user::find_by_email(db, &email).await?;
let valid = match &user {
Some(u) => auth::password::verify(&password, &u.password_hash),
None => {
let _ = auth::password::verify(&password, auth::dummy_password_hash());
false
}
};
if !valid {
auth::LoginRateLimiter::global().record_failure(&rate_key);
return Ok(login_page(401, Some(&email), Some(generic)));
}
let user = user.expect("valid credentials imply a found user");
if !user.is_active {
return Ok(login_page(
403,
Some(&email),
Some("This account is inactive. Contact an administrator."),
));
}
auth::LoginRateLimiter::global().record_success(&rate_key);
let _ = auth::session::sweep_expired(db).await;
let session = auth::session::create(db, user.id).await?;
let mut resp = redirect("/admin");
let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
crate::http::set_cookie(
&mut resp,
&build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
);
Ok(with_admin_headers(resp))
}
async fn handle_logout(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
use crate::auth;
let cookie_token = req.cookie(auth::SESSION_COOKIE);
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
if let Some(token) = cookie_token {
let _ = auth::session::delete(db, &token).await;
}
let mut resp = redirect("/admin/logout");
crate::http::set_cookie(
&mut resp,
&build_session_cookie(auth::SESSION_COOKIE, "", 0),
);
Ok(with_admin_headers(resp))
}
async fn handle_password_change_post(
req: Request,
db: &crate::orm::Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
) -> Result<Response, Error> {
use crate::auth;
let (_, body, ctx) = req.into_parts();
let form = read_form_from_parts(body).await?;
require_csrf(&ctx, &form)?;
let user_id = match ctx.get::<auth::Identity>() {
Some(i) => i.user_id,
None => return Ok(login_page(401, None, None)),
};
let identity = crate::auth::identity(&ctx).cloned();
let csrf = ctx_csrf(&ctx).map(str::to_string);
let old = form.get("old_password").unwrap_or("").to_string();
let new1 = form.get("new_password1").unwrap_or("").to_string();
let new2 = form.get("new_password2").unwrap_or("").to_string();
async fn render_err(
db: &crate::orm::Db,
registry: &crate::admin::admin_form_bridge::AdminRegistry,
legacy_entries: &[AdminEntry],
identity: Option<&crate::auth::Identity>,
csrf: Option<&str>,
msg: &str,
) -> Response {
let html = crate::admin::layout::password_change_render(
db,
registry,
legacy_entries,
identity,
csrf,
Some(msg),
)
.await;
with_admin_headers(crate::http::html(html))
}
if old.is_empty() || new1.is_empty() || new2.is_empty() {
return Ok(render_err(
db,
registry,
legacy_entries,
identity.as_ref(),
csrf.as_deref(),
"All three fields are required.",
)
.await);
}
if new1 != new2 {
return Ok(render_err(
db,
registry,
legacy_entries,
identity.as_ref(),
csrf.as_deref(),
"The two new password fields did not match. Try again.",
)
.await);
}
if new1.len() < 8 {
return Ok(render_err(
db,
registry,
legacy_entries,
identity.as_ref(),
csrf.as_deref(),
"Your new password must be at least 8 characters.",
)
.await);
}
let user = match auth::user::find_by_id(db, user_id).await? {
Some(u) => u,
None => return Ok(login_page(401, None, None)),
};
if !auth::password::verify(&old, &user.password_hash) {
return Ok(render_err(
db,
registry,
legacy_entries,
identity.as_ref(),
csrf.as_deref(),
"Your old password was entered incorrectly. Please try again.",
)
.await);
}
auth::user::set_password(db, user.id, &new1).await?;
let session = auth::session::create(db, user.id).await?;
let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
let mut resp = redirect("/admin/password_change/done");
crate::http::set_cookie(
&mut resp,
&build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
);
Ok(with_admin_headers(resp))
}
pub fn parse_datetime_local(raw: &str) -> Result<DateTime<Utc>, String> {
if raw.is_empty() {
return Err(String::from("date-time value is empty"));
}
if raw.trim_matches(|c: char| c.is_ascii_whitespace()) != raw {
return Err(format!("`{raw}` has surrounding whitespace"));
}
if raw.ends_with('Z') || raw.contains('+') || (raw.matches('-').count() > 2) {
return Err(format!(
"`{raw}` looks like a timezoned date-time; expected YYYY-MM-DDTHH:MM"
));
}
let parsed = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
.map_err(|_| format!("`{raw}` is not a valid date-time"))?;
match Utc.from_local_datetime(&parsed) {
chrono::LocalResult::Single(dt) => Ok(dt),
_ => Err(format!("`{raw}` could not be interpreted as UTC")),
}
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Timelike;
#[test]
fn session_cookie_dev_has_no_secure_flag() {
let c = build_session_cookie_impl("rustio_session", "TOK", 600, false);
assert_eq!(
c,
"rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Max-Age=600"
);
assert!(!c.contains("Secure"));
}
#[test]
fn session_cookie_production_has_secure_flag() {
let c = build_session_cookie_impl("rustio_session", "TOK", 600, true);
assert_eq!(
c,
"rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=600"
);
}
#[test]
fn session_cookie_expiration_shape_is_stable() {
let c = build_session_cookie_impl("rustio_session", "", 0, true);
assert!(c.contains("rustio_session=; "));
assert!(c.contains("HttpOnly"));
assert!(c.contains("SameSite=Strict"));
assert!(c.contains("Secure"));
assert!(c.contains("Max-Age=0"));
}
#[test]
fn escape_html_escapes_dangerous_chars() {
assert_eq!(
escape_html("<script>alert(\"xss\")</script>"),
"<script>alert("xss")</script>"
);
assert_eq!(escape_html("a & b"), "a & b");
assert_eq!(escape_html("it's"), "it's");
}
#[test]
fn escape_css_color_accepts_hex_tokens() {
assert_eq!(escape_css_color("#0f172a"), "#0f172a");
assert_eq!(escape_css_color("#4f46e5"), "#4f46e5");
}
#[test]
fn escape_css_color_rejects_injection_attempts() {
assert_eq!(escape_css_color("red; } body { display:none"), "#0f172a");
assert_eq!(escape_css_color("}</style><script>"), "#0f172a");
assert_eq!(escape_css_color("red\\0A "), "#0f172a");
}
#[test]
fn parse_datetime_local_accepts_minute_precision() {
let dt = parse_datetime_local("2026-04-18T10:12").unwrap();
assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:00+00:00");
assert!(dt.to_rfc3339().ends_with("+00:00"));
}
#[test]
fn parse_datetime_local_accepts_second_precision() {
let dt = parse_datetime_local("2026-04-18T10:12:33").unwrap();
assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:33+00:00");
}
#[test]
fn parse_datetime_local_rejects_empty_string() {
assert!(parse_datetime_local("").is_err());
}
#[test]
fn parse_datetime_local_rejects_free_text() {
assert!(parse_datetime_local("tomorrow at noon").is_err());
}
#[test]
fn parse_datetime_local_rejects_partial_date() {
assert!(parse_datetime_local("2026-04-18").is_err());
}
#[test]
fn parse_datetime_local_rejects_out_of_range_date() {
assert!(parse_datetime_local("2026-13-01T00:00").is_err());
assert!(parse_datetime_local("2026-04-31T00:00").is_err());
}
#[test]
fn parse_datetime_local_rejects_out_of_range_time() {
assert!(parse_datetime_local("2026-04-18T25:00").is_err());
assert!(parse_datetime_local("2026-04-18T10:99").is_err());
}
#[test]
fn parse_datetime_local_rejects_surrounding_whitespace() {
assert!(parse_datetime_local(" 2026-04-18T10:12").is_err());
assert!(parse_datetime_local("2026-04-18T10:12 ").is_err());
}
#[test]
fn parse_datetime_local_rejects_timezone_suffix() {
assert!(parse_datetime_local("2026-04-18T10:12Z").is_err());
assert!(parse_datetime_local("2026-04-18T10:12:00+00:00").is_err());
}
struct Widgety;
impl crate::orm::Model for Widgety {
const TABLE: &'static str = "widgety";
const COLUMNS: &'static [&'static str] = &["id"];
const INSERT_COLUMNS: &'static [&'static str] = &[];
fn id(&self) -> i64 {
0
}
fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<crate::orm::Value> {
Vec::new()
}
}
impl AdminModel for Widgety {
const ADMIN_NAME: &'static str = "widgety";
const DISPLAY_NAME: &'static str = "Widgety";
const FIELDS: &'static [AdminField] = &[];
fn field_display(&self, name: &str) -> Option<String> {
match name {
"filled" => Some(String::from("2026-04-18T10:12")),
"empty" => Some(String::new()),
_ => None,
}
}
fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
unimplemented!()
}
}
fn string_field(name: &'static str, nullable: bool) -> AdminField {
AdminField {
name,
ty: FieldType::String,
editable: true,
nullable,
relation: None,
}
}
fn datetime_field(name: &'static str, nullable: bool) -> AdminField {
AdminField {
name,
ty: FieldType::DateTime,
editable: true,
nullable,
relation: None,
}
}
#[test]
fn nullable_string_field_omits_required_attribute() {
let f = string_field("note", true);
let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
assert!(!html.contains("required"), "html was: {html}");
}
#[test]
fn non_nullable_string_field_marks_required() {
let f = string_field("title", false);
let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
assert!(html.contains("required"), "html was: {html}");
}
#[test]
fn bool_field_never_marks_required() {
let f = AdminField {
name: "flag",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
};
let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
assert!(!html.contains("required"), "html was: {html}");
}
#[test]
fn datetime_field_uses_datetime_local_input() {
let f = datetime_field("starts_at", false);
let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
assert!(
html.contains(r#"type="datetime-local""#),
"html was: {html}"
);
}
#[test]
fn datetime_field_renders_existing_value() {
let f = datetime_field("filled", true);
let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
assert!(
html.contains(r#"value="2026-04-18T10:12""#),
"html was: {html}"
);
}
#[test]
fn nullable_field_with_none_value_does_not_panic() {
let f = string_field("empty", true);
let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
assert!(html.contains(r#"value="""#));
assert!(!html.contains("required"));
}
#[test]
fn field_display_returning_none_renders_empty_value() {
let f = string_field("unknown_field", false);
let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
assert!(html.contains(r#"value="""#));
}
#[test]
fn parse_datetime_local_enforces_utc_for_every_valid_input() {
let inputs = [
"2000-01-01T00:00",
"2026-04-18T10:12",
"2026-04-18T10:12:33",
"2099-12-31T23:59",
];
for raw in inputs {
let dt = parse_datetime_local(raw).unwrap_or_else(|e| panic!("`{raw}`: {e}"));
assert!(
dt.to_rfc3339().ends_with("+00:00"),
"non-UTC offset in output for `{raw}`: {}",
dt.to_rfc3339(),
);
assert!(
dt.nanosecond() == 0,
"unexpected sub-second part for `{raw}`"
);
}
}
#[test]
fn humanise_converts_snake_case_to_title_case() {
assert_eq!(humanise("title"), "Title");
assert_eq!(humanise("is_active"), "Is Active");
assert_eq!(humanise("created_at"), "Created At");
assert_eq!(humanise("assigned_to"), "Assigned To");
}
fn make_field(
name: &'static str,
ty: FieldType,
relation: Option<AdminRelation>,
) -> AdminField {
AdminField {
name,
ty,
editable: true,
nullable: false,
relation,
}
}
fn fk(target: &'static str) -> AdminRelation {
AdminRelation {
kind: crate::schema::RelationKind::BelongsTo,
model: target,
display_field: None,
}
}
#[test]
fn primary_rule_1_id_always_primary() {
assert!(is_primary_column(&make_field("id", FieldType::I64, None)));
}
#[test]
fn primary_rule_4_fk_ending_in_id() {
assert!(is_primary_column(&make_field(
"department_id",
FieldType::I64,
Some(fk("Department")),
)));
assert!(!is_primary_column(&make_field(
"department_id",
FieldType::I64,
None,
)));
assert!(!is_primary_column(&make_field(
"department",
FieldType::I64,
Some(fk("Department")),
)));
}
#[test]
fn primary_rule_5_is_prefix_bool() {
assert!(is_primary_column(&make_field(
"is_active",
FieldType::Bool,
None,
)));
assert!(is_primary_column(&make_field(
"is_admin",
FieldType::Bool,
None,
)));
assert!(!is_primary_column(&make_field(
"active",
FieldType::Bool,
None,
)));
assert!(!is_primary_column(&make_field(
"is_active",
FieldType::String,
None,
)));
}
#[test]
fn primary_rule_6_status_state_priority() {
assert!(is_primary_column(&make_field(
"status",
FieldType::String,
None,
)));
assert!(is_primary_column(&make_field(
"state",
FieldType::String,
None,
)));
assert!(is_primary_column(&make_field(
"priority",
FieldType::I32,
None,
)));
assert!(!is_primary_column(&make_field(
"priorities",
FieldType::I32,
None,
)));
}
#[test]
fn primary_rule_7_plain_fields_not_primary() {
assert!(!is_primary_column(&make_field(
"specialty",
FieldType::String,
None,
)));
assert!(!is_primary_column(&make_field(
"license_no",
FieldType::String,
None,
)));
assert!(!is_primary_column(&make_field(
"years_experience",
FieldType::I32,
None,
)));
assert!(!is_primary_column(&make_field(
"created_at",
FieldType::DateTime,
None,
)));
}
struct DoctorFixture;
impl crate::orm::Model for DoctorFixture {
const TABLE: &'static str = "doctors";
const COLUMNS: &'static [&'static str] = &[
"id",
"full_name",
"specialty",
"department_id",
"license_no",
"email",
"phone",
"years_experience",
"is_active",
"created_at",
];
const INSERT_COLUMNS: &'static [&'static str] = &[];
fn id(&self) -> i64 {
0
}
fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<crate::orm::Value> {
Vec::new()
}
}
impl AdminModel for DoctorFixture {
const ADMIN_NAME: &'static str = "doctors";
const DISPLAY_NAME: &'static str = "Doctors";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
ty: FieldType::I64,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "full_name",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "specialty",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "department_id",
ty: FieldType::I64,
editable: true,
nullable: false,
relation: Some(AdminRelation {
kind: crate::schema::RelationKind::BelongsTo,
model: "Department",
display_field: None,
}),
},
AdminField {
name: "license_no",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "email",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "phone",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "years_experience",
ty: FieldType::I32,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "is_active",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "created_at",
ty: FieldType::DateTime,
editable: false,
nullable: false,
relation: None,
},
];
fn singular_name() -> &'static str {
"Doctor"
}
fn field_display(&self, _: &str) -> Option<String> {
None
}
fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
unimplemented!()
}
}
#[test]
fn default_columns_doctor_yields_four_rule_matches() {
let cols = default_list_columns::<DoctorFixture>();
assert_eq!(cols, vec!["id", "full_name", "department_id", "is_active",]);
}
#[test]
fn default_columns_returns_fewer_than_five_when_rules_match_fewer() {
struct Tiny;
impl crate::orm::Model for Tiny {
const TABLE: &'static str = "tinies";
const COLUMNS: &'static [&'static str] = &["id", "name", "is_active"];
const INSERT_COLUMNS: &'static [&'static str] = &[];
fn id(&self) -> i64 {
0
}
fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<crate::orm::Value> {
Vec::new()
}
}
impl AdminModel for Tiny {
const ADMIN_NAME: &'static str = "tinies";
const DISPLAY_NAME: &'static str = "Tinies";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
ty: FieldType::I64,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "name",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "is_active",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
},
];
fn singular_name() -> &'static str {
"Tiny"
}
fn field_display(&self, _: &str) -> Option<String> {
None
}
fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
unimplemented!()
}
}
let cols = default_list_columns::<Tiny>();
assert_eq!(cols, vec!["id", "name", "is_active"]);
}
#[test]
fn default_list_columns_caps_at_five() {
struct Stuffed;
impl crate::orm::Model for Stuffed {
const TABLE: &'static str = "stuffed";
const COLUMNS: &'static [&'static str] = &[
"id",
"name",
"status",
"state",
"priority",
"is_active",
"is_admin",
];
const INSERT_COLUMNS: &'static [&'static str] = &[];
fn id(&self) -> i64 {
0
}
fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<crate::orm::Value> {
Vec::new()
}
}
impl AdminModel for Stuffed {
const ADMIN_NAME: &'static str = "stuffed";
const DISPLAY_NAME: &'static str = "Stuffed";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
ty: FieldType::I64,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "name",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "status",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "state",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "priority",
ty: FieldType::I32,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "is_active",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "is_admin",
ty: FieldType::Bool,
editable: true,
nullable: false,
relation: None,
},
];
fn singular_name() -> &'static str {
"Stuffed"
}
fn field_display(&self, _: &str) -> Option<String> {
None
}
fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
unimplemented!()
}
}
let cols = default_list_columns::<Stuffed>();
assert_eq!(cols.len(), 5, "cap must hold at 5");
assert_eq!(cols, vec!["id", "name", "status", "state", "priority"]);
}
#[test]
fn default_list_columns_rule_3_first_name_like_wins() {
struct TwoNames;
impl crate::orm::Model for TwoNames {
const TABLE: &'static str = "two_names";
const COLUMNS: &'static [&'static str] = &["id", "full_name", "email"];
const INSERT_COLUMNS: &'static [&'static str] = &[];
fn id(&self) -> i64 {
0
}
fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
unimplemented!()
}
fn insert_values(&self) -> Vec<crate::orm::Value> {
Vec::new()
}
}
impl AdminModel for TwoNames {
const ADMIN_NAME: &'static str = "two_names";
const DISPLAY_NAME: &'static str = "TwoNames";
const FIELDS: &'static [AdminField] = &[
AdminField {
name: "id",
ty: FieldType::I64,
editable: false,
nullable: false,
relation: None,
},
AdminField {
name: "full_name",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
AdminField {
name: "email",
ty: FieldType::String,
editable: true,
nullable: false,
relation: None,
},
];
fn singular_name() -> &'static str {
"TwoName"
}
fn field_display(&self, _: &str) -> Option<String> {
None
}
fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
unimplemented!()
}
}
let cols = default_list_columns::<TwoNames>();
assert_eq!(cols, vec!["id", "full_name"]);
}
}