use std::collections::HashMap;
use std::sync::Arc;
use serde::Serialize;
use crate::auth::{self, Identity, Role};
use crate::error::{Error, Result};
use crate::http::{Request, Response};
use crate::orm::{Db, Row};
use crate::templates::Templates;
use super::render;
use super::render::{BaseContext, FlashCtx, SidebarEntry};
use super::types::Admin;
pub(crate) struct AuthAdminCtx {
pub admin: Arc<Admin>,
pub db: Db,
pub templates: Arc<Templates>,
}
#[derive(Serialize)]
struct UserRow {
id: i64,
email: String,
role: String,
is_active: bool,
created_at: String,
}
#[derive(Serialize)]
struct UsersListCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
entries: Vec<SidebarEntry>,
users: Vec<UserRow>,
flash: Option<FlashCtx>,
}
pub(crate) async fn list_users(
ctx: &AuthAdminCtx,
identity: Identity,
csrf: String,
) -> Result<Response> {
let rows = sqlx::query(
"SELECT id, email, role, is_active, created_at
FROM rustio_users
ORDER BY id ASC",
)
.fetch_all(ctx.db.pool())
.await?;
let users = rows
.iter()
.map(|r| {
let r = Row::from_pg(r);
Ok(UserRow {
id: r.get_i64("id")?,
email: r.get_string("email")?,
role: r.get_string("role")?,
is_active: r.get_bool("is_active")?,
created_at: r
.get_datetime("created_at")?
.format("%Y-%m-%d %H:%M")
.to_string(),
})
})
.collect::<Result<Vec<_>>>()?;
let view = UsersListCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Users",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
users,
flash: None,
};
let body = ctx.templates.render("admin/users_list.html", &view)?;
Ok(Response::html(body))
}
#[derive(Serialize)]
struct UserEditCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
entries: Vec<SidebarEntry>,
user_id: i64,
email: String,
role: String,
is_active: bool,
all_groups: Vec<GroupRow>,
user_groups: Vec<i64>,
errors: Vec<String>,
flash: Option<FlashCtx>,
is_last_developer: bool,
identity_sections: Vec<render::FormSection>,
password_sections: Vec<render::FormSection>,
}
#[derive(Serialize)]
struct GroupRow {
id: i64,
name: String,
description: String,
}
async fn load_groups(db: &Db) -> Result<Vec<GroupRow>> {
let rows = sqlx::query("SELECT id, name, description FROM rustio_groups ORDER BY name ASC")
.fetch_all(db.pool())
.await?;
rows.iter()
.map(|r| {
let r = Row::from_pg(r);
Ok(GroupRow {
id: r.get_i64("id")?,
name: r.get_string("name")?,
description: r.get_string("description")?,
})
})
.collect()
}
pub(crate) async fn show_user_edit(
ctx: &AuthAdminCtx,
identity: Identity,
user_id: i64,
csrf: String,
) -> Result<Response> {
let row = sqlx::query("SELECT id, email, role, is_active FROM rustio_users WHERE id = $1")
.bind(user_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("user #{user_id}")))?;
let r = Row::from_pg(&row);
let group_ids: Vec<i64> =
sqlx::query_scalar::<_, i64>("SELECT group_id FROM rustio_user_groups WHERE user_id = $1")
.bind(user_id)
.fetch_all(ctx.db.pool())
.await?;
let is_last_developer =
auth::would_orphan_developers(&ctx.db, user_id, Some(Role::User)).await?;
let email_str = r.get_string("email")?;
let role_str = r.get_string("role")?;
let is_active_val = r.get_bool("is_active")?;
let view = UserEditCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: format!("Edit user #{user_id}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
user_id,
identity_sections: render::user_edit_identity_sections(
&email_str,
&role_str,
is_active_val,
),
password_sections: render::user_edit_password_sections(),
email: email_str,
role: role_str,
is_active: is_active_val,
all_groups: load_groups(&ctx.db).await?,
user_groups: group_ids,
errors: vec![],
flash: None,
is_last_developer,
};
let body = ctx.templates.render("admin/user_edit.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_user_edit(
ctx: &AuthAdminCtx,
identity: Identity,
user_id: i64,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let role = Role::parse(form.required("role")?)?;
let is_active = form.bool_flag("is_active");
let mut wanted: Vec<i64> = Vec::new();
for (k, v) in form.as_map() {
if let Some(id_str) = k.strip_prefix("group_") {
if v == "on" {
if let Ok(gid) = id_str.parse::<i64>() {
wanted.push(gid);
}
}
}
}
let new_password = form
.get("new_password")
.map(|s| s.to_string())
.unwrap_or_default();
let effective_role = if is_active { role } else { Role::User };
if auth::would_orphan_developers(&ctx.db, user_id, Some(effective_role)).await? {
let csrf = req
.ctx()
.get::<crate::middleware::CsrfGuard>()
.map(|g| g.token.clone())
.unwrap_or_default();
return render_user_edit_with_errors(
ctx,
&identity,
user_id,
role,
is_active,
wanted,
csrf,
vec!["Cannot demote or deactivate the last active developer. \
Use rustio-cli to promote a backup developer first."
.into()],
)
.await;
}
sqlx::query(
"UPDATE rustio_users SET role = $1, is_active = $2, updated_at = NOW() WHERE id = $3",
)
.bind(role.as_str())
.bind(is_active)
.bind(user_id)
.execute(ctx.db.pool())
.await?;
sqlx::query("DELETE FROM rustio_user_groups WHERE user_id = $1")
.bind(user_id)
.execute(ctx.db.pool())
.await?;
auth::invalidate_user_cache(user_id);
for gid in wanted {
auth::add_user_to_group(&ctx.db, user_id, gid).await?;
}
if !new_password.is_empty() {
auth::set_password(&ctx.db, user_id, &new_password).await?;
}
Ok(Response::redirect("/admin/users"))
}
#[allow(clippy::too_many_arguments)]
async fn render_user_edit_with_errors(
ctx: &AuthAdminCtx,
identity: &Identity,
user_id: i64,
role: Role,
is_active: bool,
user_groups: Vec<i64>,
csrf: String,
errors: Vec<String>,
) -> Result<Response> {
let row = sqlx::query("SELECT email FROM rustio_users WHERE id = $1")
.bind(user_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("user #{user_id}")))?;
let r = Row::from_pg(&row);
let is_last_developer =
auth::would_orphan_developers(&ctx.db, user_id, Some(Role::User)).await?;
let email_str = r.get_string("email")?;
let role_str: String = role.as_str().into();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
for msg in &errors {
field_errors
.entry("role".into())
.or_default()
.push(msg.clone());
}
let mut identity_sections =
render::user_edit_identity_sections(&email_str, &role_str, is_active);
render::apply_field_errors(&mut identity_sections, &field_errors);
let view = UserEditCtx {
base: BaseContext::new(Some(identity), csrf, &ctx.admin),
page_title: format!("Edit user #{user_id}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
user_id,
identity_sections,
password_sections: render::user_edit_password_sections(),
email: email_str,
role: role_str,
is_active,
all_groups: load_groups(&ctx.db).await?,
user_groups,
errors,
flash: None,
is_last_developer,
};
let body = ctx.templates.render("admin/user_edit.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST))
}
#[derive(Serialize)]
struct UserViewGroup {
name: String,
description: String,
}
#[derive(Serialize)]
struct UserViewCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
entries: Vec<SidebarEntry>,
target_id: i64,
target_email: String,
target_role: String,
target_is_active: bool,
target_is_demo: bool,
target_demo_label: Option<String>,
target_created_at: String,
target_updated_at: String,
groups: Vec<UserViewGroup>,
direct_perms: Vec<String>,
is_self: bool,
is_last_developer: bool,
can_edit: bool,
can_delete: bool,
}
pub(crate) async fn show_user_view(
ctx: &AuthAdminCtx,
identity: Identity,
user_id: i64,
csrf: String,
) -> Result<Response> {
let row = sqlx::query(
"SELECT id, email, role, is_active, is_demo, demo_label, \
created_at, updated_at \
FROM rustio_users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("user #{user_id}")))?;
let r = Row::from_pg(&row);
let group_rows = sqlx::query(
"SELECT g.name, g.description \
FROM rustio_groups g \
JOIN rustio_user_groups ug ON ug.group_id = g.id \
WHERE ug.user_id = $1 \
ORDER BY g.name ASC",
)
.bind(user_id)
.fetch_all(ctx.db.pool())
.await?;
let groups = group_rows
.iter()
.map(|r| {
let r = Row::from_pg(r);
Ok(UserViewGroup {
name: r.get_string("name")?,
description: r.get_string("description")?,
})
})
.collect::<Result<Vec<_>>>()?;
let direct_perms: Vec<String> = sqlx::query_scalar(
"SELECT p.name \
FROM rustio_permissions p \
JOIN rustio_user_permissions up ON up.permission_id = p.id \
WHERE up.user_id = $1 \
ORDER BY p.name ASC",
)
.bind(user_id)
.fetch_all(ctx.db.pool())
.await?;
let is_self = identity.user_id == user_id;
let is_last_developer =
auth::would_orphan_developers(&ctx.db, user_id, Some(Role::User)).await?;
let target_email = r.get_string("email")?;
let view = UserViewCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: format!("User: {target_email}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
target_id: user_id,
target_email,
target_role: r.get_string("role")?,
target_is_active: r.get_bool("is_active")?,
target_is_demo: r.get_bool("is_demo")?,
target_demo_label: r.get_optional_string("demo_label")?,
target_created_at: r
.get_datetime("created_at")?
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
target_updated_at: r
.get_datetime("updated_at")?
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
groups,
direct_perms,
is_self,
is_last_developer,
can_edit: true,
can_delete: !is_self && !is_last_developer,
};
let body = ctx.templates.render("admin/user_view.html", &view)?;
Ok(Response::html(body))
}
#[derive(Serialize)]
struct UserDeleteCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
entries: Vec<SidebarEntry>,
user_id: i64,
email: String,
role: String,
group_count: i64,
session_count: i64,
direct_perm_count: i64,
is_self: bool,
is_last_developer: bool,
}
pub(crate) async fn show_user_delete(
ctx: &AuthAdminCtx,
identity: Identity,
user_id: i64,
csrf: String,
) -> Result<Response> {
let row = sqlx::query("SELECT id, email, role FROM rustio_users WHERE id = $1")
.bind(user_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("user #{user_id}")))?;
let r = Row::from_pg(&row);
let group_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM rustio_user_groups WHERE user_id = $1")
.bind(user_id)
.fetch_one(ctx.db.pool())
.await?;
let session_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_sessions WHERE user_id = $1 AND expires_at > NOW()",
)
.bind(user_id)
.fetch_one(ctx.db.pool())
.await?;
let direct_perm_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM rustio_user_permissions WHERE user_id = $1")
.bind(user_id)
.fetch_one(ctx.db.pool())
.await?;
let is_self = identity.user_id == user_id;
let is_last_developer =
auth::would_orphan_developers(&ctx.db, user_id, Some(Role::User)).await?;
let email = r.get_string("email")?;
let view = UserDeleteCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: format!("Delete user: {email}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
user_id,
email,
role: r.get_string("role")?,
group_count,
session_count,
direct_perm_count,
is_self,
is_last_developer,
};
let body = ctx
.templates
.render("admin/user_confirm_delete.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_user_delete(
ctx: &AuthAdminCtx,
identity: Identity,
user_id: i64,
_req: Request,
) -> Result<Response> {
if identity.user_id == user_id {
return Err(Error::BadRequest(
"You cannot delete your own account while signed in.".into(),
));
}
if auth::would_orphan_developers(&ctx.db, user_id, Some(Role::User)).await? {
return Err(Error::BadRequest(
"Cannot delete the last active developer. \
Use rustio-cli to promote a backup developer first."
.into(),
));
}
sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(user_id)
.execute(ctx.db.pool())
.await?;
auth::invalidate_user_cache(user_id);
Ok(Response::redirect("/admin/users"))
}
#[derive(Serialize)]
struct GroupsListCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
entries: Vec<SidebarEntry>,
groups: Vec<GroupRow>,
flash: Option<FlashCtx>,
}
pub(crate) async fn list_groups(
ctx: &AuthAdminCtx,
identity: Identity,
csrf: String,
) -> Result<Response> {
let view = GroupsListCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Groups",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
groups: load_groups(&ctx.db).await?,
flash: None,
};
let body = ctx.templates.render("admin/groups_list.html", &view)?;
Ok(Response::html(body))
}
#[derive(Serialize)]
struct GroupEditCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
entries: Vec<SidebarEntry>,
group_id: i64,
name: String,
description: String,
all_permissions: Vec<PermRow>,
group_permissions: Vec<i64>,
errors: Vec<String>,
flash: Option<FlashCtx>,
sections: Vec<render::FormSection>,
}
#[derive(Serialize)]
struct PermRow {
id: i64,
name: String,
}
pub(crate) async fn show_group_edit(
ctx: &AuthAdminCtx,
identity: Identity,
group_id: i64,
csrf: String,
) -> Result<Response> {
let row = sqlx::query("SELECT id, name, description FROM rustio_groups WHERE id = $1")
.bind(group_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("group #{group_id}")))?;
let r = Row::from_pg(&row);
let all: Vec<PermRow> = {
let rows = sqlx::query("SELECT id, name FROM rustio_permissions ORDER BY name ASC")
.fetch_all(ctx.db.pool())
.await?;
rows.iter()
.map(|r| {
let r = Row::from_pg(r);
Ok(PermRow {
id: r.get_i64("id")?,
name: r.get_string("name")?,
})
})
.collect::<Result<Vec<_>>>()?
};
let current: Vec<i64> = sqlx::query_scalar::<_, i64>(
"SELECT permission_id FROM rustio_group_permissions WHERE group_id = $1",
)
.bind(group_id)
.fetch_all(ctx.db.pool())
.await?;
let name_str = r.get_string("name")?;
let description_str = r.get_string("description")?;
let view = GroupEditCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: format!("Edit group #{group_id}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
group_id,
sections: render::group_form_sections(&name_str, &description_str),
name: name_str,
description: description_str,
all_permissions: all,
group_permissions: current,
errors: vec![],
flash: None,
};
let body = ctx.templates.render("admin/group_edit.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_group_edit(
ctx: &AuthAdminCtx,
_identity: Identity,
group_id: i64,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let name = form.required("name")?;
let description = form.get("description").unwrap_or("");
sqlx::query("UPDATE rustio_groups SET name = $1, description = $2 WHERE id = $3")
.bind(name)
.bind(description)
.bind(group_id)
.execute(ctx.db.pool())
.await?;
sqlx::query("DELETE FROM rustio_group_permissions WHERE group_id = $1")
.bind(group_id)
.execute(ctx.db.pool())
.await?;
for (k, v) in form.as_map() {
if let Some(id_str) = k.strip_prefix("perm_") {
if v == "on" {
if let Ok(pid) = id_str.parse::<i64>() {
sqlx::query(
"INSERT INTO rustio_group_permissions (group_id, permission_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(group_id)
.bind(pid)
.execute(ctx.db.pool())
.await?;
}
}
}
}
Ok(Response::redirect("/admin/groups"))
}
#[derive(Serialize)]
struct GroupDeleteCtx {
#[serde(flatten)]
base: BaseContext,
page_title: String,
entries: Vec<SidebarEntry>,
group_id: i64,
name: String,
description: String,
user_count: i64,
perm_count: i64,
}
pub(crate) async fn show_group_delete(
ctx: &AuthAdminCtx,
identity: Identity,
group_id: i64,
csrf: String,
) -> Result<Response> {
let row = sqlx::query("SELECT id, name, description FROM rustio_groups WHERE id = $1")
.bind(group_id)
.fetch_optional(ctx.db.pool())
.await?;
let row = row.ok_or_else(|| Error::NotFound(format!("group #{group_id}")))?;
let r = Row::from_pg(&row);
let user_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM rustio_user_groups WHERE group_id = $1")
.bind(group_id)
.fetch_one(ctx.db.pool())
.await?;
let perm_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM rustio_group_permissions WHERE group_id = $1")
.bind(group_id)
.fetch_one(ctx.db.pool())
.await?;
let name = r.get_string("name")?;
let view = GroupDeleteCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: format!("Delete group: {name}"),
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
group_id,
name,
description: r.get_string("description")?,
user_count,
perm_count,
};
let body = ctx
.templates
.render("admin/group_confirm_delete.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_group_delete(
ctx: &AuthAdminCtx,
_identity: Identity,
group_id: i64,
_req: Request,
) -> Result<Response> {
let user_ids: Vec<i64> =
sqlx::query_scalar("SELECT user_id FROM rustio_user_groups WHERE group_id = $1")
.bind(group_id)
.fetch_all(ctx.db.pool())
.await?;
sqlx::query("DELETE FROM rustio_groups WHERE id = $1")
.bind(group_id)
.execute(ctx.db.pool())
.await?;
for uid in user_ids {
crate::auth::invalidate_user_cache(uid);
}
Ok(Response::redirect("/admin/groups"))
}
#[derive(Serialize)]
struct UserNewCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
entries: Vec<SidebarEntry>,
email: String,
role: String,
errors: Vec<String>,
sections: Vec<render::FormSection>,
}
pub(crate) async fn show_new_user(
ctx: &AuthAdminCtx,
identity: Identity,
csrf: String,
) -> Result<Response> {
let email = String::new();
let role: String = "staff".into();
let view = UserNewCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Add user",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sections: render::user_new_form_sections(&email, &role),
email,
role,
errors: Vec::new(),
};
let body = ctx.templates.render("admin/user_new.html", &view)?;
Ok(Response::html(body))
}
const MIN_NEW_USER_PASSWORD_LEN: usize = 8;
fn looks_like_email(s: &str) -> bool {
let s = s.trim();
let Some((local, domain)) = s.split_once('@') else {
return false;
};
if local.is_empty() || domain.is_empty() {
return false;
}
let Some((host, tld)) = domain.rsplit_once('.') else {
return false;
};
!host.is_empty() && !tld.is_empty()
}
pub(crate) async fn do_new_user(
ctx: &AuthAdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let email = form.get("email").unwrap_or("").trim().to_string();
let password = form.get("password").unwrap_or("");
let role_str = form.get("role").unwrap_or("staff").to_string();
let mut errors: Vec<String> = Vec::new();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
let role_parsed = Role::parse(&role_str).ok();
if role_parsed.is_none() {
let msg = format!("Unknown role: \"{role_str}\".");
errors.push(msg.clone());
field_errors.entry("role".into()).or_default().push(msg);
}
if email.is_empty() {
let msg = "Email is required.";
errors.push(msg.into());
field_errors
.entry("email".into())
.or_default()
.push(msg.into());
} else if !looks_like_email(&email) {
let msg = "Enter a valid email address.";
errors.push(msg.into());
field_errors
.entry("email".into())
.or_default()
.push(msg.into());
} else {
let existing = auth::find_user_by_email(&ctx.db, &email).await?;
if existing.is_some() {
let msg = format!("A user with email \"{email}\" already exists.");
errors.push(msg.clone());
field_errors.entry("email".into()).or_default().push(msg);
}
}
if password.len() < MIN_NEW_USER_PASSWORD_LEN {
let msg = format!(
"This password is too short. It must contain at least {MIN_NEW_USER_PASSWORD_LEN} characters."
);
errors.push(msg.clone());
field_errors.entry("password".into()).or_default().push(msg);
}
if errors.is_empty() {
let role = role_parsed.expect("role parsed when errors empty");
let new_id = auth::create_user(&ctx.db, &email, password, role).await?;
return Ok(Response::redirect(format!("/admin/users/{new_id}/edit")));
}
let csrf = req
.ctx()
.get::<crate::middleware::CsrfGuard>()
.map(|g| g.token.clone())
.unwrap_or_default();
let mut sections = render::user_new_form_sections(&email, &role_str);
render::apply_field_errors(&mut sections, &field_errors);
let view = UserNewCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Add user",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sections,
email,
role: role_str,
errors,
};
let body = ctx.templates.render("admin/user_new.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST))
}
#[derive(Serialize)]
struct GroupNewCtx {
#[serde(flatten)]
base: BaseContext,
page_title: &'static str,
entries: Vec<SidebarEntry>,
name: String,
description: String,
errors: Vec<String>,
sections: Vec<render::FormSection>,
}
pub(crate) async fn show_new_group(
ctx: &AuthAdminCtx,
identity: Identity,
csrf: String,
) -> Result<Response> {
let name = String::new();
let description = String::new();
let view = GroupNewCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Add group",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sections: render::group_form_sections(&name, &description),
name,
description,
errors: Vec::new(),
};
let body = ctx.templates.render("admin/group_new.html", &view)?;
Ok(Response::html(body))
}
pub(crate) async fn do_new_group(
ctx: &AuthAdminCtx,
identity: Identity,
req: Request,
) -> Result<Response> {
let form = req.form()?;
let name = form.get("name").unwrap_or("").trim().to_string();
let description = form.get("description").unwrap_or("").to_string();
let mut errors: Vec<String> = Vec::new();
let mut field_errors: HashMap<String, Vec<String>> = HashMap::new();
if name.is_empty() {
let msg = "Name is required.";
errors.push(msg.into());
field_errors
.entry("name".into())
.or_default()
.push(msg.into());
} else if name.len() > 150 {
let msg = "Name must be 150 characters or fewer.";
errors.push(msg.into());
field_errors
.entry("name".into())
.or_default()
.push(msg.into());
}
if errors.is_empty() {
let result = sqlx::query(
"INSERT INTO rustio_groups (name, description) VALUES ($1, $2) RETURNING id",
)
.bind(&name)
.bind(&description)
.fetch_one(ctx.db.pool())
.await;
match result {
Ok(row) => {
let r = Row::from_pg(&row);
let new_id: i64 = r.get_i64("id")?;
return Ok(Response::redirect(format!("/admin/groups/{new_id}/edit")));
}
Err(sqlx::Error::Database(db_err)) if db_err.constraint().is_some() => {
let msg = format!("A group named \"{name}\" already exists.");
errors.push(msg.clone());
field_errors.entry("name".into()).or_default().push(msg);
}
Err(e) => return Err(e.into()),
}
}
let csrf = req
.ctx()
.get::<crate::middleware::CsrfGuard>()
.map(|g| g.token.clone())
.unwrap_or_default();
let mut sections = render::group_form_sections(&name, &description);
render::apply_field_errors(&mut sections, &field_errors);
let view = GroupNewCtx {
base: BaseContext::new(Some(&identity), csrf, &ctx.admin),
page_title: "Add group",
entries: ctx
.admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sections,
name,
description,
errors,
};
let body = ctx.templates.render("admin/group_new.html", &view)?;
Ok(Response::html(body).with_status(hyper::StatusCode::BAD_REQUEST))
}