1use std::sync::Arc;
5
6use axum::extract::{Extension, State};
7use axum::response::{IntoResponse, Redirect, Response};
8use serde::Deserialize;
9
10use super::events::{
11 AccountDeleted, OtherDevicesLoggedOut, PasswordChanged, ProfileUpdated, announce,
12};
13use super::module::{Settings, go, label, texts};
14use super::{User, change_password, logout_other_devices, verification};
15use crate::db::Model;
16use crate::i18n::Lang;
17use crate::validation::{Errors, Validate, ValidationError, Validator};
18use crate::{AppState, AuthUser, Htmx, Result, Routes, Session, View, context, view};
19
20const CONFIRMED_AT: &str = "_password_confirmed_at";
22pub(crate) const CONFIRM_FOR: u64 = 3 * 60 * 60;
24const CONFIRM_INTENDED: &str = "_password_confirm_intended";
25
26pub(super) fn routes() -> Routes {
27 Routes::new()
28 .get("/account", show)
29 .name("account.show")
30 .put("/account/profile", update_profile)
31 .name("account.profile")
32 .put("/account/password", update_password)
33 .name("account.password")
34 .post("/account/logout-others", logout_others)
35 .name("account.logout_others")
36 .delete("/account", destroy)
37 .name("account.destroy")
38 .require_auth()
39}
40
41fn unix_now() -> u64 {
42 crate::clock::unix_secs().max(0) as u64
43}
44
45pub(crate) fn mark_confirmed(session: &Session) -> Result {
47 session.put(CONFIRMED_AT, unix_now())
48}
49
50pub(crate) fn confirmed_destination(session: &Session) -> String {
52 session
53 .pull::<String>(CONFIRM_INTENDED)
54 .filter(|path| crate::htmx::is_local_path(path))
55 .unwrap_or_else(|| "/".into())
56}
57
58pub(crate) fn recently_confirmed(session: &Session) -> bool {
60 session
61 .get::<u64>(CONFIRMED_AT)
62 .is_some_and(|at| unix_now().saturating_sub(at) < CONFIRM_FOR)
63}
64
65pub(crate) async fn require_password_confirmed(
68 req: axum::extract::Request,
69 next: axum::middleware::Next,
70) -> Response {
71 let Some(session) = req.extensions().get::<Session>().cloned() else {
72 return next.run(req).await;
73 };
74 if recently_confirmed(&session) {
75 return next.run(req).await;
76 }
77 let back = if req.method() == axum::http::Method::GET {
81 req.uri().path_and_query().map(|p| p.as_str().to_owned())
82 } else {
83 crate::htmx::same_site_referer(req.headers())
84 };
85 if let Some(back) = back {
86 let _ = session.put(CONFIRM_INTENDED, back);
87 }
88 let confirm = req
89 .extensions()
90 .get::<AppState>()
91 .and_then(|state| state.url("password.confirm", &[]).ok())
92 .unwrap_or_else(|| "/confirm-password".into());
93 if crate::Htmx::from_headers(req.headers()).request {
94 return crate::HxRedirect(confirm).into_response();
95 }
96 Redirect::to(&confirm).into_response()
97}
98
99pub(super) async fn show_confirm(lang: Lang) -> View {
100 view(
101 "renox/auth/confirm-password.html",
102 context! { text => texts(&lang) },
103 )
104}
105
106#[derive(Deserialize)]
107pub(super) struct ConfirmForm {
108 password: String,
109}
110
111impl Validate for ConfirmForm {
112 fn rules(&self, v: &mut Validator) {
113 let password = label(v, "password");
114 v.field("password", &self.password)
115 .fallback_label(password)
116 .required();
117 }
118}
119
120async fn check_password(
123 user: &User,
124 password: &str,
125 field: &str,
126 lang: &Lang,
127) -> std::result::Result<(), crate::Error> {
128 if user.check_password(password).await {
129 return Ok(());
130 }
131 let template = crate::validation::template_for(Some(&lang.texts()), "current_password");
132 let name = lang
135 .texts()
136 .get(&format!("renox.validation.attributes.{field}"))
137 .cloned()
138 .unwrap_or_else(|| match field {
139 "current_password" => "current password".to_owned(),
140 _ => "password".to_owned(),
141 });
142 let mut errors = Errors::new();
143 errors.add(field, crate::validation::render(&template, &name, &[]));
144 Err(ValidationError::new(errors).into())
145}
146
147pub(super) async fn confirm(
148 user: AuthUser,
149 session: Session,
150 htmx: Htmx,
151 lang: Lang,
152 crate::validation::Valid(form): crate::validation::Valid<ConfirmForm>,
153) -> Result<Response> {
154 check_password(user.user(), &form.password, "password", &lang).await?;
155 mark_confirmed(&session)?;
156 Ok(go(&htmx, confirmed_destination(&session)))
157}
158
159pub(crate) struct AccountSection {
161 template: String,
162 pub(crate) order: i32,
163 data: SectionFn,
164}
165
166type SectionFn = Arc<
167 dyn Fn(
168 User,
169 AppState,
170 )
171 -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<serde_json::Value>> + Send>>
172 + Send
173 + Sync,
174>;
175
176pub(crate) fn section<F, Fut>(template: &str, order: i32, data: F) -> AccountSection
177where
178 F: Fn(User, AppState) -> Fut + Send + Sync + 'static,
179 Fut: std::future::Future<Output = Result<serde_json::Value>> + Send + 'static,
180{
181 AccountSection {
182 template: template.to_owned(),
183 order,
184 data: Arc::new(move |user, state| Box::pin(data(user, state))),
185 }
186}
187
188async fn show(
189 Extension(settings): Extension<Arc<Settings>>,
190 State(state): State<AppState>,
191 user: AuthUser,
192 lang: Lang,
193) -> Result<View> {
194 let registered: Vec<(String, SectionFn)> = state
197 .account_sections
198 .iter()
199 .map(|section| (section.template.clone(), section.data.clone()))
200 .collect();
201 let mut sections = Vec::with_capacity(registered.len());
202 for (template, data) in registered {
203 let data = data(user.user().clone(), state.clone()).await?;
204 sections.push(context! { template, data });
205 }
206 Ok(view(
207 "renox/auth/account.html",
208 context! {
209 text => texts(&lang),
210 user => user.user(),
211 has_password => user.has_password(),
212 verify_email => settings.verify_email,
213 sections,
214 },
215 ))
216}
217
218#[derive(Deserialize)]
219struct ProfileForm {
220 name: String,
221 email: String,
222}
223
224impl Validate for ProfileForm {
225 fn rules(&self, v: &mut Validator) {
226 let name = label(v, "name");
227 v.field("name", &self.name)
228 .fallback_label(name)
229 .required()
230 .max(255);
231 v.field("email", &super::user::normalize_email(&self.email))
232 .required()
233 .email()
234 .max(255);
235 }
236}
237
238async fn update_profile(
239 Extension(settings): Extension<Arc<Settings>>,
240 State(state): State<AppState>,
241 user: AuthUser,
242 session: Session,
243 htmx: Htmx,
244 lang: Lang,
245 req: axum::extract::Request,
246) -> Result<Response> {
247 let id = user.id;
248 let validated = crate::validation::extract::validate_request(
249 req,
250 &state,
251 move |form: &ProfileForm, _, v| {
252 v.field("email", &super::user::normalize_email(&form.email))
253 .unique("users", "email")
254 .ignore(id);
255 },
256 )
257 .await;
258 let form = match validated {
259 Ok((form, _)) => form,
260 Err(rejection) => return Ok(rejection),
261 };
262 let mut me = user.user().clone();
263 let email = super::user::normalize_email(&form.email);
264 let email_changed = email != me.email;
265 me.name = form.name.trim().to_owned();
266 if email_changed {
267 me.email = email;
268 if settings.verify_email {
269 me.email_verified_at = None;
270 }
271 }
272 me.save(&state.db).await?;
273 if email_changed && settings.verify_email {
274 verification::send_verification(&state, &me).await?;
275 }
276 let event = ProfileUpdated {
277 user_id: me.id,
278 email_changed,
279 };
280 announce(&state, event).await;
281 session.flash("status", &texts(&lang)["profile_saved"])?;
282 Ok(go(&htmx, state.url("account.show", &[])?))
283}
284
285#[derive(Deserialize)]
286struct PasswordForm {
287 #[serde(default)]
289 current_password: String,
290 password: String,
291 password_confirmation: Option<String>,
292}
293
294impl Validate for PasswordForm {
295 fn rules(&self, _v: &mut Validator) {}
296}
297
298#[derive(Deserialize)]
301struct PasswordCheck {
302 #[serde(default)]
303 password: String,
304}
305
306impl Validate for PasswordCheck {
307 fn rules(&self, _v: &mut Validator) {}
308}
309
310fn needs_confirmation(
315 state: &AppState,
316 session: &Session,
317 user: &User,
318 htmx: &Htmx,
319) -> Option<Response> {
320 if user.has_password() || recently_confirmed(session) {
321 return None;
322 }
323 if let Ok(account) = state.url("account.show", &[]) {
324 let _ = session.put(CONFIRM_INTENDED, account);
325 }
326 let confirm = state
327 .url("password.confirm", &[])
328 .unwrap_or_else(|_| "/confirm-password".into());
329 Some(go(htmx, confirm))
330}
331
332async fn check_typed(user: &User, typed: &str, field: &str, lang: &Lang) -> Result {
334 if user.has_password() {
335 check_password(user, typed, field, lang).await?;
336 }
337 Ok(())
338}
339
340async fn password_check(
342 state: &AppState,
343 user: &User,
344 req: axum::extract::Request,
345) -> std::result::Result<PasswordCheck, Box<Response>> {
346 let has_password = user.has_password();
347 crate::validation::extract::validate_request(req, state, move |form: &PasswordCheck, _, v| {
348 if has_password {
349 let password = label(v, "password");
350 v.field("password", &form.password)
351 .fallback_label(password)
352 .required();
353 }
354 })
355 .await
356 .map(|(form, _)| form)
357 .map_err(Box::new)
358}
359
360async fn update_password(
361 Extension(settings): Extension<Arc<Settings>>,
362 State(state): State<AppState>,
363 user: AuthUser,
364 session: Session,
365 htmx: Htmx,
366 lang: Lang,
367 req: axum::extract::Request,
368) -> Result<Response> {
369 if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
370 return Ok(confirm);
371 }
372 let policy = settings.password.clone();
373 let has_password = user.has_password();
374 let validated = crate::validation::extract::validate_request(
375 req,
376 &state,
377 move |form: &PasswordForm, _, v| {
378 if has_password {
379 let current = label(v, "current_password");
380 v.field("current_password", &form.current_password)
381 .fallback_label(current)
382 .required();
383 }
384 let password = label(v, "password");
385 v.field("password", &form.password)
386 .fallback_label(password)
387 .required()
388 .password(&policy)
389 .confirmed(&form.password_confirmation);
390 },
391 )
392 .await;
393 let form = match validated {
394 Ok((form, _)) => form,
395 Err(rejection) => return Ok(rejection),
396 };
397 check_typed(
398 user.user(),
399 &form.current_password,
400 "current_password",
401 &lang,
402 )
403 .await?;
404 let mut me = user.user().clone();
405 change_password(&state.db, &session, &mut me, &form.password).await?;
406 mark_confirmed(&session)?;
407 announce(&state, PasswordChanged { user_id: me.id }).await;
408 session.flash("status", &texts(&lang)["password_changed"])?;
409 Ok(go(&htmx, state.url("account.show", &[])?))
410}
411
412async fn logout_others(
413 State(state): State<AppState>,
414 user: AuthUser,
415 session: Session,
416 htmx: Htmx,
417 lang: Lang,
418 req: axum::extract::Request,
419) -> Result<Response> {
420 if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
421 return Ok(confirm);
422 }
423 let form = match password_check(&state, user.user(), req).await {
424 Ok(form) => form,
425 Err(rejection) => return Ok(*rejection),
426 };
427 check_typed(user.user(), &form.password, "password", &lang).await?;
428 logout_other_devices(&state.db, &session, user.user()).await?;
429 announce(&state, OtherDevicesLoggedOut { user_id: user.id }).await;
430 session.flash("status", &texts(&lang)["other_devices_logged_out"])?;
431 Ok(go(&htmx, state.url("account.show", &[])?))
432}
433
434async fn destroy(
435 State(state): State<AppState>,
436 user: AuthUser,
437 session: Session,
438 htmx: Htmx,
439 lang: Lang,
440 req: axum::extract::Request,
441) -> Result<Response> {
442 if let Some(confirm) = needs_confirmation(&state, &session, user.user(), &htmx) {
443 return Ok(confirm);
444 }
445 let form = match password_check(&state, user.user(), req).await {
446 Ok(form) => form,
447 Err(rejection) => return Ok(*rejection),
448 };
449 check_typed(user.user(), &form.password, "password", &lang).await?;
450 user.delete_account(&state.db).await?;
451 session.flush();
452 let event = AccountDeleted {
453 user_id: user.id,
454 email: user.email.clone(),
455 };
456 announce(&state, event).await;
457 Ok(go(
458 &htmx,
459 state.url("home", &[]).unwrap_or_else(|_| "/".into()),
460 ))
461}
462
463impl User {
464 pub async fn delete_account(&self, db: &crate::db::Db) -> Result {
468 let mut tx = db.begin().await?;
469 crate::db::sql("DELETE FROM grid_preferences WHERE user_id = ?")
472 .bind(self.id)
473 .execute(&mut tx)
474 .await?;
475 crate::db::sql("DELETE FROM users WHERE id = ?")
476 .bind(self.id)
477 .execute(&mut tx)
478 .await?;
479 tx.commit().await?;
480 Ok(())
481 }
482}