use std::collections::HashMap;
use askama::Template;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Form};
use laterite_auth::{AuthenticatedUser, PermissionSet};
use laterite_core::query::{bind_values, build as to_sql, text_cast};
use laterite_core::AnyRowExt;
use sea_query::{Alias, Expr, Query};
use crate::{not_found, render, render_error, AdminState, Permission, Shell};
pub(crate) async fn edit_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(editor): Extension<AuthenticatedUser>,
Path(id): Path<String>,
) -> Response {
let (sql, values) = {
let stmt = Query::select()
.columns([
Alias::new("username"),
Alias::new("first_name"),
Alias::new("last_name"),
Alias::new("email"),
Alias::new("is_superuser"),
Alias::new("permissions"),
])
.from(Alias::new("backend_users"))
.and_where(
Expr::col(Alias::new("id"))
.cast_as(Alias::new(text_cast(state.db.backend)))
.eq(id.clone()),
)
.to_owned();
to_sql(state.db.backend, stmt)
};
let row = match bind_values(sqlx::query(&sql), values)
.fetch_optional(&state.db.pool)
.await
{
Ok(row) => row,
Err(_) => return render_error(),
};
let Some(row) = row else {
return not_found();
};
let username = row.get_text("username").unwrap_or_default();
let first_name = row.get_text("first_name").unwrap_or_default();
let last_name = row.get_text_opt("last_name").unwrap_or_default();
let email = row.get_text("email").unwrap_or_default();
let is_superuser = row.get_bool("is_superuser").unwrap_or(false);
let perms_json = row.get_text("permissions").unwrap_or_default();
let overrides: HashMap<String, i64> = serde_json::from_str(&perms_json).unwrap_or_default();
render(build(
&state,
shell,
&editor.permissions,
format!("/admin/users/{id}/edit"),
full_name(&first_name, last_name.as_deref()),
username,
email,
is_superuser,
&overrides,
))
}
pub(crate) async fn update(
State(state): State<AdminState>,
Extension(editor): Extension<AuthenticatedUser>,
Path(id): Path<String>,
Form(pairs): Form<Vec<(String, String)>>,
) -> Response {
let (sql, values) = {
let stmt = Query::select()
.columns([Alias::new("is_superuser"), Alias::new("permissions")])
.from(Alias::new("backend_users"))
.and_where(
Expr::col(Alias::new("id"))
.cast_as(Alias::new(text_cast(state.db.backend)))
.eq(id.clone()),
)
.to_owned();
to_sql(state.db.backend, stmt)
};
let row = match bind_values(sqlx::query(&sql), values)
.fetch_optional(&state.db.pool)
.await
{
Ok(row) => row,
Err(_) => return render_error(),
};
let Some(row) = row else {
return not_found();
};
if row.get_bool("is_superuser").unwrap_or(false) {
return Redirect::to("/admin/users").into_response();
}
let target_id = match id.parse::<i64>() {
Ok(target_id) => target_id,
Err(_) => return not_found(),
};
let perms_json = row.get_text("permissions").unwrap_or_default();
let mut overrides: HashMap<String, i64> = serde_json::from_str(&perms_json).unwrap_or_default();
let submitted = parse_states(&pairs);
for permission in state.permissions.iter() {
if !editor.allows(&permission.code) {
continue;
}
match submitted.get(&permission.code).copied() {
Some(1) => {
overrides.insert(permission.code.clone(), 1);
}
Some(-1) => {
overrides.insert(permission.code.clone(), -1);
}
Some(_) => {
overrides.remove(&permission.code);
}
None => {}
}
}
match state.auth.set_user_permissions(target_id, &overrides).await {
Ok(()) => Redirect::to("/admin/users").into_response(),
Err(_) => render_error(),
}
}
fn parse_states(pairs: &[(String, String)]) -> HashMap<String, i64> {
let mut states = HashMap::new();
for (key, value) in pairs {
if let Some(code) = key.strip_prefix("p:") {
if let Ok(state) = value.parse::<i64>() {
states.insert(code.to_string(), state);
}
}
}
states
}
fn full_name(first: &str, last: Option<&str>) -> String {
match last {
Some(last) if !last.is_empty() => format!("{first} {last}"),
_ => first.to_string(),
}
}
fn group_permissions(
registry: &[Permission],
overrides: &HashMap<String, i64>,
editor: &PermissionSet,
) -> Vec<PermGroupView> {
let mut groups: Vec<PermGroupView> = Vec::new();
for permission in registry {
let state = match overrides.get(&permission.code).copied() {
Some(1) => 1,
Some(-1) => -1,
_ => 0,
};
let row = PermRowView {
code: permission.code.clone(),
label: permission.label.clone(),
state,
changeable: editor.allows(&permission.code),
};
match groups.iter_mut().find(|g| g.name == permission.group) {
Some(group) => group.rows.push(row),
None => groups.push(PermGroupView {
name: permission.group.clone(),
rows: vec![row],
}),
}
}
groups
}
#[allow(clippy::too_many_arguments)]
fn build(
state: &AdminState,
shell: Shell,
editor: &PermissionSet,
action: String,
full_name: String,
username: String,
email: String,
is_superuser: bool,
overrides: &HashMap<String, i64>,
) -> UsersFormTemplate {
let groups = if is_superuser {
Vec::new()
} else {
group_permissions(&state.permissions, overrides, editor)
};
UsersFormTemplate {
shell,
action,
cancel_path: "/admin/users".to_string(),
full_name,
username,
email,
is_superuser,
groups,
}
}
struct PermRowView {
code: String,
label: String,
state: i32,
changeable: bool,
}
struct PermGroupView {
name: String,
rows: Vec<PermRowView>,
}
#[derive(Template)]
#[template(path = "users_form.html")]
struct UsersFormTemplate {
shell: Shell,
action: String,
cancel_path: String,
full_name: String,
username: String,
email: String,
is_superuser: bool,
groups: Vec<PermGroupView>,
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> Vec<Permission> {
vec![
Permission {
code: "backend.manage_users".to_string(),
label: "Manage backend users".to_string(),
group: "Backend".to_string(),
},
Permission {
code: "acme.publish".to_string(),
label: "Publish".to_string(),
group: "Content".to_string(),
},
]
}
#[test]
fn parse_states_reads_prefixed_radio_values() {
let pairs = vec![
("p:backend.manage_users".to_string(), "1".to_string()),
("p:acme.publish".to_string(), "-1".to_string()),
("other".to_string(), "ignored".to_string()),
];
let states = parse_states(&pairs);
assert_eq!(states.get("backend.manage_users"), Some(&1));
assert_eq!(states.get("acme.publish"), Some(&-1));
assert_eq!(states.get("other"), None);
}
#[test]
fn grouping_marks_state_and_changeability() {
let editor = PermissionSet::new(false, ["backend.manage_users".to_string()]);
let overrides = HashMap::from([("acme.publish".to_string(), -1i64)]);
let groups = group_permissions(®istry(), &overrides, &editor);
let backend = &groups[0].rows[0];
assert_eq!(backend.state, 0);
assert!(backend.changeable);
let content = &groups[1].rows[0];
assert_eq!(content.state, -1);
assert!(!content.changeable);
}
}