//! Page: admin user management (`/admin/users`). Non-admins get 404. use forge_core::names::{normalize_name, validate_username}; use forge_core::store::user_delete::{DeletionReport, GHOST_USERNAME}; use forge_core::{AuditAction, Session, Store, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, not_found, see_other}; use topcoat::router::{page, path_param_segment}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{check_csrf, current_user, hash_password_async}; use crate::i18n::admin::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::state::AppState; use crate::views::{alert, enc, shell, user_facing_message}; const MIN_PASSWORD_CHARS: usize = 12; const MAX_PASSWORD_BYTES: usize = 1024; #[derive(Deserialize)] struct CreateUserForm { #[serde(default)] csrf: String, #[serde(default)] username: String, #[serde(default)] email: String, #[serde(default)] password: String, /// Checkbox: present with any value when checked. #[serde(default)] is_admin: Option, } #[derive(Deserialize)] struct DeleteForm { #[serde(default)] csrf: String, #[serde(default)] confirm_username: String, } /// The ghost account is reserved and never shown as a deletable user. fn is_ghost(user: &User) -> bool { normalize_name(&user.username) == GHOST_USERNAME } #[component] async fn users_view( lang: Lang, csrf: &str, viewer_id: &str, users: Vec, error: Option, ) -> Result { Ok(view! {

(g::t(lang, K::Users))

(g::t(lang, K::AuditLog)) " · " (g::t(lang, K::Backups)) " · " (g::t(lang, K::Replication)) " · " (g::t(lang, K::Mail)) " · " (g::t(lang, K::Runners)) " · " (g::t(lang, K::Invites))

for user in users.into_iter() { }
(i18n::t(lang, Key::Username))(i18n::t(lang, Key::Email))(g::t(lang, K::Role))(i18n::t(lang, Key::Created))
(user.username.clone()) (user.email.clone().unwrap_or_default()) (if user.is_org() { g::t(lang, K::RoleOrganization) } else if user.is_admin { g::t(lang, K::RoleAdmin) } else { g::t(lang, K::RoleUser) }) (user.created_at.clone()) if user.id != viewer_id && !is_ghost(&user) { (i18n::t(lang, Key::Delete)) }

(g::t(lang, K::CreateAUser))

if let Some(message) = error { alert(message: message.as_str()) }
}) } #[page("/admin/users")] async fn users_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let users = app_context::(cx).store.list_users().await?; let csrf = viewer.1.csrf_token.clone(); let title = g::t(lang, K::Users); Ok(view! { shell(title: title, viewer: Some(&viewer), users_view(lang: lang, csrf: csrf.as_str(), viewer_id: viewer.0.id.as_str(), users: users, error: None)) }) } #[page(POST "/admin/users")] async fn users_create(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&viewer.0)); let store = &app_context::(cx).store; // Ok(Ok(user)) created; Ok(Err(message)) rejected with a user-facing message. let outcome: Result> = create_user(store, &form, lang).await; match outcome? { Ok(created) => { audit::record( store, Some(cx), Actor::User(&viewer.0), AuditAction::UserCreated, Target { kind: "user", id: &created.id, name: &created.username, }, serde_json::json!({ "admin": created.is_admin }), ) .await; Err(see_other("/admin/users").into()) } Err(message) => { let users = store.list_users().await?; let csrf = viewer.1.csrf_token.clone(); let title = g::t(lang, K::Users); Ok(view! { shell(title: title, viewer: Some(&viewer), users_view(lang: lang, csrf: csrf.as_str(), viewer_id: viewer.0.id.as_str(), users: users, error: Some(message))) }) } } } /// The signed-in admin, or 404 for anonymous users and non-admins alike. pub(crate) async fn admin_only(cx: &Cx) -> Result<(User, Session)> { match current_user(cx).await? { Some(viewer) if viewer.0.is_admin => Ok(viewer), _ => Err(not_found().into()), } } /// Validates the form and creates the user. The inner `Err` is a user-facing rejection. async fn create_user( store: &forge_core::Store, form: &CreateUserForm, lang: Lang, ) -> Result> { let username = match validate_username(&form.username) { Ok((name, _)) => name, Err(e) => { return Ok(Err(user_facing_message(&e) .unwrap_or_else(|| g::t(lang, K::ErrInvalidUsername).to_owned()))); } }; if form.password.chars().count() < MIN_PASSWORD_CHARS { return Ok(Err(g::password_min_error(lang, MIN_PASSWORD_CHARS))); } if form.password.len() > MAX_PASSWORD_BYTES { return Ok(Err(g::t(lang, K::ErrPasswordTooLong).to_owned())); } let email = form.email.trim(); let email = (!email.is_empty()).then_some(email); let hash = hash_password_async(form.password.clone()).await?; match store .create_user(&username, email, &hash, form.is_admin.is_some()) .await { Ok(created) => Ok(Ok(created)), Err(e) => match user_facing_message(&e) { Some(message) => Ok(Err(message)), None => Err(e.into()), }, } } /// Rows moved and removed by a deletion, as `"