use askama::Template;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::{Extension, Form};
use laterite_auth::AuthenticatedUser;
use laterite_core::query::{bind_values, build as to_sql, text_cast};
use laterite_core::{t, AnyRowExt, Text};
use sea_query::{Alias, Expr, Query};
use crate::{not_found, render, render_error, AdminState, Permission, Shell};
pub(crate) async fn new_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
) -> Response {
let action = format!("{}/roles/new", state.admin_path);
render(build(&state, &action, None, "", "", &[], shell))
}
pub(crate) async fn create(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(session): Extension<crate::session::SessionHandle>,
Extension(user): Extension<AuthenticatedUser>,
Form(pairs): Form<Vec<(String, String)>>,
) -> Response {
let (code, name, perms) = parse(&pairs);
let perms = registered_only(perms, &state.permissions);
let action = format!("{}/roles/new", state.admin_path);
if code.is_empty() || name.is_empty() {
return render(build(
&state,
&action,
Some(t!("Code and name are required.")),
&code,
&name,
&perms,
shell,
));
}
match laterite_auth::store::create_role(&state.db, &code, &name, &perms).await {
Ok(role_id) => {
let detail = serde_json::json!({ "code": code, "name": name }).to_string();
let target_id = role_id.to_string();
crate::audit::record(
&state,
&user,
"backend.role.create",
Some("backend_role"),
Some(target_id.as_str()),
Some(detail.as_str()),
)
.await;
session.push_flash(crate::session::FlashLevel::Success, t!("Role created."));
Redirect::to(&format!("{}/roles", state.admin_path)).into_response()
}
Err(_) => render(build(
&state,
&action,
Some(t!("Could not save. The code may already be in use.")),
&code,
&name,
&perms,
shell,
)),
}
}
pub(crate) async fn edit_form(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Path(id): Path<String>,
) -> Response {
let action = format!("{}/roles/{id}/edit", state.admin_path);
let (sql, values) = {
let stmt = Query::select()
.columns([
Alias::new("code"),
Alias::new("name"),
Alias::new("permissions"),
])
.from(Alias::new("backend_roles"))
.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 code = row.get_text("code").unwrap_or_default();
let name = row.get_text("name").unwrap_or_default();
let perms_json = row.get_text("permissions").unwrap_or_default();
let perms: Vec<String> = serde_json::from_str(&perms_json).unwrap_or_default();
render(build(&state, &action, None, &code, &name, &perms, shell))
}
pub(crate) async fn update(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(session): Extension<crate::session::SessionHandle>,
Extension(user): Extension<AuthenticatedUser>,
Path(id): Path<String>,
Form(pairs): Form<Vec<(String, String)>>,
) -> Response {
let (code, name, perms) = parse(&pairs);
let perms = registered_only(perms, &state.permissions);
let action = format!("{}/roles/{id}/edit", state.admin_path);
if code.is_empty() || name.is_empty() {
return render(build(
&state,
&action,
Some(t!("Code and name are required.")),
&code,
&name,
&perms,
shell,
));
}
let perms_json = serde_json::to_string(&perms).unwrap_or_else(|_| "[]".to_string());
let (sql, values) = {
let stmt = Query::update()
.table(Alias::new("backend_roles"))
.value(Alias::new("code"), code.clone())
.value(Alias::new("name"), name.clone())
.value(Alias::new("permissions"), perms_json)
.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)
};
match bind_values(sqlx::query(&sql), values)
.execute(&state.db.pool)
.await
{
Ok(_) => {
let detail = serde_json::json!({ "code": code, "name": name }).to_string();
crate::audit::record(
&state,
&user,
"backend.role.update",
Some("backend_role"),
Some(id.as_str()),
Some(detail.as_str()),
)
.await;
session.push_flash(crate::session::FlashLevel::Success, t!("Role updated."));
Redirect::to(&format!("{}/roles", state.admin_path)).into_response()
}
Err(_) => render(build(
&state,
&action,
Some(t!("Could not save. The code may already be in use.")),
&code,
&name,
&perms,
shell,
)),
}
}
fn parse(pairs: &[(String, String)]) -> (String, String, Vec<String>) {
let mut code = String::new();
let mut name = String::new();
let mut perms = Vec::new();
for (key, value) in pairs {
match key.as_str() {
"code" => code = value.trim().to_string(),
"name" => name = value.trim().to_string(),
"perm" => perms.push(value.clone()),
_ => {}
}
}
(code, name, perms)
}
fn registered_only(perms: Vec<String>, registry: &[Permission]) -> Vec<String> {
perms
.into_iter()
.filter(|p| registry.iter().any(|r| &r.code == p))
.collect()
}
fn group_permissions(
registry: &[Permission],
selected: &[String],
shell: &Shell,
) -> Vec<PermGroupView> {
let mut groups: Vec<PermGroupView> = Vec::new();
for permission in registry {
let check = PermCheckView {
code: permission.code.clone(),
label: shell.tt(&permission.label),
checked: selected.iter().any(|s| s == &permission.code),
};
let gname = shell.tt(&permission.group);
match groups.iter_mut().find(|g| g.name == gname) {
Some(group) => group.perms.push(check),
None => groups.push(PermGroupView {
name: gname,
perms: vec![check],
}),
}
}
groups
}
#[allow(clippy::too_many_arguments)]
fn build(
state: &AdminState,
action: &str,
error: Option<Text>,
code: &str,
name: &str,
selected: &[String],
shell: Shell,
) -> RolesFormTemplate {
let groups = group_permissions(&state.permissions, selected, &shell);
let title = shell.tt(&t!("Role"));
let error = error.map(|e| shell.tt(&e));
RolesFormTemplate {
shell,
title,
action: action.to_string(),
cancel_path: format!("{}/roles", state.admin_path),
error,
code: code.to_string(),
name: name.to_string(),
groups,
}
}
struct PermCheckView {
code: String,
label: String,
checked: bool,
}
struct PermGroupView {
name: String,
perms: Vec<PermCheckView>,
}
#[derive(Template)]
#[template(path = "roles_form.html")]
struct RolesFormTemplate {
shell: Shell,
title: String,
action: String,
cancel_path: String,
error: Option<String>,
code: String,
name: String,
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".into(),
group: "Backend".into(),
},
Permission {
code: "acme.publish".to_string(),
label: "Publish".into(),
group: "Content".into(),
},
]
}
#[test]
fn parse_pulls_scalars_and_repeated_perms() {
let pairs = vec![
("code".to_string(), " editor ".to_string()),
("name".to_string(), "Editor".to_string()),
("perm".to_string(), "backend.manage_users".to_string()),
("perm".to_string(), "acme.publish".to_string()),
];
let (code, name, perms) = parse(&pairs);
assert_eq!(code, "editor");
assert_eq!(name, "Editor");
assert_eq!(perms, ["backend.manage_users", "acme.publish"]);
}
#[test]
fn only_registered_permissions_survive() {
let kept = registered_only(
vec![
"backend.manage_users".to_string(),
"nope.invalid".to_string(),
],
®istry(),
);
assert_eq!(kept, ["backend.manage_users"]);
}
#[test]
fn grouping_preserves_order_and_marks_selected() {
let groups = group_permissions(®istry(), &["acme.publish".to_string()], &Shell::test());
assert_eq!(groups[0].name, "Backend");
assert_eq!(groups[1].name, "Content");
assert!(!groups[0].perms[0].checked);
assert!(groups[1].perms[0].checked);
}
}