Skip to main content

better_auth_api/plugins/user_management/
mod.rs

1use async_trait::async_trait;
2use chrono::Duration;
3use std::sync::Arc;
4
5use better_auth_core::adapters::DatabaseAdapter;
6use better_auth_core::entity::AuthUser;
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
10
11pub(super) mod handlers;
12pub(super) mod types;
13
14#[cfg(test)]
15mod tests;
16
17use handlers::*;
18use types::*;
19
20// ---------------------------------------------------------------------------
21// User info snapshot (dyn-compatible alternative to &dyn AuthUser)
22// ---------------------------------------------------------------------------
23
24/// A plain-data snapshot of the core user fields, passed to callback hooks.
25///
26/// `AuthUser` is **not** dyn-compatible (it requires `Serialize`), so we
27/// extract the fields the callbacks are most likely to need into this struct.
28#[derive(Debug, Clone)]
29pub struct UserInfo {
30    pub id: String,
31    pub email: Option<String>,
32    pub name: Option<String>,
33    pub email_verified: bool,
34}
35
36impl UserInfo {
37    /// Build a [`UserInfo`] from any type that implements [`AuthUser`].
38    fn from_auth_user(user: &impl AuthUser) -> Self {
39        Self {
40            id: user.id().to_string(),
41            email: user.email().map(|s| s.to_string()),
42            name: user.name().map(|s| s.to_string()),
43            email_verified: user.email_verified(),
44        }
45    }
46}
47
48// ---------------------------------------------------------------------------
49// Callback traits
50// ---------------------------------------------------------------------------
51
52/// Custom callback for sending change-email confirmation emails.
53///
54/// If set on [`ChangeEmailConfig`], this callback is invoked instead of the
55/// default [`EmailProvider`]. This allows callers to customise the email
56/// subject, template, and delivery mechanism.
57#[async_trait]
58pub trait SendChangeEmailConfirmation: Send + Sync {
59    async fn send(
60        &self,
61        user: &UserInfo,
62        new_email: &str,
63        url: &str,
64        token: &str,
65    ) -> AuthResult<()>;
66}
67
68/// Hook invoked **before** a user is deleted.
69///
70/// Return `Err(...)` from [`before_delete`](BeforeDeleteUser::before_delete) to
71/// abort the deletion.
72#[async_trait]
73pub trait BeforeDeleteUser: Send + Sync {
74    async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
75}
76
77/// Hook invoked **after** a user has been deleted.
78#[async_trait]
79pub trait AfterDeleteUser: Send + Sync {
80    async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
81}
82
83// ---------------------------------------------------------------------------
84// Configuration
85// ---------------------------------------------------------------------------
86
87/// Configuration for the change-email feature.
88#[derive(Clone, Default)]
89pub struct ChangeEmailConfig {
90    /// Whether the change-email endpoints are enabled. Default: `false`.
91    pub enabled: bool,
92    /// If `true`, the new email is updated immediately without sending a
93    /// verification email. Default: `false`.
94    pub update_without_verification: bool,
95    /// Optional custom callback for sending the confirmation email.
96    pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
97}
98
99impl std::fmt::Debug for ChangeEmailConfig {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("ChangeEmailConfig")
102            .field("enabled", &self.enabled)
103            .field(
104                "update_without_verification",
105                &self.update_without_verification,
106            )
107            .field(
108                "send_change_email_confirmation",
109                &self.send_change_email_confirmation.is_some(),
110            )
111            .finish()
112    }
113}
114
115/// Configuration for the delete-user feature.
116#[derive(Clone)]
117pub struct DeleteUserConfig {
118    /// Whether the delete-user endpoints are enabled. Default: `false`.
119    pub enabled: bool,
120    /// How long a delete-confirmation token remains valid. Default: 1 day.
121    pub delete_token_expires_in: Duration,
122    /// If `true`, a verification email must be confirmed before the account is
123    /// deleted. Default: `true`.
124    pub require_verification: bool,
125    /// Hook called before the user record is removed.
126    pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
127    /// Hook called after the user record has been removed.
128    pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
129}
130
131impl std::fmt::Debug for DeleteUserConfig {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.debug_struct("DeleteUserConfig")
134            .field("enabled", &self.enabled)
135            .field("delete_token_expires_in", &self.delete_token_expires_in)
136            .field("require_verification", &self.require_verification)
137            .field("before_delete", &self.before_delete.is_some())
138            .field("after_delete", &self.after_delete.is_some())
139            .finish()
140    }
141}
142
143impl Default for DeleteUserConfig {
144    fn default() -> Self {
145        Self {
146            enabled: false,
147            delete_token_expires_in: Duration::hours(24),
148            require_verification: true,
149            before_delete: None,
150            after_delete: None,
151        }
152    }
153}
154
155/// Combined configuration for the [`UserManagementPlugin`].
156#[derive(Debug, Clone, Default)]
157pub struct UserManagementConfig {
158    pub change_email: ChangeEmailConfig,
159    pub delete_user: DeleteUserConfig,
160}
161
162// ---------------------------------------------------------------------------
163// Plugin
164// ---------------------------------------------------------------------------
165
166/// User self-service management plugin (change email & delete account).
167pub struct UserManagementPlugin {
168    config: UserManagementConfig,
169}
170
171impl UserManagementPlugin {
172    pub fn new() -> Self {
173        Self {
174            config: UserManagementConfig::default(),
175        }
176    }
177
178    pub fn with_config(config: UserManagementConfig) -> Self {
179        Self { config }
180    }
181
182    // -- builder helpers --
183
184    pub fn change_email_enabled(mut self, enabled: bool) -> Self {
185        self.config.change_email.enabled = enabled;
186        self
187    }
188
189    pub fn update_without_verification(mut self, flag: bool) -> Self {
190        self.config.change_email.update_without_verification = flag;
191        self
192    }
193
194    pub fn send_change_email_confirmation(
195        mut self,
196        cb: Arc<dyn SendChangeEmailConfirmation>,
197    ) -> Self {
198        self.config.change_email.send_change_email_confirmation = Some(cb);
199        self
200    }
201
202    pub fn delete_user_enabled(mut self, enabled: bool) -> Self {
203        self.config.delete_user.enabled = enabled;
204        self
205    }
206
207    pub fn delete_token_expires_in(mut self, duration: Duration) -> Self {
208        self.config.delete_user.delete_token_expires_in = duration;
209        self
210    }
211
212    pub fn require_delete_verification(mut self, require: bool) -> Self {
213        self.config.delete_user.require_verification = require;
214        self
215    }
216
217    pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
218        self.config.delete_user.before_delete = Some(hook);
219        self
220    }
221
222    pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
223        self.config.delete_user.after_delete = Some(hook);
224        self
225    }
226}
227
228impl Default for UserManagementPlugin {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Route handlers (delegate to core functions)
236// ---------------------------------------------------------------------------
237
238impl UserManagementPlugin {
239    /// `POST /change-email`
240    async fn handle_change_email<DB: DatabaseAdapter>(
241        &self,
242        req: &AuthRequest,
243        ctx: &AuthContext<DB>,
244    ) -> AuthResult<AuthResponse> {
245        let (user, _session) = ctx.require_session(req).await?;
246        let body: ChangeEmailRequest = match better_auth_core::validate_request_body(req) {
247            Ok(v) => v,
248            Err(resp) => return Ok(resp),
249        };
250        let response = change_email_core(&body, &user, &self.config, ctx).await?;
251        Ok(AuthResponse::json(200, &response)?)
252    }
253
254    /// `GET /change-email/verify`
255    async fn handle_change_email_verify<DB: DatabaseAdapter>(
256        &self,
257        req: &AuthRequest,
258        ctx: &AuthContext<DB>,
259    ) -> AuthResult<AuthResponse> {
260        let token = req
261            .query
262            .get("token")
263            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
264        let response = change_email_verify_core(token, ctx).await?;
265        Ok(AuthResponse::json(200, &response)?)
266    }
267
268    /// `POST /delete-user`
269    async fn handle_delete_user<DB: DatabaseAdapter>(
270        &self,
271        req: &AuthRequest,
272        ctx: &AuthContext<DB>,
273    ) -> AuthResult<AuthResponse> {
274        let (user, _session) = ctx.require_session(req).await?;
275        let response = delete_user_core(&user, &self.config, ctx).await?;
276        Ok(AuthResponse::json(200, &response)?)
277    }
278
279    /// `GET /delete-user/verify`
280    async fn handle_delete_user_verify<DB: DatabaseAdapter>(
281        &self,
282        req: &AuthRequest,
283        ctx: &AuthContext<DB>,
284    ) -> AuthResult<AuthResponse> {
285        let token = req
286            .query
287            .get("token")
288            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
289        let response = delete_user_verify_core(token, &self.config, ctx).await?;
290        Ok(AuthResponse::json(200, &response)?)
291    }
292}
293
294// ---------------------------------------------------------------------------
295// AuthPlugin implementation
296// ---------------------------------------------------------------------------
297
298#[async_trait]
299impl<DB: DatabaseAdapter> AuthPlugin<DB> for UserManagementPlugin {
300    fn name(&self) -> &'static str {
301        "user-management"
302    }
303
304    fn routes(&self) -> Vec<AuthRoute> {
305        let mut routes = Vec::new();
306        if self.config.change_email.enabled {
307            routes.push(AuthRoute::post("/change-email", "change_email"));
308            routes.push(AuthRoute::get(
309                "/change-email/verify",
310                "change_email_verify",
311            ));
312        }
313        if self.config.delete_user.enabled {
314            routes.push(AuthRoute::post("/delete-user", "delete_user"));
315            routes.push(AuthRoute::get("/delete-user/verify", "delete_user_verify"));
316        }
317        routes
318    }
319
320    async fn on_request(
321        &self,
322        req: &AuthRequest,
323        ctx: &AuthContext<DB>,
324    ) -> AuthResult<Option<AuthResponse>> {
325        match (req.method(), req.path()) {
326            // -- change email --
327            (HttpMethod::Post, "/change-email") if self.config.change_email.enabled => {
328                Ok(Some(self.handle_change_email(req, ctx).await?))
329            }
330            (HttpMethod::Get, "/change-email/verify") if self.config.change_email.enabled => {
331                Ok(Some(self.handle_change_email_verify(req, ctx).await?))
332            }
333            // -- delete user --
334            (HttpMethod::Post, "/delete-user") if self.config.delete_user.enabled => {
335                Ok(Some(self.handle_delete_user(req, ctx).await?))
336            }
337            (HttpMethod::Get, "/delete-user/verify") if self.config.delete_user.enabled => {
338                Ok(Some(self.handle_delete_user_verify(req, ctx).await?))
339            }
340            _ => Ok(None),
341        }
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Axum plugin
347// ---------------------------------------------------------------------------
348
349#[cfg(feature = "axum")]
350mod axum_impl {
351    use super::*;
352    use std::sync::Arc;
353
354    use axum::Json;
355    use axum::extract::{Extension, Query, State};
356    use better_auth_core::{
357        AuthError, AuthState, CurrentSession, SuccessMessageResponse, ValidatedJson,
358    };
359
360    #[derive(Clone)]
361    struct PluginState {
362        config: UserManagementConfig,
363    }
364
365    async fn handle_change_email<DB: DatabaseAdapter>(
366        State(state): State<AuthState<DB>>,
367        Extension(ps): Extension<Arc<PluginState>>,
368        CurrentSession { user, .. }: CurrentSession<DB>,
369        ValidatedJson(body): ValidatedJson<ChangeEmailRequest>,
370    ) -> Result<Json<StatusMessageResponse>, AuthError> {
371        if !ps.config.change_email.enabled {
372            return Err(AuthError::not_found("Not found"));
373        }
374        let ctx = state.to_context();
375        let response = change_email_core(&body, &user, &ps.config, &ctx).await?;
376        Ok(Json(response))
377    }
378
379    async fn handle_change_email_verify<DB: DatabaseAdapter>(
380        State(state): State<AuthState<DB>>,
381        Extension(ps): Extension<Arc<PluginState>>,
382        Query(query): Query<TokenQuery>,
383    ) -> Result<Json<StatusMessageResponse>, AuthError> {
384        if !ps.config.change_email.enabled {
385            return Err(AuthError::not_found("Not found"));
386        }
387        let ctx = state.to_context();
388        let response = change_email_verify_core(&query.token, &ctx).await?;
389        Ok(Json(response))
390    }
391
392    async fn handle_delete_user<DB: DatabaseAdapter>(
393        State(state): State<AuthState<DB>>,
394        Extension(ps): Extension<Arc<PluginState>>,
395        CurrentSession { user, .. }: CurrentSession<DB>,
396    ) -> Result<Json<SuccessMessageResponse>, AuthError> {
397        if !ps.config.delete_user.enabled {
398            return Err(AuthError::not_found("Not found"));
399        }
400        let ctx = state.to_context();
401        let response = delete_user_core(&user, &ps.config, &ctx).await?;
402        Ok(Json(response))
403    }
404
405    async fn handle_delete_user_verify<DB: DatabaseAdapter>(
406        State(state): State<AuthState<DB>>,
407        Extension(ps): Extension<Arc<PluginState>>,
408        Query(query): Query<TokenQuery>,
409    ) -> Result<Json<SuccessMessageResponse>, AuthError> {
410        if !ps.config.delete_user.enabled {
411            return Err(AuthError::not_found("Not found"));
412        }
413        let ctx = state.to_context();
414        let response = delete_user_verify_core(&query.token, &ps.config, &ctx).await?;
415        Ok(Json(response))
416    }
417
418    impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for UserManagementPlugin {
419        fn name(&self) -> &'static str {
420            "user-management"
421        }
422
423        fn router(&self) -> axum::Router<AuthState<DB>> {
424            use axum::routing::{get, post};
425
426            let plugin_state = Arc::new(PluginState {
427                config: self.config.clone(),
428            });
429
430            axum::Router::new()
431                .route("/change-email", post(handle_change_email::<DB>))
432                .route(
433                    "/change-email/verify",
434                    get(handle_change_email_verify::<DB>),
435                )
436                .route("/delete-user", post(handle_delete_user::<DB>))
437                .route("/delete-user/verify", get(handle_delete_user_verify::<DB>))
438                .layer(Extension(plugin_state))
439        }
440    }
441}