//! Page: admin invites (`/admin/invites`). An admin mails an invite link to one address. The link //! creates that address's account once, and it expires after seven days. use forge_core::store::validate_email; use forge_core::{AuditAction, Registration}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::forbidden; use topcoat::router::page; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::check_csrf; use crate::i18n::{self, Key, Lang, t}; use crate::mail::account; use crate::pages::admin_users::admin_only; use crate::state::AppState; use crate::views::{alert, shell, user_facing_message}; #[derive(Deserialize)] struct InviteForm { #[serde(default)] email: String, #[serde(default)] csrf: String, } #[component] async fn invite_body( lang: Lang, csrf: &str, notice: Option<&'static str>, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::InvitesTitle))

(t(lang, Key::InvitesSub))

if let Some(message) = notice {

(message)

}
if let Some(message) = error { alert(message: message.as_str()) }
}) } #[page("/admin/invites")] async fn invites_page(cx: &Cx) -> Result { let viewer = admin_only(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::InvitesTitle), viewer: Some(&viewer), invite_body(lang: lang, csrf: csrf.as_str(), notice: None, error: None)) }) } #[page(POST "/admin/invites")] async fn invites_submit(cx: &Cx, Form(form): Form) -> Result { let viewer = admin_only(cx).await?; if !check_csrf(&viewer.1, &form.csrf) { return Err(forbidden().into()); } let state = app_context::(cx); let lang = i18n::lang(cx, Some(&viewer.0)); let address = form.email.trim().to_owned(); let mut notice = None; let mut error = None; if state.config.registration == Registration::Closed { error = Some(t(lang, Key::InvitesOff).to_owned()); } else { match validate_email(&address) { Err(e) => error = user_facing_message(&e), Ok(()) => match account::send_invite(state, &address, &viewer.0.id).await { Ok(()) => { audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::InviteSent, Target { kind: "invite", id: "", name: &address, }, serde_json::Value::Null, ) .await; notice = Some(t(lang, Key::InvitesSent)); } Err(e) => match user_facing_message(&e) { Some(message) => error = Some(message), None => return Err(e.into()), }, }, } } let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::InvitesTitle), viewer: Some(&viewer), invite_body(lang: lang, csrf: csrf.as_str(), notice: notice, error: error)) }) } #[cfg(test)] mod tests { use std::path::Path; use forge_core::Registration; use crate::pages::register::test_helpers::send; use crate::state::AppState; async fn app(dir: &Path) -> (topcoat::router::Router, AppState) { let mut state = crate::test_support::test_state(dir).await; state.config = std::sync::Arc::new(forge_core::Config { registration: Registration::Invite, ..crate::test_support::test_config(dir) }); (crate::test_support::app_router(state.clone()), state) } #[tokio::test] async fn invite_page_needs_an_admin() { let tmp = tempfile::tempdir().expect("tempdir"); let (router, _state) = app(tmp.path()).await; let (status, _, _) = send(&router, "GET", "/admin/invites", None, None).await; assert_eq!( status, 404, "anonymous visitors get a 404, as on other admin pages" ); } }