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