#![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 has_theme_overrides: bool,
pub accent_hex: Option<String>,
pub accent_rgb: Option<String>,
pub theme_bg: Option<String>,
pub theme_surface: Option<String>,
pub theme_text: Option<String>,
pub theme_text_muted: Option<String>,
pub theme_border: Option<String>,
}
pub(crate) fn hex_to_rgb_triplet(hex: &str) -> String {
const FALLBACK: &str = "160 52 26"; 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(160);
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(52);
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(26);
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 = accent_hex.as_deref().map(hex_to_rgb_triplet);
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,
has_theme_overrides: theme.has_overrides(),
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,
}
}
#[derive(Serialize)]
pub(crate) struct AccountSessionsCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub sessions: Vec<AccountSessionRowCtx>,
}
#[derive(Serialize)]
pub(crate) struct AccountSessionRowCtx {
pub session_id: i64,
pub trust_label: &'static str,
pub is_current: bool,
pub ip: String,
pub ua_summary: String,
pub created_at: String,
pub last_seen_relative: String,
pub expires_relative: String,
}
pub(crate) fn account_sessions_ctx(
identity: &Identity,
admin: &Admin,
sessions: Vec<crate::auth::Session>,
current_session_id: Option<i64>,
csrf_token: String,
) -> AccountSessionsCtx {
let rows = sessions
.into_iter()
.map(|s| AccountSessionRowCtx {
session_id: s.session_id,
trust_label: trust_label(s.trust_level),
is_current: Some(s.session_id) == current_session_id,
ip: s.ip.unwrap_or_else(|| "—".to_string()),
ua_summary: summarise_user_agent(s.user_agent.as_deref()),
created_at: s.created_at.format("%Y-%m-%d %H:%M").to_string(),
last_seen_relative: relative_time(s.last_seen),
expires_relative: relative_time(s.expires_at),
})
.collect();
AccountSessionsCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Active sessions",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sessions: rows,
}
}
const fn trust_label(t: crate::auth::SessionTrust) -> &'static str {
match t {
crate::auth::SessionTrust::Authenticated => "Signed in",
crate::auth::SessionTrust::Elevated => "Elevated",
crate::auth::SessionTrust::MfaVerified => "MFA verified",
}
}
pub(crate) fn summarise_user_agent(ua: Option<&str>) -> String {
let Some(ua) = ua else {
return "—".to_string();
};
let lc = ua.to_ascii_lowercase();
let os = if lc.contains("windows") {
"Windows"
} else if lc.contains("iphone") {
"iOS"
} else if lc.contains("ipad") {
"iPadOS"
} else if lc.contains("android") {
"Android"
} else if lc.contains("mac os x") || lc.contains("macos") {
"macOS"
} else if lc.contains("linux") {
"Linux"
} else {
"—"
};
let browser = if lc.contains("firefox") {
"Firefox"
} else if lc.contains("edg/") {
"Edge"
} else if lc.contains("opr/") || lc.contains("opera") {
"Opera"
} else if lc.contains("chrome") {
"Chrome"
} else if lc.contains("safari") {
"Safari"
} else if lc.contains("curl") {
"curl"
} else {
"—"
};
if os == "—" && browser == "—" {
ua.chars().take(40).collect()
} else {
format!("{os} · {browser}")
}
}
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 active_filter_count: usize,
pub active_filter_pairs: Vec<(String, String)>,
pub active_filter_pills: Vec<ActiveFilterPillCtx>,
pub clear_all_filters_link: String,
pub sort_options: Vec<SortOptionCtx>,
pub current_sort_label: String,
pub active_sort_field: Option<String>,
pub active_sort_dir: Option<&'static str>,
pub per_page_options: Vec<PerPageOptionCtx>,
pub current_per_page_label: String,
pub active_per_page_override: Option<usize>,
pub page: usize,
pub total_pages: usize,
pub per_page: usize,
pub total_rows: usize,
pub prev_page_link: Option<String>,
pub next_page_link: Option<String>,
pub page_items: Vec<PageItem>,
pub bulk_actions_enabled: bool,
pub bulk_action_buttons: Vec<BulkActionBtnCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct BulkActionBtnCtx {
pub name: &'static str,
pub label: &'static str,
pub destructive: bool,
pub form_action: String,
}
#[derive(Serialize)]
pub(crate) struct ListRowCtx {
pub id: i64,
#[serde(flatten)]
pub values: HashMap<String, serde_json::Value>,
pub links: HashMap<String, String>,
}
#[derive(Serialize)]
pub(crate) struct FilterGroupCtx {
pub field: String,
pub label: String,
pub options: Vec<FilterOptionCtx>,
pub current: Option<String>,
#[serde(default)]
pub all_link: String,
}
#[derive(Serialize)]
pub(crate) struct FilterOptionCtx {
pub value: String,
pub label: String,
pub selected: bool,
#[serde(default)]
pub link: String,
}
#[derive(Serialize)]
pub(crate) struct SortOptionCtx {
pub label: String,
pub link: String,
pub is_active: bool,
}
#[derive(Serialize)]
pub(crate) struct PerPageOptionCtx {
pub value: usize,
pub label: String,
pub link: String,
pub is_active: bool,
}
#[derive(Serialize)]
pub(crate) struct ActiveFilterPillCtx {
pub label: String,
pub value_label: String,
pub remove_link: String,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum PageItem {
Number {
number: usize,
link: String,
is_active: bool,
},
Ellipsis,
}
fn build_page_items(
current: usize,
total: usize,
build_link: impl Fn(usize) -> String,
) -> Vec<PageItem> {
if total <= 1 {
return Vec::new();
}
let mk = |n: usize| PageItem::Number {
number: n,
link: build_link(n),
is_active: n == current,
};
if total <= 7 {
return (1..=total).map(mk).collect();
}
let mut items: Vec<PageItem> = Vec::with_capacity(9);
items.push(mk(1));
if current > 3 {
items.push(PageItem::Ellipsis);
}
let start = current.saturating_sub(1).max(2);
let end = (current + 1).min(total - 1);
for n in start..=end {
items.push(mk(n));
}
if current + 2 < total {
items.push(PageItem::Ellipsis);
}
items.push(mk(total));
items
}
fn sort_direction_label(
field_type: super::types::FieldType,
dir: super::modeladmin::SortDir,
) -> &'static str {
use super::modeladmin::SortDir;
use super::types::FieldType::*;
match (field_type, dir) {
(DateTime | OptionalDateTime, SortDir::Desc) => "newest first",
(DateTime | OptionalDateTime, SortDir::Asc) => "oldest first",
(String | OptionalString, SortDir::Asc) => "A → Z",
(String | OptionalString, SortDir::Desc) => "Z → A",
(Bool, SortDir::Asc) => "off → on",
(Bool, SortDir::Desc) => "on → off",
(_, SortDir::Asc) => "ascending",
(_, SortDir::Desc) => "descending",
}
}
fn build_list_url(
admin_name: &str,
q: &str,
filters: &[(String, String)],
sort: Option<(&str, super::modeladmin::SortDir)>,
page: usize,
per_page: Option<usize>,
) -> String {
use super::modeladmin::SortDir;
let mut parts: Vec<String> = Vec::new();
if !q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(q)));
}
for (field, value) in filters {
parts.push(format!(
"{}={}",
urlencoding::encode(field),
urlencoding::encode(value),
));
}
if let Some((col, dir)) = sort {
parts.push(format!("sort={}", urlencoding::encode(col)));
parts.push(
match dir {
SortDir::Asc => "dir=asc",
SortDir::Desc => "dir=desc",
}
.to_string(),
);
}
if page > 1 {
parts.push(format!("page={}", page));
}
if let Some(n) = per_page {
parts.push(format!("per_page={}", n));
}
if parts.is_empty() {
format!("/admin/{}", admin_name)
} else {
format!("/admin/{}?{}", admin_name, parts.join("&"))
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn list_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
rows: Vec<ListRow>,
search_query: String,
mut filters: Vec<FilterGroupCtx>,
page: usize,
per_page: usize,
per_page_override: Option<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 active_filter_pairs: Vec<(String, String)> = filters
.iter()
.filter_map(|g| g.current.as_ref().map(|v| (g.field.clone(), v.clone())))
.collect();
let active_sort_ref: Option<(&str, super::modeladmin::SortDir)> =
active_sort.as_ref().map(|(c, d)| (c.as_str(), *d));
for group in &mut filters {
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &group.field)
.cloned()
.collect();
group.all_link = build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
);
for opt in &mut group.options {
let mut combined = other.clone();
combined.push((group.field.clone(), opt.value.clone()));
opt.link = build_list_url(
entry.admin_name,
&search_query,
&combined,
active_sort_ref,
1,
per_page_override,
);
}
}
let clear_all_filters_link = build_list_url(
entry.admin_name,
&search_query,
&[],
active_sort_ref,
1,
per_page_override,
);
let active_filter_pills: Vec<ActiveFilterPillCtx> = filters
.iter()
.filter_map(|g| {
let v = g.current.as_ref()?;
let value_label = g
.options
.iter()
.find(|o| &o.value == v)
.map(|o| o.label.clone())
.unwrap_or_else(|| v.clone());
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &g.field)
.cloned()
.collect();
Some(ActiveFilterPillCtx {
label: g.label.clone(),
value_label,
remove_link: build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
),
})
})
.collect();
let visible_fields: Vec<&AdminField> = if entry.list_display.is_empty() {
entry.fields.iter().collect()
} else {
entry
.list_display
.iter()
.filter_map(|name| entry.fields.iter().find(|f| f.name == *name))
.collect()
};
let fields: Vec<ListField> = visible_fields
.iter()
.map(|f| {
let (sort_active, sort_link) = build_sort_link(
f.name,
&active_sort,
entry.admin_name,
&search_query,
&active_filter_pairs,
per_page_override,
);
ListField {
name: f.name.to_string(),
label: f.label.to_string(),
kind: f.field_type.widget(),
sort_active,
sort_link,
}
})
.collect();
use super::modeladmin::SortDir;
let mut sort_options: Vec<SortOptionCtx> = Vec::with_capacity(visible_fields.len() * 2 + 1);
sort_options.push(SortOptionCtx {
label: "Default order".to_string(),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
None,
1,
per_page_override,
),
is_active: active_sort.is_none(),
});
for f in &visible_fields {
for dir in [SortDir::Asc, SortDir::Desc] {
let dir_label = sort_direction_label(f.field_type, dir);
let is_active = matches!(
&active_sort,
Some((col, d)) if col == f.name && *d == dir
);
sort_options.push(SortOptionCtx {
label: format!("{} ({})", f.label, dir_label),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
Some((f.name, dir)),
1,
per_page_override,
),
is_active,
});
}
}
let current_sort_label = sort_options
.iter()
.find(|o| o.is_active)
.map(|o| o.label.clone())
.unwrap_or_else(|| "Default order".to_string());
let prev_page_link = (page > 1).then(|| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
page - 1,
per_page_override,
)
});
let next_page_link = (page < total_pages).then(|| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
page + 1,
per_page_override,
)
});
let page_items = build_page_items(page, total_pages, |n| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
n,
per_page_override,
)
});
let (active_sort_field, active_sort_dir) = match &active_sort {
Some((col, SortDir::Asc)) => (Some(col.clone()), Some("asc")),
Some((col, SortDir::Desc)) => (Some(col.clone()), Some("desc")),
None => (None, None),
};
let per_page_choices: [usize; 4] = [25, 50, 100, 200];
let model_default_per_page = entry.list_per_page;
let per_page_options: Vec<PerPageOptionCtx> = per_page_choices
.iter()
.map(|&n| {
let override_for_link = (n != model_default_per_page).then_some(n);
PerPageOptionCtx {
value: n,
label: format!("{n} / page"),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
1,
override_for_link,
),
is_active: per_page == n,
}
})
.collect();
let current_per_page_label = format!("{per_page} / page");
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));
let mut links: HashMap<String, String> = HashMap::new();
let cell_links = r.cell_links;
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);
if let Some(Some(link)) = cell_links.get(i) {
links.insert(
(*name).to_string(),
format!("/admin/{}/{}/edit", link.admin_name, link.id),
);
}
}
}
ListRowCtx {
id: r.id,
values,
links,
}
})
.collect(),
search_query,
active_filter_count: filters.iter().filter(|g| g.current.is_some()).count(),
active_filter_pairs,
active_filter_pills,
clear_all_filters_link,
filters,
sort_options,
current_sort_label,
active_sort_field,
active_sort_dir,
per_page_options,
current_per_page_label,
active_per_page_override: per_page_override,
page,
total_pages,
per_page,
total_rows,
prev_page_link,
next_page_link,
page_items,
bulk_actions_enabled: false,
bulk_action_buttons: entry
.bulk_actions
.iter()
.map(|a| BulkActionBtnCtx {
name: a.name,
label: a.label,
destructive: a.destructive,
form_action: format!("/admin/{}/bulk/{}", entry.admin_name, a.name),
})
.collect(),
flash: None,
}
}
fn build_sort_link(
name: &'static str,
active: &Option<(String, super::modeladmin::SortDir)>,
admin_name: &str,
q: &str,
filters: &[(String, String)],
per_page: Option<usize>,
) -> (&'static str, String) {
use super::modeladmin::SortDir;
let (marker, new_sort) = match active {
Some((col, SortDir::Asc)) if col == name => ("asc", Some((name, SortDir::Desc))),
Some((col, SortDir::Desc)) if col == name => ("desc", None),
_ => ("", Some((name, SortDir::Asc))),
};
(
marker,
build_list_url(admin_name, q, filters, new_sort, 1, per_page),
)
}
#[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 display_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,
display_name: entry.display_name,
singular_name: entry.singular_name,
object_id,
object_label,
cascading,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct BulkConfirmDeleteCtx {
#[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 items: Vec<BulkDeleteItem>,
pub ids_csv: String,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct BulkDeleteItem {
pub id: i64,
pub label: String,
}
#[derive(Serialize)]
pub(crate) struct BulkConfirmActionCtx {
#[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 action_name: &'static str,
pub action_label: &'static str,
pub action_destructive: bool,
pub items: Vec<BulkDeleteItem>,
pub ids_csv: String,
pub flash: Option<FlashCtx>,
}
pub(crate) fn bulk_confirm_action_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
action: super::modeladmin::BulkAction,
items: Vec<BulkDeleteItem>,
csrf_token: String,
) -> BulkConfirmActionCtx {
let ids_csv = items
.iter()
.map(|i| i.id.to_string())
.collect::<Vec<_>>()
.join(",");
BulkConfirmActionCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("{} — {} {}", action.label, items.len(), entry.display_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,
action_name: action.name,
action_label: action.label,
action_destructive: action.destructive,
items,
ids_csv,
flash: None,
}
}
pub(crate) fn bulk_confirm_delete_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
items: Vec<BulkDeleteItem>,
csrf_token: String,
) -> BulkConfirmDeleteCtx {
let ids_csv = items
.iter()
.map(|i| i.id.to_string())
.collect::<Vec<_>>()
.join(",");
BulkConfirmDeleteCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Delete {} {}", items.len(), entry.display_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,
items,
ids_csv,
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(editor_rank: u32) -> Vec<SelectOption> {
let all = [
(crate::auth::Role::User, "user", "User (no admin access)"),
(
crate::auth::Role::Staff,
"staff",
"Staff (admin access; per-model group permissions)",
),
(
crate::auth::Role::Supervisor,
"supervisor",
"Supervisor (view + edit; no destructive ops)",
),
(
crate::auth::Role::Administrator,
"administrator",
"Administrator (full coverage; bypasses group checks)",
),
(
crate::auth::Role::Developer,
"developer",
"Developer (highest tier)",
),
];
all.iter()
.filter(|(role, _, _)| role.rank() <= editor_rank)
.map(|(_, slug, label)| SelectOption {
value: (*slug).to_string(),
label: (*label).to_string(),
})
.collect()
}
pub(crate) fn user_new_form_sections(
email: &str,
role: &str,
editor_rank: u32,
) -> 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(editor_rank)),
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,
editor_rank: u32,
) -> 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(editor_rank)),
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(min_length: usize) -> Vec<FormSection> {
let new_password_hint = format!("At least {min_length} characters.");
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(new_password_hint),
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,
},
],
}]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ua_summary_macos_safari() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
assert_eq!(summarise_user_agent(Some(ua)), "macOS · Safari");
}
#[test]
fn ua_summary_windows_chrome() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
assert_eq!(summarise_user_agent(Some(ua)), "Windows · Chrome");
}
#[test]
fn ua_summary_linux_firefox() {
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0";
assert_eq!(summarise_user_agent(Some(ua)), "Linux · Firefox");
}
#[test]
fn ua_summary_android_chrome() {
let ua = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
assert_eq!(summarise_user_agent(Some(ua)), "Android · Chrome");
}
#[test]
fn ua_summary_ios_safari() {
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
assert_eq!(summarise_user_agent(Some(ua)), "iOS · Safari");
}
#[test]
fn ua_summary_curl_falls_through_to_unknown_os() {
let ua = "curl/8.4.0";
let s = summarise_user_agent(Some(ua));
assert!(s.contains("curl"));
}
#[test]
fn ua_summary_unknown_returns_truncated() {
let ua =
"QuiteUnusualUserAgent/1.0 with extremely long descriptor that should be truncated";
let s = summarise_user_agent(Some(ua));
assert!(s.len() <= 40);
}
#[test]
fn ua_summary_none_returns_dash() {
assert_eq!(summarise_user_agent(None), "—");
}
#[test]
fn trust_label_strings() {
assert_eq!(
trust_label(crate::auth::SessionTrust::Authenticated),
"Signed in"
);
assert_eq!(trust_label(crate::auth::SessionTrust::Elevated), "Elevated");
assert_eq!(
trust_label(crate::auth::SessionTrust::MfaVerified),
"MFA verified"
);
}
#[test]
fn password_change_form_sections_renders_live_min_length() {
let sections = password_change_form_sections(10);
assert_eq!(sections.len(), 1);
let fields = §ions[0].fields;
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].name, "old_password");
assert_eq!(fields[1].name, "new_password1");
assert_eq!(fields[2].name, "new_password2");
assert_eq!(
fields[1].hint.as_deref(),
Some("At least 10 characters."),
"default-policy floor 10 must surface in the hint"
);
let sections = password_change_form_sections(16);
assert_eq!(
sections[0].fields[1].hint.as_deref(),
Some("At least 16 characters."),
);
}
#[test]
fn password_change_form_sections_only_new_password_carries_hint() {
let sections = password_change_form_sections(10);
let fields = §ions[0].fields;
assert!(fields[0].hint.is_none(), "old_password must have no hint");
assert!(fields[1].hint.is_some(), "new_password1 must have the hint");
assert!(fields[2].hint.is_none(), "new_password2 must have no hint");
}
}