use crate::utils::config::TraceResult;
use std::sync::Arc;
use axum::{
Extension, Router,
extract::Path,
http::{HeaderName, HeaderValue},
middleware,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde::Deserialize;
use tower_http::set_header::SetResponseHeaderLayer;
use crate::app::staging::AdminStaging;
use crate::auth::{
guard::LoginGuard,
session::{login, logout},
};
use crate::context::template::Request;
use crate::middleware::security::rate_limit_middleware;
use crate::urlpatterns;
use crate::utils::{
aliases::{AppResult, StrMap, StrVecMap},
trad::{current_lang, t, tf},
};
use crate::{
admin::{
PrototypeAdminState,
config::AdminConfig,
middleware::admin_required,
trad::{inject_admin_prefix, insert_admin_messages},
},
flash_now,
};
#[derive(Clone)]
pub struct AdminState {
pub config: Arc<AdminConfig>,
pub login_guard: Option<Arc<LoginGuard>>,
}
#[derive(Deserialize)]
struct AdminLoginData {
username: String,
password: String,
#[serde(default)]
csrf_token: String,
}
pub fn build_admin_router(admin_staging: AdminStaging) -> Router {
let admin_path = admin_staging
.config
.prefix
.trim_end_matches('/')
.to_string();
let mount_prefix = admin_staging.mount_prefix.clone();
let prefix = format!("{mount_prefix}{admin_path}");
let mut config = admin_staging.config;
config.prefix = prefix.clone();
let state = admin_staging.state;
let login_guard = config.login_guard.clone();
let rate_limiter = config.rate_limiter.clone();
let admin_state = Arc::new(AdminState {
config: Arc::new(config.clone()),
login_guard,
});
let login_get_route = urlpatterns! {
&format!("{prefix}/login") => get(admin_login_get), name = "admin_login",
};
let login_post_route = Router::new().route(&format!("{prefix}/login"), post(admin_login_post));
let login_post_route = if let Some(limiter) = rate_limiter {
login_post_route.layer(middleware::from_fn_with_state(
limiter,
rate_limit_middleware,
))
} else {
login_post_route
};
let public_router = login_get_route.merge(login_post_route);
let protected_router = urlpatterns! {
&format!("{prefix}/") => get(admin_dashboard), name = "admin_dashboard",
&prefix => get(admin_dashboard_redirect), name = "admin_dashboard_redirect",
&format!("{prefix}/logout") => post(admin_logout), name = "admin_logout",
&format!("{prefix}/toggle-template") => get(admin_toggle_template), name = "admin_toggle_template",
&format!("{prefix}/history") => get(admin_history), name = "admin_history",
&format!("{prefix}/history/timeline") => get(admin_history_timeline), name = "admin_history_timeline",
&format!("{prefix}/history/batch/{{batch_id}}") => get(admin_history_batch), name = "admin_history_batch",
&format!("{prefix}/history/{{id}}") => get(admin_history_diff), name = "admin_history_diff",
};
let generated_router = if let Some(router) = admin_staging.route_admin {
router
} else {
Router::new()
};
let generated_router =
admin_staging
.extra_routes
.into_iter()
.fold(generated_router, |router, route| {
let gated = route.router.layer(middleware::from_fn_with_state(
(route.resource, route.operation),
crate::admin::admin_main::gate::extra_route_gate,
));
router.route(&format!("{}{}", admin_path, route.path), gated)
});
let generated_router = if mount_prefix.is_empty() {
generated_router
} else {
Router::new().nest(&mount_prefix, generated_router)
};
let mut router = public_router
.merge(
protected_router
.merge(generated_router)
.layer(middleware::from_fn(admin_required)),
)
.layer(middleware::from_fn(
crate::admin::middleware::admin_middleware::load_admin_user,
))
.layer(Extension(admin_state))
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("x-robots-tag"),
HeaderValue::from_static("noindex, nofollow"),
));
if let Some(state) = state {
let order = config.resource_order.clone();
let config = Arc::new(config);
let registry = match Arc::try_unwrap(state) {
Ok(proto) => match Arc::try_unwrap(proto.registry) {
Ok(mut reg) => {
if !order.is_empty() {
reg.reorder(&order);
}
Arc::new(reg)
}
Err(arc) => arc,
},
Err(arc) => arc.registry.clone(),
};
let merged = Arc::new(PrototypeAdminState { registry, config });
router = router.layer(Extension(merged));
}
router
}
pub const ADMIN_TEMPLATE_SESSION_KEY: &str = "admin_template_override";
async fn admin_dashboard_redirect(Extension(admin): Extension<Arc<AdminState>>) -> Response {
Redirect::permanent(&format!("{}/", admin.config.prefix.trim_end_matches('/'))).into_response()
}
async fn admin_dashboard(
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
mut req: Request,
) -> AppResult<Response> {
let db = req.engine.db.clone();
let mut resource_counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
for (key, entry) in &state.registry.resources {
if let Some(count_fn) = &entry.count_fn
&& let Ok(n) = (count_fn)(db.clone(), None, Vec::new(), None).await
{
resource_counts.insert(key.clone(), n);
}
}
state
.registry
.all()
.filter(|e| current_user.is_superuser || current_user.can_access_resource(e.meta.key))
.map(|e| &e.meta)
.collect()
} else {
Vec::new()
};
let resource_groups: StrVecMap = {
use crate::auth::permissions::{groupe, groupes_droits};
use sea_orm::EntityTrait;
let groupes: std::collections::HashMap<_, String> = groupe::Entity::find()
.all(&*db)
.await
.trace(
crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list),
"load groupes for permission map",
)
.unwrap_or_default()
.into_iter()
.map(|g| (g.id, g.nom))
.collect();
let rows = groupes_droits::Entity::find()
.all(&*db)
.await
.trace(
crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list),
"load groupes_droits for permission map",
)
.unwrap_or_default();
let mut map: StrVecMap = StrVecMap::new();
for r in rows {
let nom = groupes
.get(&r.groupe_id)
.cloned()
.unwrap_or_else(|| r.groupe_id.to_string());
let entry = map.entry(r.resource_key).or_default();
if !entry.contains(&nom) {
entry.push(nom);
}
}
map
};
let session_override: Option<String> = req
.session
.get(ADMIN_TEMPLATE_SESSION_KEY)
.await
.unwrap_or(None);
insert_admin_messages(&mut req.context, "dashboard");
insert_admin_messages(&mut req.context, "base");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("current_user", ¤t_user)
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("resources", &resources)
.insert("resource_groups", &resource_groups)
.insert("resource_counts", &resource_counts)
.insert("current_page", "dashboard")
.insert("lang", current_lang().code())
.insert("current_resource", &Option::<String>::None)
.insert("admin_has_session_override", session_override.is_some());
let template = session_override
.as_deref()
.unwrap_or_else(|| admin.config.templates.dashboard.resolve());
req.render(template)
}
fn signed_in_admin(current_user: Option<&Extension<crate::auth::session::CurrentUser>>) -> bool {
current_user.is_some_and(|Extension(user)| user.can_access_admin())
}
async fn admin_login_get(
Extension(admin): Extension<Arc<AdminState>>,
axum::extract::Query(params): axum::extract::Query<StrMap>,
current_user: Option<Extension<crate::auth::session::CurrentUser>>,
mut req: Request,
) -> AppResult<Response> {
let from_logout = params.get("from").is_some_and(|v| v == "logout");
if !from_logout && signed_in_admin(current_user.as_ref()) {
return Ok(Redirect::to(&format!("{}/", admin.config.prefix)).into_response());
}
insert_admin_messages(&mut req.context, "login");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("lang", current_lang().code());
req.render(admin.config.templates.login.resolve())
}
async fn admin_login_post(
Extension(admin): Extension<Arc<AdminState>>,
current_user: Option<Extension<crate::auth::session::CurrentUser>>,
mut req: Request,
) -> Response {
use crate::utils::crypto::csrf::unmask_csrf_token;
use subtle::ConstantTimeEq;
if signed_in_admin(current_user.as_ref()) {
return Redirect::to(&format!("{}/", admin.config.prefix)).into_response();
}
if req.engine.session_store_saturated() {
return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
}
let data = AdminLoginData {
username: req.prisme.data.get("username").cloned().unwrap_or_default(),
password: req.prisme.data.get("password").cloned().unwrap_or_default(),
csrf_token: req
.prisme
.data
.get(crate::utils::constante::session_key::session::CSRF_TOKEN_KEY)
.cloned()
.unwrap_or_default(),
};
let csrf_valid = unmask_csrf_token(&data.csrf_token)
.map(|unmasked| {
bool::from(
unmasked
.as_bytes()
.ct_eq(req.csrf_token.as_str().as_bytes()),
)
})
.unwrap_or(false);
if !csrf_valid {
insert_admin_messages(&mut req.context, "login");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("error", t("csrf.invalid_or_missing").to_string());
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
if guard.is_locked(&key) {
let secs = guard.remaining_lockout_secs(&key).unwrap_or(0);
insert_admin_messages(&mut req.context, "login");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("error", tf("admin.login.error_locked", &[secs]));
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
}
let result =
crate::auth::authenticate_admin(&req.engine.db, &data.username, &data.password).await;
if let Some(user) = result {
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
guard.record_success(&key);
}
let db_store = req
.engine
.session_db_store
.read()
.ok()
.and_then(|g| g.as_ref().cloned());
let exclusive = req.engine.features.exclusive_login;
if login(&req.session, &user, db_store.as_deref(), exclusive)
.await
.is_err()
{
insert_admin_messages(&mut req.context, "login");
insert_admin_messages(&mut req.context, "base");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("error", t("admin.login.error_session").to_string());
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
Redirect::to(&format!("{}/", admin.config.prefix)).into_response()
} else {
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
guard.record_failure(&key);
}
insert_admin_messages(&mut req.context, "login");
insert_admin_messages(&mut req.context, "base");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("error", t("admin.login.error_credentials").to_string());
req.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response)
}
}
fn resource_title_map<'a>(
resources: &[&'a crate::admin::AdminResource],
) -> std::collections::HashMap<&'a str, &'a str> {
resources.iter().map(|r| (r.key, r.title)).collect()
}
fn object_label_from_json(obj: &serde_json::Value, preferred: Option<&str>) -> Option<String> {
fn stringify(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
if let Some(col) = preferred
&& let Some(s) = obj.get(col).and_then(stringify)
{
return Some(s);
}
const FALLBACK: &[&str] = &[
"nom", "name", "titre", "title", "libelle", "username", "label", "email",
];
FALLBACK
.iter()
.find_map(|key| obj.get(*key).and_then(stringify))
}
fn inject_admin_chrome(
mut req: Request,
admin: &AdminState,
current_user: &crate::auth::session::CurrentUser,
resources: &[&crate::admin::AdminResource],
current_page: &str,
) -> Request {
insert_admin_messages(&mut req.context, "base");
inject_admin_prefix(&mut req.context, &admin.config.prefix);
req.insert("current_page", current_page)
.insert("current_resource", &Option::<String>::None)
.insert("resources", resources)
.insert("resource_titles", resource_title_map(resources))
.insert("current_user", current_user)
.insert("site_title", &admin.config.site_title)
.insert("site_url", admin.config.view_site_href())
.insert("lang", current_lang().code())
}
async fn admin_history(
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
axum::extract::Query(params): axum::extract::Query<StrMap>,
mut req: Request,
) -> AppResult<Response> {
use crate::admin::history;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
if !current_user.is_staff && !current_user.is_superuser {
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
let page = params
.get("page")
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(1)
.max(1);
let per_page = admin.config.page_size;
let filter_resource: Option<String> = params.get("resource").filter(|v| !v.is_empty()).cloned();
let filter_action: Option<String> = params.get("action").filter(|v| !v.is_empty()).cloned();
let filter_user: Option<String> = params.get("user").filter(|v| !v.is_empty()).cloned();
let mut query = history::Entity::find().order_by_desc(history::Column::CreatedAt);
if let Some(ref res) = filter_resource {
query = query.filter(history::Column::ResourceKey.eq(res.clone()));
}
if let Some(ref action) = filter_action {
query = query.filter(history::Column::Action.eq(action.clone()));
}
if let Some(ref user) = filter_user {
query = query.filter(history::Column::Username.eq(user.clone()));
}
if !current_user.is_superuser {
let allowed = proto
.as_ref()
.map(|Extension(s)| s.registry.accessible_keys(¤t_user))
.unwrap_or_default();
query = query.filter(history::Column::ResourceKey.is_in(allowed));
}
let paginator = query.paginate(&req.engine.db, per_page);
let total_pages = paginator.num_pages().await.unwrap_or(1);
let raw_entries: Vec<history::Model> = paginator
.fetch_page(page - 1)
.await
.trace(
crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list),
"history fetch_page",
)
.unwrap_or_default();
#[derive(serde::Serialize)]
struct HistoryRow {
id: i64,
resource_key: String,
object_pk: String,
action: String,
username: String,
created_at: chrono::NaiveDateTime,
summary: Option<String>,
batch_id: Option<String>,
batch_count: usize,
object_label: Option<String>,
}
let mut entries: Vec<HistoryRow> = Vec::new();
let mut seen_batches: std::collections::HashSet<String> = std::collections::HashSet::new();
for e in raw_entries {
if let Some(ref bid) = e.batch_id {
if seen_batches.contains(bid) {
if let Some(row) = entries
.iter_mut()
.find(|r| r.batch_id.as_deref() == Some(bid))
{
row.batch_count += 1;
}
continue;
}
seen_batches.insert(bid.clone());
}
entries.push(HistoryRow {
id: e.id,
resource_key: e.resource_key,
object_pk: e.object_pk,
action: e.action,
username: e.username,
created_at: e.created_at,
summary: e.summary,
batch_id: e.batch_id,
batch_count: 1,
object_label: None,
});
}
let resources = proto
.as_ref()
.map(|Extension(s)| s.registry.visible_to(¤t_user))
.unwrap_or_default();
if let Some(Extension(ref state)) = proto {
let mut cache: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::new();
for row in &entries {
if row.batch_count > 1 || row.object_pk.is_empty() {
continue;
}
let key = (row.resource_key.clone(), row.object_pk.clone());
if cache.contains_key(&key) {
continue;
}
let Some(res) = state.registry.get(&row.resource_key) else {
continue;
};
let Some(get_fn) = &res.get_fn else { continue };
let preferred = match &res.meta.display.columns {
crate::admin::ColumnFilter::Include(cols) => cols.first().map(|(c, _)| c.clone()),
_ => None,
};
if let Ok(Some(obj)) = get_fn(req.engine.db.clone(), row.object_pk.clone()).await
&& let Some(label) = object_label_from_json(&obj, preferred.as_deref())
{
cache.insert(key, label);
}
}
for row in &mut entries {
row.object_label = cache
.get(&(row.resource_key.clone(), row.object_pk.clone()))
.cloned();
}
}
req = req
.insert("entries", &entries)
.insert("page", page)
.insert("total_pages", total_pages)
.insert("filter_resource", &filter_resource)
.insert("filter_action", &filter_action)
.insert("filter_user", &filter_user);
req = inject_admin_chrome(req, &admin, ¤t_user, &resources, "history");
req.render("admin/history.html")
}
async fn admin_history_diff(
Path(entry_id): Path<i64>,
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
mut req: Request,
) -> AppResult<Response> {
use crate::admin::history;
use crate::context::template::AppError;
use crate::errors::error::ErrorContext;
use sea_orm::EntityTrait;
if !current_user.is_staff && !current_user.is_superuser {
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
let entry = history::Entity::find_by_id(entry_id)
.one(&req.engine.db)
.await
.map_err(|e| Box::new(AppError::new(ErrorContext::database(e))))?
.ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Entry not found"))))?;
if !current_user.is_superuser && !current_user.can_access_resource(&entry.resource_key) {
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
#[derive(serde::Serialize)]
struct DiffField {
key: String,
old_val: String,
new_val: String,
}
let diff_fields: Vec<DiffField> = entry
.summary
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.as_object().cloned())
.map(|mut map| {
crate::admin::history::redact_sensitive(&mut map);
let mut fields: Vec<DiffField> = map
.into_iter()
.filter_map(|(k, v)| {
let obj = v.as_object()?;
let str_val = |v: &serde_json::Value| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
Some(DiffField {
key: k,
old_val: obj.get("old").map(str_val).unwrap_or_default(),
new_val: obj.get("new").map(str_val).unwrap_or_default(),
})
})
.collect();
fields.sort_by(|a, b| a.key.cmp(&b.key));
fields
})
.unwrap_or_default();
let resources = proto
.as_ref()
.map(|Extension(s)| s.registry.visible_to(¤t_user))
.unwrap_or_default();
req = req
.insert("entry", &entry)
.insert("diff_fields", &diff_fields);
req = inject_admin_chrome(req, &admin, ¤t_user, &resources, "history");
req.render("admin/history_diff.html")
}
async fn admin_history_timeline(
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
axum::extract::Query(params): axum::extract::Query<StrMap>,
mut req: Request,
) -> AppResult<Response> {
use crate::admin::history;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
if !current_user.is_staff && !current_user.is_superuser {
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
let page = params
.get("page")
.and_then(|p| p.parse::<u64>().ok())
.unwrap_or(1)
.max(1);
let per_page = admin.config.page_size;
let filter_user_id: Option<crate::utils::Pk> =
params.get("user_id").and_then(|v| v.parse().ok());
let filter_resource: Option<String> = params.get("resource").filter(|v| !v.is_empty()).cloned();
let filter_object_id: Option<String> =
params.get("object_id").filter(|v| !v.is_empty()).cloned();
let mut query = history::Entity::find().order_by_desc(history::Column::CreatedAt);
if let Some(uid) = filter_user_id {
query = query.filter(history::Column::UserId.eq(uid));
}
if let Some(ref res) = filter_resource {
query = query.filter(history::Column::ResourceKey.eq(res.clone()));
}
if let Some(ref oid) = filter_object_id {
query = query.filter(history::Column::ObjectPk.eq(oid.clone()));
}
if !current_user.is_superuser {
let allowed = proto
.as_ref()
.map(|Extension(s)| s.registry.accessible_keys(¤t_user))
.unwrap_or_default();
query = query.filter(history::Column::ResourceKey.is_in(allowed));
}
let paginator = query.paginate(&req.engine.db, per_page);
let total_pages = paginator.num_pages().await.unwrap_or(1);
let total = paginator.num_items().await.unwrap_or(0);
let entries: Vec<history::Model> = paginator
.fetch_page(page - 1)
.await
.trace(
crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list),
"history fetch_page",
)
.unwrap_or_default();
let filter_type = if filter_object_id.is_some() && filter_resource.is_some() {
"object"
} else if filter_user_id.is_some() {
"user"
} else if filter_resource.is_some() {
"resource"
} else {
"all"
};
let resources = proto
.as_ref()
.map(|Extension(s)| s.registry.visible_to(¤t_user))
.unwrap_or_default();
req = req
.insert("entries", &entries)
.insert("page", page)
.insert("total_pages", total_pages)
.insert("total", total)
.insert("filter_type", filter_type)
.insert("filter_user_id", filter_user_id)
.insert("filter_resource", &filter_resource)
.insert("filter_object_id", &filter_object_id);
req = inject_admin_chrome(req, &admin, ¤t_user, &resources, "history");
req.render("admin/history_timeline.html")
}
async fn admin_history_batch(
Path(batch_id): Path<String>,
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
mut req: Request,
) -> AppResult<Response> {
use crate::admin::history;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
if !current_user.is_staff && !current_user.is_superuser {
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
let entries: Vec<history::Model> = history::Entity::find()
.filter(history::Column::BatchId.eq(batch_id.clone()))
.order_by_asc(history::Column::Id)
.all(&req.engine.db)
.await
.trace(
crate::utils::runique_log::get_log()
.admin
.as_ref()
.and_then(|a| a.list),
"history batch fetch",
)
.unwrap_or_default();
if !current_user.is_superuser
&& let Some(first) = entries.first()
&& !current_user.can_access_resource(&first.resource_key)
{
req.notices
.error(t("admin.access.insufficient_rights").to_string())
.await;
return Ok(
axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
);
}
#[derive(serde::Serialize)]
struct DiffField {
key: String,
old_val: String,
new_val: String,
}
#[derive(serde::Serialize)]
struct BatchEntry {
id: i64,
object_pk: String,
display_name: Option<String>,
action: String,
summary: Option<String>,
diff_fields: Vec<DiffField>,
}
fn parse_diff(summary: Option<&str>) -> Vec<DiffField> {
summary
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.as_object().cloned())
.map(|mut map| {
crate::admin::history::redact_sensitive(&mut map);
let mut fields: Vec<DiffField> = map
.into_iter()
.filter_map(|(k, v)| {
let obj = v.as_object()?;
let str_val = |v: &serde_json::Value| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
Some(DiffField {
key: k,
old_val: obj.get("old").map(str_val).unwrap_or_default(),
new_val: obj.get("new").map(str_val).unwrap_or_default(),
})
})
.collect();
fields.sort_by(|a, b| a.key.cmp(&b.key));
fields
})
.unwrap_or_default()
}
fn extract_display_name(val: &serde_json::Value) -> Option<String> {
let obj = val.as_object()?;
for key in &["username", "name", "title", "email", "label", "slug"] {
if let Some(serde_json::Value::String(s)) = obj.get(*key)
&& !s.is_empty()
{
return Some(s.clone());
}
}
None
}
let resource_key = entries
.first()
.map(|e| e.resource_key.clone())
.unwrap_or_default();
let username = entries
.first()
.map(|e| e.username.clone())
.unwrap_or_default();
let created_at = entries.first().map(|e| e.created_at);
let action = entries
.first()
.map(|e| e.action.clone())
.unwrap_or_default();
let get_fn = proto
.as_ref()
.and_then(|Extension(state)| state.registry.get(&resource_key))
.and_then(|entry| entry.get_fn.clone());
let mut batch_entries: Vec<BatchEntry> = Vec::new();
for e in entries {
let display_name = if let Some(ref f) = get_fn {
f(req.engine.db.clone(), e.object_pk.clone())
.await
.ok()
.flatten()
.as_ref()
.and_then(extract_display_name)
} else {
None
};
let diff = parse_diff(e.summary.as_deref());
batch_entries.push(BatchEntry {
id: e.id,
object_pk: e.object_pk,
display_name,
action: e.action,
summary: e.summary,
diff_fields: diff,
});
}
let resources = proto
.as_ref()
.map(|Extension(s)| s.registry.visible_to(¤t_user))
.unwrap_or_default();
req = req
.insert("batch_id", &batch_id)
.insert("batch_entries", &batch_entries)
.insert("resource_key", &resource_key)
.insert("username", &username)
.insert("created_at", created_at)
.insert("action", &action);
req = inject_admin_chrome(req, &admin, ¤t_user, &resources, "history");
req.render("admin/history_batch.html")
}
async fn admin_toggle_template(
Extension(admin): Extension<Arc<AdminState>>,
req: Request,
) -> Response {
let current: Option<String> = req
.session
.get::<String>(ADMIN_TEMPLATE_SESSION_KEY)
.await
.unwrap_or(None);
let session_level = crate::utils::runique_log::get_log()
.session
.as_ref()
.and_then(|s| s.store);
if current.is_some() {
req.session
.remove::<String>(ADMIN_TEMPLATE_SESSION_KEY)
.await
.trace(session_level, "remove admin template session key");
} else {
req.session
.insert(ADMIN_TEMPLATE_SESSION_KEY, "admin/dashboard.html")
.await
.trace(session_level, "insert admin template session key");
}
Redirect::to(&format!("{}/", admin.config.prefix)).into_response()
}
async fn admin_logout(Extension(admin): Extension<Arc<AdminState>>, req: Request) -> Response {
let session = &req.session;
let db_store = req
.engine
.session_db_store
.read()
.ok()
.and_then(|g| g.as_ref().cloned());
logout(session, db_store.as_deref()).await.trace(
crate::utils::runique_log::get_log()
.session
.as_ref()
.and_then(|s| s.store),
"admin logout",
);
let login_url = format!("{}/login?from=logout", admin.config.prefix);
flash_now!(info => t("admin.logout_success").as_ref());
Redirect::to(&login_url).into_response()
}