Skip to main content

doido_auth/controllers/
passwords.rs

1//! Default passwords controller (`recoverable` reset request + update).
2
3use crate::recoverable;
4use doido_auth_macros::auth_controller;
5use doido_core::Result;
6use serde::Deserialize;
7use std::marker::PhantomData;
8
9/// Default passwords controller for [`auth_routes!`](crate::auth_routes).
10pub struct AuthPasswords<U>(PhantomData<U>);
11
12#[derive(Debug, Deserialize)]
13pub struct PasswordResetForm {
14    pub email: String,
15}
16
17#[derive(Debug, Deserialize)]
18pub struct PasswordUpdateForm {
19    pub password: String,
20    #[serde(default)]
21    pub password_confirmation: Option<String>,
22    pub reset_password_token: String,
23}
24
25#[derive(Debug, Deserialize)]
26pub struct EditQuery {
27    #[serde(default)]
28    pub reset_password_token: String,
29}
30
31#[auth_controller]
32impl<U> AuthPasswords<U>
33where
34    U: Send + Sync + 'static,
35{
36    /// GET `{prefix}/password/new` — request-reset form (HTML mode).
37    pub async fn new(ctx: doido_controller::Context) -> doido_controller::Response {
38        ctx.render("auth/password_new", serde_json::json!({}))
39    }
40
41    /// POST `{prefix}/password` — generate a token and email reset instructions.
42    /// Always responds generically to avoid leaking which emails exist.
43    pub async fn create(mut ctx: doido_controller::Context) -> Result<doido_controller::Response> {
44        let json = ctx.wants_json();
45        let form: PasswordResetForm = if json {
46            ctx.body_json().await?
47        } else {
48            ctx.form().await?
49        };
50
51        if let Some(token) = recoverable::request_reset(ctx.db(), &form.email).await? {
52            let _ = recoverable::send_reset_email(&form.email, &token).await;
53        }
54
55        if json {
56            Ok(ctx.json(serde_json::json!({ "status": "reset_email_sent" })))
57        } else {
58            Ok(ctx.render(
59                "auth/password_new",
60                serde_json::json!({ "notice": "If your email exists, reset instructions were sent." }),
61            ))
62        }
63    }
64
65    /// GET `{prefix}/password/edit?reset_password_token=…` — choose-new-password
66    /// form (HTML mode).
67    pub async fn edit(ctx: doido_controller::Context) -> doido_controller::Response {
68        let token = ctx
69            .params::<EditQuery>()
70            .map(|q| q.reset_password_token)
71            .unwrap_or_default();
72        ctx.render(
73            "auth/password_edit",
74            serde_json::json!({ "reset_password_token": token }),
75        )
76    }
77
78    /// PATCH `{prefix}/password` — set a new password using a valid reset token.
79    pub async fn update(mut ctx: doido_controller::Context) -> Result<doido_controller::Response> {
80        let json = ctx.wants_json();
81        let form: PasswordUpdateForm = if json {
82            ctx.body_json().await?
83        } else {
84            ctx.form().await?
85        };
86
87        if let Some(ref confirm) = form.password_confirmation {
88            if &form.password != confirm {
89                return password_error(ctx, json, "Password confirmation does not match");
90            }
91        }
92
93        let reset =
94            recoverable::reset_password(ctx.db(), &form.reset_password_token, &form.password)
95                .await?;
96        if !reset {
97            return password_error(ctx, json, "Reset link is invalid or has expired");
98        }
99
100        if json {
101            Ok(ctx.json(serde_json::json!({ "status": "password_reset" })))
102        } else {
103            Ok(ctx.redirect_to("/users/sign_in"))
104        }
105    }
106}
107
108fn password_error(
109    ctx: &mut doido_controller::Context,
110    json: bool,
111    message: &str,
112) -> Result<doido_controller::Response> {
113    if json {
114        Ok(ctx.status(422))
115    } else {
116        Ok(ctx.render(
117            "auth/password_edit",
118            serde_json::json!({ "error": message }),
119        ))
120    }
121}