#![allow(dead_code)]
use std::collections::HashMap;
use serde::Serialize;
use super::audit::AdminAction;
use super::types::{Admin, AdminEntry, AdminField, EditRow, ListRow};
use crate::auth::Identity;
use crate::error::Result;
use crate::http::FormData;
use crate::orm::Db;
#[derive(Serialize)]
pub(crate) struct IdentityCtx {
pub email: String,
pub is_admin: bool,
pub is_developer: bool,
}
impl From<&Identity> for IdentityCtx {
fn from(i: &Identity) -> Self {
Self {
email: i.email.clone(),
is_admin: i.is_admin(),
is_developer: i.is_active && i.role.includes(crate::auth::Role::Developer),
}
}
}
#[derive(Serialize)]
pub(crate) struct BaseContext {
pub identity: Option<IdentityCtx>,
pub csrf_token: String,
pub site_title: String,
pub site_header: String,
pub index_title: String,
pub footer_copyright: String,
pub is_demo_session: bool,
pub demo_label: Option<String>,
pub accent_hex: String,
pub accent_rgb: String,
pub theme_bg: String,
pub theme_surface: String,
pub theme_text: String,
pub theme_text_muted: String,
pub theme_border: String,
}
pub(crate) fn hex_to_rgb_triplet(hex: &str) -> String {
const FALLBACK: &str = "37 99 235"; let h = hex.trim_start_matches('#');
if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return FALLBACK.into();
}
let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(37);
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(99);
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(235);
format!("{r} {g} {b}")
}
impl BaseContext {
pub fn new(identity: Option<&Identity>, csrf_token: String, admin: &Admin) -> Self {
let b = admin.branding();
let (is_demo_session, demo_label) = match identity {
Some(i) => (i.is_demo, i.demo_label.clone()),
None => (false, None),
};
let theme = admin.active_theme();
let accent_hex = theme.accent.clone();
let accent_rgb = hex_to_rgb_triplet(&accent_hex);
Self {
identity: identity.map(IdentityCtx::from),
csrf_token,
site_title: b.site_title.clone(),
site_header: b.site_header.clone(),
index_title: b.index_title.clone(),
footer_copyright: b.footer_copyright.clone(),
is_demo_session,
demo_label,
accent_hex,
accent_rgb,
theme_bg: theme.bg.clone(),
theme_surface: theme.surface.clone(),
theme_text: theme.text.clone(),
theme_text_muted: theme.text_muted.clone(),
theme_border: theme.border.clone(),
}
}
}
#[derive(Serialize)]
pub(crate) struct SidebarEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
}
impl From<&AdminEntry> for SidebarEntry {
fn from(e: &AdminEntry) -> Self {
Self {
admin_name: e.admin_name,
display_name: e.display_name,
}
}
}
#[derive(Serialize)]
pub(crate) struct FlashCtx {
pub kind: &'static str,
pub message: String,
}
#[derive(Serialize)]
pub(crate) struct LoginCtx {
#[serde(flatten)]
pub base: BaseContext,
pub error: Option<String>,
pub sections: Vec<FormSection>,
pub flash: Option<FlashCtx>,
}
pub(crate) fn login_form_sections() -> Vec<FormSection> {
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("username"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("current-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
#[derive(Serialize)]
pub(crate) struct DashboardCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub apps: Vec<DashboardApp>,
pub recent_actions: Vec<RecentActionCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct DashboardApp {
pub label: String,
pub models: Vec<DashboardModel>,
}
#[derive(Serialize)]
pub(crate) struct DashboardModel {
pub admin_name: &'static str,
pub display_name: &'static str,
pub field_count: usize,
}
#[derive(Serialize)]
pub(crate) struct RecentActionCtx {
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub object_id: i64,
pub user_email: String,
pub summary: String,
pub when_relative: String,
}
pub(crate) fn group_entries_by_app(entries: &[AdminEntry]) -> Vec<DashboardApp> {
let mut apps: Vec<DashboardApp> = Vec::new();
for entry in entries {
if entry.core {
continue;
}
let label = app_label_for(entry.admin_name);
let app = match apps.iter_mut().find(|a| a.label == label) {
Some(a) => a,
None => {
apps.push(DashboardApp {
label: label.clone(),
models: Vec::new(),
});
apps.last_mut().unwrap()
}
};
app.models.push(DashboardModel {
admin_name: entry.admin_name,
display_name: entry.display_name,
field_count: entry.fields.len(),
});
}
apps
}
pub(crate) fn app_label_for(admin_name: &str) -> String {
let prefix = admin_name.split('.').next().unwrap_or(admin_name);
capitalise(prefix)
}
fn capitalise(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub(crate) fn dashboard_ctx(
identity: &Identity,
admin: &Admin,
recent_actions: Vec<AdminAction>,
csrf_token: String,
) -> DashboardCtx {
let recent = recent_actions
.into_iter()
.map(|a| RecentActionCtx {
action_type: a.action_type.clone(),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name,
object_id: a.object_id,
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
summary: a.summary,
when_relative: relative_time(a.timestamp),
})
.collect();
DashboardCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
apps: group_entries_by_app(admin.entries()),
recent_actions: recent,
flash: None,
}
}
fn action_label(action_type: &str) -> &'static str {
match action_type {
"create" => "Created",
"update" => "Changed",
"delete" => "Deleted",
_ => "Action",
}
}
fn action_pill_class(action_type: &str) -> &'static str {
match action_type {
"create" => "badge-success",
"update" => "badge-neutral",
"delete" => "badge-danger",
_ => "badge-neutral",
}
}
pub(crate) fn relative_time(ts: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let delta = now - ts;
if delta.num_seconds() < 60 {
"just now".to_string()
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else if delta.num_days() < 30 {
format!("{}d ago", delta.num_days())
} else {
ts.format("%Y-%m-%d").to_string()
}
}
#[derive(Serialize)]
pub(crate) struct ListField {
pub name: String,
pub label: String,
pub kind: &'static str,
pub sort_active: &'static str,
pub sort_link: String,
}
#[derive(Serialize)]
pub(crate) struct ListCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub fields: Vec<ListField>,
pub rows: Vec<ListRowCtx>,
pub search_query: String,
pub filters: Vec<FilterGroupCtx>,
pub page: usize,
pub total_pages: usize,
pub per_page: usize,
pub total_rows: usize,
pub bulk_actions_enabled: bool,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct ListRowCtx {
pub id: i64,
#[serde(flatten)]
pub values: HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
pub(crate) struct FilterGroupCtx {
pub field: String,
pub label: String,
pub options: Vec<FilterOptionCtx>,
pub current: Option<String>,
}
#[derive(Serialize)]
pub(crate) struct FilterOptionCtx {
pub value: String,
pub label: String,
pub selected: bool,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn list_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
rows: Vec<ListRow>,
search_query: String,
filters: Vec<FilterGroupCtx>,
page: usize,
per_page: usize,
total_rows: usize,
active_sort: Option<(String, super::modeladmin::SortDir)>,
csrf_token: String,
) -> ListCtx {
let total_pages = total_rows.div_ceil(per_page.max(1)).max(1);
let fields: Vec<ListField> = entry
.fields
.iter()
.map(|f| {
let (sort_active, sort_link) = build_sort_link(f.name, &active_sort);
ListField {
name: f.name.to_string(),
label: f.label.to_string(),
kind: f.field_type.widget(),
sort_active,
sort_link,
}
})
.collect();
let field_names: Vec<&'static str> = entry.fields.iter().map(|f| f.name).collect();
let field_types: Vec<crate::admin::FieldType> =
entry.fields.iter().map(|f| f.field_type).collect();
ListCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: entry.display_name.to_string(),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
fields,
rows: rows
.into_iter()
.map(|r| {
let mut values: HashMap<String, serde_json::Value> =
HashMap::with_capacity(field_names.len().saturating_sub(1));
for (i, cell) in r.cells.into_iter().enumerate() {
if let Some(name) = field_names.get(i) {
if *name == "id" {
continue;
}
let typed = match field_types.get(i) {
Some(crate::admin::FieldType::Bool) => {
serde_json::Value::Bool(cell == "true")
}
_ => serde_json::Value::String(cell),
};
values.insert((*name).to_string(), typed);
}
}
ListRowCtx { id: r.id, values }
})
.collect(),
search_query,
filters,
page,
total_pages,
per_page,
total_rows,
bulk_actions_enabled: false,
flash: None,
}
}
fn build_sort_link(
name: &'static str,
active: &Option<(String, super::modeladmin::SortDir)>,
) -> (&'static str, String) {
use super::modeladmin::SortDir;
match active {
Some((col, SortDir::Asc)) if col == name => ("asc", format!("?sort={name}&dir=desc")),
Some((col, SortDir::Desc)) if col == name => ("desc", String::from("?")),
_ => ("", format!("?sort={name}&dir=asc")),
}
}
#[derive(Serialize)]
pub(crate) struct FormCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub mode: &'static str, pub object_id: Option<i64>,
pub sections: Vec<FormSection>,
pub errors: Vec<String>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize, Clone)]
pub(crate) struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Serialize)]
pub(crate) struct FormField {
pub name: &'static str,
pub label: String,
pub widget: &'static str,
pub input_type: &'static str,
pub value: String,
pub hint: Option<String>,
pub placeholder: Option<String>,
pub required: bool,
pub options: Option<Vec<SelectOption>>,
pub multiple: bool,
pub span: u8,
pub autocomplete: Option<&'static str>,
pub autofocus: bool,
pub disabled: bool,
pub maxlength: Option<u16>,
pub searchable: bool,
pub has_more: bool,
pub search_url: Option<String>,
pub errors: Vec<String>,
pub target_model: Option<String>,
pub checked: bool,
}
#[derive(Serialize)]
pub(crate) struct FormSection {
pub title: Option<&'static str>,
pub fields: Vec<FormField>,
}
fn humanise_field(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
}
pub(crate) fn bucket_errors_by_label(
entry: &AdminEntry,
errors: Vec<String>,
) -> (Vec<String>, HashMap<String, Vec<String>>) {
let labels: Vec<(&'static str, String)> = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| (f.name, format!("{} ", humanise_field(f.name))))
.collect();
let mut global: Vec<String> = Vec::new();
let mut per_field: HashMap<String, Vec<String>> = HashMap::new();
'outer: for err in errors {
for (name, prefix) in &labels {
if err.starts_with(prefix.as_str()) {
per_field.entry((*name).to_string()).or_default().push(err);
continue 'outer;
}
}
global.push(err);
}
(global, per_field)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn form_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
mode: &'static str,
object_id: Option<i64>,
existing: Option<&EditRow>,
errors: Vec<String>,
csrf_token: String,
relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)>,
field_errors: HashMap<String, Vec<String>>,
submitted: Option<&FormData>,
) -> FormCtx {
let fields = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| {
let value = if let Some(form) = submitted {
form.get(f.name).map(str::to_string).unwrap_or_default()
} else {
existing
.and_then(|row| {
row.values
.iter()
.find(|(col, _)| col == f.name)
.map(|(_, v)| v.clone())
})
.unwrap_or_default()
};
let ui = super::filters::field_ui_metadata(f);
let (base_widget, input_type) = map_field_to_ui(f);
let widget = if base_widget == "input"
&& matches!(
f.field_type,
super::types::FieldType::String | super::types::FieldType::OptionalString
)
&& is_long_text_name(f.name)
{
"textarea"
} else {
base_widget
};
let required =
!f.field_type.nullable() && !matches!(f.field_type, super::types::FieldType::Bool);
let (mut options, multiple, mut searchable, mut has_more) = if let Some(values) =
f.choices
{
let mut opts: Vec<SelectOption> = Vec::with_capacity(values.len() + 1);
if f.field_type.nullable() {
opts.push(SelectOption {
value: String::new(),
label: "—".to_string(),
});
}
opts.extend(values.iter().map(|v| SelectOption {
value: (*v).to_string(),
label: (*v).to_string(),
}));
(Some(opts), false, false, false)
} else if let Some(rel) = &f.relation {
let (opts, has_more) = relation_options.get(f.name).cloned().unwrap_or_default();
(Some(opts), rel.multi, true, has_more)
} else {
(None, false, false, false)
};
let mut widget = widget;
if f.name == "status" && options.is_none() {
options = Some(vec![
SelectOption {
value: "draft".to_string(),
label: "draft".to_string(),
},
SelectOption {
value: "published".to_string(),
label: "published".to_string(),
},
]);
searchable = false;
has_more = false;
widget = "select";
}
let span: u8 = if widget == "textarea" { 2 } else { 1 };
let search_url = f
.relation
.as_ref()
.map(|rel| format!("/admin/search/{}", rel.target_model));
let target_model = f.relation.as_ref().map(|rel| rel.target_model.to_string());
let checked = matches!(value.as_str(), "on" | "true" | "1" | "yes");
let placeholder = if let Some(rel) = &f.relation {
Some(format!("Select {}…", rel.target_model))
} else {
ui.placeholder
};
FormField {
name: f.name,
label: ui.label,
widget,
input_type,
value,
hint: ui.hint,
placeholder,
required,
options,
multiple,
span,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable,
has_more,
search_url,
errors: field_errors.get(f.name).cloned().unwrap_or_default(),
target_model,
checked,
}
})
.collect::<Vec<FormField>>();
let sections = group_fields_into_sections(fields);
FormCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: match mode {
"new" => format!("Add {}", entry.singular_name),
_ => format!("Change {}", entry.singular_name),
},
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
mode,
object_id,
sections,
errors,
flash: None,
}
}
pub(crate) fn apply_field_errors(
sections: &mut [FormSection],
field_errors: &HashMap<String, Vec<String>>,
) {
for section in sections.iter_mut() {
for field in section.fields.iter_mut() {
if let Some(errs) = field_errors.get(field.name) {
field.errors = errs.clone();
}
}
}
}
fn group_fields_into_sections(fields: Vec<FormField>) -> Vec<FormSection> {
let mut default_fields = Vec::new();
let mut metadata_fields = Vec::new();
let mut advanced_fields = Vec::new();
for field in fields {
match classify_field_section(field.name) {
FieldSection::Default => default_fields.push(field),
FieldSection::Metadata => metadata_fields.push(field),
FieldSection::Advanced => advanced_fields.push(field),
}
}
let mut sections: Vec<FormSection> = Vec::with_capacity(3);
if !default_fields.is_empty() {
sections.push(FormSection {
title: None,
fields: default_fields,
});
}
if !metadata_fields.is_empty() {
sections.push(FormSection {
title: Some("System"),
fields: metadata_fields,
});
}
if !advanced_fields.is_empty() {
sections.push(FormSection {
title: Some("Advanced"),
fields: advanced_fields,
});
}
sections
}
enum FieldSection {
Default,
Metadata,
Advanced,
}
fn classify_field_section(name: &str) -> FieldSection {
if name.contains("created") || name.contains("updated") || name.contains("timestamp") {
FieldSection::Metadata
} else if matches!(name, "id" | "uuid" | "slug") {
FieldSection::Advanced
} else {
FieldSection::Default
}
}
fn is_long_text_name(name: &str) -> bool {
matches!(
name,
"body" | "description" | "notes" | "content" | "summary" | "bio" | "details"
)
}
fn map_field_to_ui(field: &super::types::AdminField) -> (&'static str, &'static str) {
if field.choices.is_some() {
return ("select", "select");
}
if let Some(rel) = &field.relation {
if rel.multi {
return ("select", "select-multiple");
}
return ("select", "select");
}
use super::types::FieldType::*;
match field.field_type {
Bool => ("checkbox", "checkbox"),
I32 | I64 | OptionalI64 => ("input", "number"),
DateTime | OptionalDateTime => ("input", "datetime-local"),
String | OptionalString => ("input", "text"),
}
}
pub(crate) const FK_OPTIONS_LIMIT: usize = 50;
pub(crate) async fn resolve_relation_options(
admin: &Admin,
entry: &AdminEntry,
db: &Db,
) -> Result<HashMap<&'static str, (Vec<SelectOption>, bool)>> {
let mut out: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
for f in entry.fields.iter() {
let Some(rel) = &f.relation else {
continue;
};
let target = admin.entries().iter().find(|e| {
e.singular_name == rel.target_model
|| e.admin_name == rel.target_model
|| e.display_name == rel.target_model
});
let Some(target) = target else {
out.insert(f.name, (Vec::new(), false));
continue;
};
let page = target
.ops
.list(
db,
super::types::ListOpts {
limit: Some(FK_OPTIONS_LIMIT as i64),
..super::types::ListOpts::default()
},
)
.await?;
let display_idx = pick_display_index(target.fields, rel.display_field);
let opts: Vec<SelectOption> = page
.rows
.into_iter()
.map(|r| {
let label = display_idx
.and_then(|i| r.cells.get(i).cloned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| r.id.to_string());
SelectOption {
value: r.id.to_string(),
label,
}
})
.collect();
let has_more = page.total > FK_OPTIONS_LIMIT as i64;
out.insert(f.name, (opts, has_more));
}
Ok(out)
}
fn pick_display_index(fields: &[AdminField], display_field: Option<&str>) -> Option<usize> {
if let Some(preferred) = display_field {
if let Some(i) = fields.iter().position(|f| f.name == preferred) {
return Some(i);
}
}
for fallback in ["name", "title"] {
if let Some(i) = fields.iter().position(|f| f.name == fallback) {
return Some(i);
}
}
None
}
#[derive(Serialize)]
pub(crate) struct ConfirmDeleteCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub singular_name: &'static str,
pub object_id: i64,
pub object_label: String,
pub cascading: Vec<CascadeItem>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct CascadeItem {
pub source_display_name: String,
pub source_admin_name: String,
pub source_field: String,
}
pub(crate) fn confirm_delete_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
object_id: i64,
object_label: String,
cascading: Vec<CascadeItem>,
csrf_token: String,
) -> ConfirmDeleteCtx {
ConfirmDeleteCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Delete {}", entry.singular_name),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
singular_name: entry.singular_name,
object_id,
object_label,
cascading,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct ForbiddenCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub page_title: &'static str,
pub attempted: Option<String>,
pub required_role: Option<&'static str>,
}
#[derive(Serialize)]
pub(crate) struct ErrorCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub status: u16,
pub heading: String,
pub message: String,
}
pub(crate) fn admin_error_heading(status: u16) -> &'static str {
match status {
400 => "Bad request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not found",
405 => "Method not allowed",
409 => "Conflict",
500 => "Server error",
_ => "Error",
}
}
pub(crate) fn render_admin_error_response(
admin: &Admin,
templates: &crate::templates::Templates,
identity: Option<&Identity>,
status: u16,
message: String,
) -> crate::http::Response {
let heading = admin_error_heading(status).to_string();
let view = ErrorCtx {
base: BaseContext::new(identity, String::new(), admin),
page_title: format!("{status} {heading}"),
status,
heading: heading.clone(),
message,
};
let html_status =
hyper::StatusCode::from_u16(status).unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR);
match templates.render("admin/error.html", &view) {
Ok(body) => crate::http::Response::html(body).with_status(html_status),
Err(e) => {
log::error!("admin/error.html render failed: {e}");
crate::http::Response::text(format!("{status} {heading}: {}", view.message))
.with_status(html_status)
}
}
}
pub(crate) fn render_forbidden_body(
admin: &Admin,
templates: &crate::templates::Templates,
identity: &Identity,
csrf_token: String,
attempted: Option<String>,
required_role: Option<&'static str>,
) -> crate::error::Result<String> {
let view = ForbiddenCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
page_title: "Permission denied",
attempted,
required_role,
};
templates.render("admin/forbidden.html", &view)
}
#[derive(Serialize)]
pub(crate) struct HistoryEntryCtx {
pub timestamp_iso: String,
pub when_relative: String,
pub user_email: String,
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub model_admin_name: String,
pub object_id: i64,
pub summary: String,
pub ip_address: String,
}
#[derive(Serialize)]
pub(crate) struct ObjectHistoryCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub admin_name: String,
pub display_name: String,
pub singular_name: String,
pub object_id: i64,
pub object_label: String,
pub entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct LogEntriesCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
}
pub(crate) fn map_audit_actions(actions: Vec<AdminAction>) -> Vec<HistoryEntryCtx> {
actions
.into_iter()
.map(|a| HistoryEntryCtx {
timestamp_iso: a.timestamp.to_rfc3339(),
when_relative: relative_time(a.timestamp),
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name.clone(),
model_admin_name: a.model_name,
action_type: a.action_type,
object_id: a.object_id,
summary: a.summary,
ip_address: a.ip_address.unwrap_or_default(),
})
.collect()
}
#[derive(Serialize)]
pub(crate) struct PasswordChangeCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub errors: Vec<String>,
pub success: bool,
pub sections: Vec<FormSection>,
}
pub(crate) fn role_select_options() -> Vec<SelectOption> {
vec![
SelectOption {
value: "user".to_string(),
label: "User (no admin access)".to_string(),
},
SelectOption {
value: "staff".to_string(),
label: "Staff (admin access; per-model group permissions)".to_string(),
},
SelectOption {
value: "supervisor".to_string(),
label: "Supervisor (view + edit; no destructive ops)".to_string(),
},
SelectOption {
value: "administrator".to_string(),
label: "Administrator (full coverage; bypasses group checks)".to_string(),
},
SelectOption {
value: "developer".to_string(),
label: "Developer (highest tier)".to_string(),
},
]
}
pub(crate) fn user_new_form_sections(email: &str, role: &str) -> Vec<FormSection> {
vec![
FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some("Must be unique across all users.".to_string()),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some(
"At least 8 characters. The user can change it later via Change password."
.to_string(),
),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
},
FormSection {
title: Some("Role"),
fields: vec![FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: Some(
"Higher roles include all lower-role capabilities. Group memberships are assigned on the next page after save."
.to_string(),
),
placeholder: None,
required: true,
options: Some(role_select_options()),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}],
},
]
}
pub(crate) fn group_form_sections(name: &str, description: &str) -> Vec<FormSection> {
vec![FormSection {
title: Some("General"),
fields: vec![
FormField {
name: "name",
label: "Name".to_string(),
widget: "input",
input_type: "text",
value: name.to_string(),
hint: Some(
"A short identifier — letters, digits, dots and dashes only. Example: editors."
.to_string(),
),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: Some(150),
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "description",
label: "Description".to_string(),
widget: "textarea",
input_type: "text",
value: description.to_string(),
hint: Some("Optional. What this group is for.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
pub(crate) fn user_edit_identity_sections(
email: &str,
role: &str,
is_active: bool,
) -> Vec<FormSection> {
vec![FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some(
"Email changes aren't exposed here — they require a full user update."
.to_string(),
),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: true,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: None,
placeholder: None,
required: true,
options: Some(role_select_options()),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "is_active",
label: "Active".to_string(),
widget: "checkbox",
input_type: "checkbox",
value: if is_active {
"true".to_string()
} else {
"false".to_string()
},
hint: Some("Inactive users cannot sign in or hold sessions.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: is_active,
},
],
}]
}
pub(crate) fn user_edit_password_sections() -> Vec<FormSection> {
vec![FormSection {
title: Some("Reset password (optional)"),
fields: vec![FormField {
name: "new_password",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some("Leave blank to keep the current password unchanged.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}],
}]
}
pub(crate) fn password_change_form_sections() -> Vec<FormSection> {
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "old_password",
label: "Old password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("current-password"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password1",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some("Your password must contain at least 8 characters.".to_string()),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password2",
label: "Confirm".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}