1use std::{
6 sync::{Arc, Mutex},
7 time::Duration,
8};
9
10use topcoat::{
11 context::{Cx, try_request_context},
12 router::{
13 Body, RouteFuture,
14 error::see_other,
15 response::{IntoResponse, Response},
16 },
17 session::Token,
18 view::{ViewExt, attributes, view},
19};
20
21use super::{
22 PanelUser, SignedIn, infrastructure_failure,
23 login::login_url,
24 mail::{TOKEN_FIELD, after_response, private, token_from_query},
25 page::{AuthPage, render},
26 panel_root,
27 session::{credential_panels, end_credentials, resolve, start_once},
28 token::{self, AuthToken, CREDENTIALS, REVOCATION, TokenPurpose, run_serializable},
29 verification::VERIFICATION_LIFETIME,
30};
31use crate::{
32 auth::realm::{Realm, current_realm, hosted, panel_prefix},
33 flash::set_notification,
34 outcome::Notification,
35};
36
37const CANCEL_LIFETIME: Duration = Duration::from_hours(24 * 7);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[must_use]
43pub enum EmailChange {
44 Pending,
47 Immediate,
49}
50
51pub async fn request_email_change(
98 cx: &Cx,
99 ex: &mut dyn toasty::Executor,
100 user: &dyn PanelUser,
101 email: &str,
102) -> topcoat::Result<EmailChange> {
103 if !current_realm(cx).is_some_and(|panel| panel.auth.verification().is_some()) {
104 return Ok(EmailChange::Immediate);
105 }
106 let user_id = user.user_id();
107 let confirm = token::issue(
108 cx,
109 &mut *ex,
110 TokenPurpose::EmailChange,
111 &user_id,
112 Some(email.to_string()),
113 VERIFICATION_LIFETIME,
114 )
115 .await?;
116 let old = user.email().map(str::to_string);
117 let cancel = match &old {
119 Some(old) => Some(
120 token::issue(
121 cx,
122 ex,
123 TokenPurpose::EmailChangeCancel,
124 &user_id,
125 Some(old.clone()),
126 CANCEL_LIFETIME,
127 )
128 .await?,
129 ),
130 None => None,
131 };
132 let request = Request {
133 old,
134 new: email.to_string(),
135 confirm,
136 cancel,
137 };
138 match try_request_context::<Requested>(cx) {
139 Some(requested) => requested.push(request),
141 None => mail(cx, request),
142 }
143 Ok(EmailChange::Pending)
144}
145
146struct Request {
148 old: Option<String>,
149 new: String,
150 confirm: Token,
151 cancel: Option<Token>,
152}
153
154#[derive(Clone, Default)]
156pub(super) struct Requested(Arc<Mutex<Vec<Request>>>);
157
158impl Requested {
159 fn push(&self, request: Request) {
160 self.0
161 .lock()
162 .unwrap_or_else(|poison| poison.into_inner())
163 .push(request);
164 }
165
166 pub(super) fn mail(&self, cx: &Cx) -> bool {
168 let requests =
169 std::mem::take(&mut *self.0.lock().unwrap_or_else(|poison| poison.into_inner()));
170 let any = !requests.is_empty();
171 for request in requests {
172 mail(cx, request);
173 }
174 any
175 }
176}
177
178fn mail(cx: &Cx, request: Request) {
180 after_response(cx, "email change links", move |cx, panel| async move {
181 let mail = panel
182 .auth
183 .auth_mail()
184 .ok_or_else(topcoat::router::error::not_found)?;
185 let brand = hosted(&cx)?.brand_name(&cx);
186 let confirm = mail.link(&confirm_url(&cx), &request.confirm);
187 let new = &request.new;
188 let message = mail.compose(
189 new,
190 crate::t!(
191 &cx,
192 "tablo-email-change-mail-subject",
193 brand = brand.clone()
194 ),
195 crate::t!(
196 &cx,
197 "tablo-email-change-mail-body",
198 new = new,
199 brand = brand.clone(),
200 link = confirm
201 ),
202 )?;
203 topcoat::mail::send(&cx, message).await?;
204 if let (Some(old), Some(cancel)) = (&request.old, &request.cancel) {
205 let cancel = mail.link(&cancel_url(&cx), cancel);
206 let message = mail.compose(
207 old,
208 crate::t!(
209 &cx,
210 "tablo-email-change-notice-subject",
211 brand = brand.clone()
212 ),
213 crate::t!(
214 &cx,
215 "tablo-email-change-notice-body",
216 brand = brand.clone(),
217 new = new,
218 old = old,
219 link = cancel
220 ),
221 )?;
222 topcoat::mail::send(&cx, message).await?;
223 }
224 Ok(())
225 });
226}
227
228pub(crate) fn confirm_url(cx: &Cx) -> String {
230 format!("{}/email-change/confirm", panel_prefix(cx))
231}
232
233pub(crate) fn cancel_url(cx: &Cx) -> String {
235 format!("{}/email-change/cancel", panel_prefix(cx))
236}
237
238struct Opened {
241 row: AuthToken,
242 account: Arc<dyn PanelUser>,
243 signed: Option<SignedIn>,
244}
245
246async fn opened(
249 cx: &Cx,
250 panel: &Arc<Realm>,
251 purpose: TokenPurpose,
252 encoded: &str,
253) -> topcoat::Result<Option<Opened>> {
254 let Some(authenticator) = panel.auth.authenticator() else {
255 return Ok(None);
256 };
257 let mut db = crate::db::db(cx)?;
258 let Some(row) = token::live(cx, &mut db, purpose, encoded).await? else {
259 return Ok(None);
260 };
261 let account = authenticator
262 .find_by_id(cx, &row.user_id)
263 .await
264 .map_err(infrastructure_failure)?
265 .filter(|account| account.can_access_panel());
266 let Some(account) = account else {
267 return Ok(None);
268 };
269 let signed = resolve(cx, panel, authenticator)
271 .await?
272 .filter(|signed| signed.user.user_id() == row.user_id);
273 Ok(Some(Opened {
274 row,
275 account,
276 signed,
277 }))
278}
279
280async fn link_page(
283 cx: &Cx,
284 title: String,
285 description: String,
286 button: String,
287) -> topcoat::Result<Response> {
288 let csrf = crate::csrf::ensure_token(cx);
289 let action = topcoat::router::request::uri(cx).path().to_string();
290 let token = token_from_query(cx);
291 let form = view! {
292 cx =>
293 <form method="post" action=(action) data-email-change="">
294 (crate::csrf::field(cx, &csrf))
295 <input type="hidden" name=(TOKEN_FIELD) value=(token)>
296 tablo_ui::button(
297 variant: tablo_ui::ButtonVariant::Primary,
298 attrs: attributes! { type="submit" class="w-full" },
299 (button)
300 )
301 </form>
302 }
303 .boxed();
304 let page = render(
305 cx,
306 AuthPage {
307 title,
308 description,
309 status: None,
310 form,
311 switch: None,
312 note: None,
313 },
314 )
315 .await?;
316 private(page.single().await?.into_response(cx)?)
317}
318
319async fn link(
322 cx: &Cx,
323 purpose: TokenPurpose,
324 encoded: &str,
325) -> topcoat::Result<Result<(Arc<Realm>, Opened), Response>> {
326 let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
327 if panel.auth.verification().is_none() {
328 return Err(topcoat::router::error::not_found().into());
329 }
330 match opened(cx, panel, purpose, encoded).await? {
331 Some(opened) => Ok(Ok((Arc::clone(panel), opened))),
332 None => Ok(Err(super::verification::invalid_link(cx).await?)),
333 }
334}
335
336async fn posted_token(cx: &Cx, body: Body) -> topcoat::Result<String> {
338 let form = crate::auth::input::read_form(cx, body).await?;
339 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
340 Ok(form.last(TOKEN_FIELD).unwrap_or_default().to_string())
341}
342
343pub(super) fn confirm_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
345 Box::pin(async move {
346 let (_, opened) = match link(cx, TokenPurpose::EmailChange, &token_from_query(cx)).await? {
347 Ok(link) => link,
348 Err(invalid) => return Ok(invalid),
349 };
350 let email = opened.row.email.unwrap_or_default();
351 link_page(
352 cx,
353 crate::t!(cx, "tablo-email-change-confirm-title"),
354 crate::t!(cx, "tablo-email-change-confirm-description", email = email),
355 crate::t!(cx, "tablo-confirm"),
356 )
357 .await
358 })
359}
360
361pub(super) fn confirm_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
365 Box::pin(async move {
366 let encoded = posted_token(cx, body).await?;
367 let (panel, Opened { row, signed, .. }) =
368 match link(cx, TokenPurpose::EmailChange, &encoded).await? {
369 Ok(link) => link,
370 Err(invalid) => return Ok(invalid),
371 };
372 let verifier = panel
373 .auth
374 .verification()
375 .ok_or_else(topcoat::router::error::not_found)?
376 .verifier();
377 let email = row.email.clone().unwrap_or_default();
378 let user_type = verifier.user_type();
379 let mut started = None;
380 let landing = run_serializable!(cx, |tx| async {
381 if token::live(cx, tx, TokenPurpose::EmailChange, &encoded)
383 .await?
384 .is_none()
385 {
386 return Ok(None);
387 }
388 token::spend(tx, &row).await?;
389 verifier
390 .store_verified_email(cx, &row.user_id, &email, tx)
391 .await
392 .map_err(infrastructure_failure)?;
393 Ok(Some(match &signed {
394 Some(signed) => {
395 let session = start_once(cx, &mut started).await?;
396 super::session::replace_sessions(cx, tx, signed, user_type, &session).await?;
397 panel_root(cx)
398 }
399 None => {
400 let panels = credential_panels(cx, user_type, &panel);
401 end_credentials(tx, &row.user_id, &panels, CREDENTIALS).await?;
402 login_url(cx)
403 }
404 }))
405 })?;
406 let Some(landing) = landing else {
407 return super::verification::invalid_link(cx).await;
408 };
409 set_notification(
410 cx,
411 Notification::success(crate::t!(cx, "tablo-email-change-done")).description(crate::t!(
412 cx,
413 "tablo-email-change-done-description",
414 email = email
415 )),
416 );
417 private(see_other(landing).into_response(cx)?)
418 })
419}
420
421pub(super) fn cancel_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
423 Box::pin(async move {
424 let (_, opened) =
425 match link(cx, TokenPurpose::EmailChangeCancel, &token_from_query(cx)).await? {
426 Ok(link) => link,
427 Err(invalid) => return Ok(invalid),
428 };
429 let email = opened.row.email.unwrap_or_default();
430 link_page(
431 cx,
432 crate::t!(cx, "tablo-email-change-keep-title"),
433 crate::t!(cx, "tablo-email-change-keep-description", email = email),
434 crate::t!(cx, "tablo-email-change-keep"),
435 )
436 .await
437 })
438}
439
440pub(super) fn cancel_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
444 Box::pin(async move {
445 let encoded = posted_token(cx, body).await?;
446 let (panel, Opened { row, account, .. }) =
447 match link(cx, TokenPurpose::EmailChangeCancel, &encoded).await? {
448 Ok(link) => link,
449 Err(invalid) => return Ok(invalid),
450 };
451 let verifier = panel
452 .auth
453 .verification()
454 .ok_or_else(topcoat::router::error::not_found)?
455 .verifier();
456 let old = row.email.clone().unwrap_or_default();
457 let cancelled = run_serializable!(cx, |tx| async {
458 if token::live(cx, tx, TokenPurpose::EmailChangeCancel, &encoded)
459 .await?
460 .is_none()
461 {
462 return Ok(false);
463 }
464 token::spend(tx, &row).await?;
465 if account.email() != Some(old.as_str()) {
466 verifier
468 .store_verified_email(cx, &row.user_id, &old, tx)
469 .await
470 .map_err(infrastructure_failure)?;
471 }
472 let panels = credential_panels(cx, verifier.user_type(), &panel);
473 end_credentials(tx, &row.user_id, &panels, REVOCATION).await?;
474 Ok(true)
475 })?;
476 if !cancelled {
477 return super::verification::invalid_link(cx).await;
478 }
479 topcoat::session::stop(cx).await?;
481 set_notification(
482 cx,
483 Notification::success(crate::t!(cx, "tablo-email-change-kept")).description(crate::t!(
484 cx,
485 "tablo-email-change-kept-description",
486 email = old
487 )),
488 );
489 private(see_other(login_url(cx)).into_response(cx)?)
490 })
491}