forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.0 KiB
1//! Page: admin invites (`/admin/invites`). An admin mails an invite link to one address. The link
2//! creates that address's account once, and it expires after seven days.
3
4use forge_core::store::validate_email;
5use forge_core::{AuditAction, Registration};
6use serde::Deserialize;
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::content::Form;
10use topcoat::router::error::forbidden;
11use topcoat::router::page;
12use topcoat::view::{View, component, view};
13
14use crate::audit::{self, Actor, Target};
15use crate::auth::check_csrf;
16use crate::i18n::{self, Key, Lang, t};
17use crate::mail::account;
18use crate::pages::admin_users::admin_only;
19use crate::state::AppState;
20use crate::views::{alert, shell, user_facing_message};
21
22#[derive(Deserialize)]
23struct InviteForm {
24 #[serde(default)]
25 email: String,
26 #[serde(default)]
27 csrf: String,
28}
29
30#[component]
31async fn invite_body(
32 lang: Lang,
33 csrf: &str,
34 notice: Option<&'static str>,
35 error: Option<String>,
36) -> Result<impl View> {
37 Ok(view! {
38 <section class="page-head">
39 <h1>(t(lang, Key::InvitesTitle))</h1>
40 <p class="muted">(t(lang, Key::InvitesSub))</p>
41 </section>
42 if let Some(message) = notice {
43 <section class="card notice" role="status">
44 <p>(message)</p>
45 </section>
46 }
47 <section class="card narrow">
48 if let Some(message) = error {
49 alert(message: message.as_str())
50 }
51 <form method="post" action="/admin/invites" class="stack">
52 <input type="hidden" name="csrf" value=(csrf)>
53 <label>
54 <span>(t(lang, Key::Email))</span>
55 <input type="email" name="email" autocomplete="off" maxlength="254" required=(true)>
56 </label>
57 <button type="submit" class="primary">(t(lang, Key::InvitesSubmit))</button>
58 </form>
59 </section>
60 })
61}
62
63#[page("/admin/invites")]
64async fn invites_page(cx: &Cx) -> Result<impl View> {
65 let viewer = admin_only(cx).await?;
66 let lang = i18n::lang(cx, Some(&viewer.0));
67 let csrf = viewer.1.csrf_token.clone();
68 Ok(view! {
69 shell(title: t(lang, Key::InvitesTitle), viewer: Some(&viewer), invite_body(lang: lang, csrf: csrf.as_str(), notice: None, error: None))
70 })
71}
72
73#[page(POST "/admin/invites")]
74async fn invites_submit(cx: &Cx, Form(form): Form<InviteForm>) -> Result<impl View> {
75 let viewer = admin_only(cx).await?;
76 if !check_csrf(&viewer.1, &form.csrf) {
77 return Err(forbidden().into());
78 }
79 let state = app_context::<AppState>(cx);
80 let lang = i18n::lang(cx, Some(&viewer.0));
81 let address = form.email.trim().to_owned();
82 let mut notice = None;
83 let mut error = None;
84 if state.config.registration == Registration::Closed {
85 error = Some(t(lang, Key::InvitesOff).to_owned());
86 } else {
87 match validate_email(&address) {
88 Err(e) => error = user_facing_message(&e),
89 Ok(()) => match account::send_invite(state, &address, &viewer.0.id).await {
90 Ok(()) => {
91 audit::record(
92 &state.store,
93 Some(cx),
94 Actor::User(&viewer.0),
95 AuditAction::InviteSent,
96 Target {
97 kind: "invite",
98 id: "",
99 name: &address,
100 },
101 serde_json::Value::Null,
102 )
103 .await;
104 notice = Some(t(lang, Key::InvitesSent));
105 }
106 Err(e) => match user_facing_message(&e) {
107 Some(message) => error = Some(message),
108 None => return Err(e.into()),
109 },
110 },
111 }
112 }
113 let csrf = viewer.1.csrf_token.clone();
114 Ok(view! {
115 shell(title: t(lang, Key::InvitesTitle), viewer: Some(&viewer), invite_body(lang: lang, csrf: csrf.as_str(), notice: notice, error: error))
116 })
117}
118
119#[cfg(test)]
120mod tests {
121 use std::path::Path;
122
123 use forge_core::Registration;
124
125 use crate::pages::register::test_helpers::send;
126 use crate::state::AppState;
127
128 async fn app(dir: &Path) -> (topcoat::router::Router, AppState) {
129 let mut state = crate::test_support::test_state(dir).await;
130 state.config = std::sync::Arc::new(forge_core::Config {
131 registration: Registration::Invite,
132 ..crate::test_support::test_config(dir)
133 });
134 (crate::test_support::app_router(state.clone()), state)
135 }
136
137 #[tokio::test]
138 async fn invite_page_needs_an_admin() {
139 let tmp = tempfile::tempdir().expect("tempdir");
140 let (router, _state) = app(tmp.path()).await;
141 let (status, _, _) = send(&router, "GET", "/admin/invites", None, None).await;
142 assert_eq!(
143 status, 404,
144 "anonymous visitors get a 404, as on other admin pages"
145 );
146 }
147}