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::wire::UserView;
6use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
7use better_auth_core::{AuthError, AuthResult};
8use better_auth_core::{AuthRequest, AuthResponse, HttpMethod};
9
10mod callbacks;
11pub(super) mod handlers;
12pub use callbacks::{ChangeEmailConfirmation, UserManagementCallbacks};
13mod request;
14pub(super) mod types;
15
16#[cfg(test)]
17mod tests;
18
19use handlers::*;
20use types::*;
21
22// ---------------------------------------------------------------------------
23// User info snapshot (dyn-compatible alternative to &dyn AuthUser)
24// ---------------------------------------------------------------------------
25
26/// Public user snapshot passed to change-email callbacks, including additional fields.
27pub type UserInfo = UserView;
28
29// ---------------------------------------------------------------------------
30// Callback traits
31// ---------------------------------------------------------------------------
32
33/// Custom callback for sending change-email confirmation emails.
34///
35/// A verified user receives this confirmation when email verification delivery
36/// is also configured. Unverified users use the verification sender.
37#[async_trait]
38pub trait SendChangeEmailConfirmation: Send + Sync {
39    async fn send(
40        &self,
41        user: &UserInfo,
42        new_email: &str,
43        url: &str,
44        token: &str,
45    ) -> AuthResult<()>;
46}
47
48/// Hook invoked **before** a user is deleted.
49///
50/// Return `Err(...)` from [`before_delete`](BeforeDeleteUser::before_delete) to
51/// abort the deletion.
52#[async_trait]
53pub trait BeforeDeleteUser: Send + Sync {
54    async fn before_delete(&self, user: &UserView, request: Option<&AuthRequest>)
55    -> AuthResult<()>;
56}
57
58/// Hook invoked **after** a user has been deleted.
59#[async_trait]
60pub trait AfterDeleteUser: Send + Sync {
61    async fn after_delete(&self, user: &UserView, request: Option<&AuthRequest>) -> AuthResult<()>;
62}
63
64/// Configuring this sender requires email confirmation before account deletion.
65#[async_trait]
66pub trait SendDeleteAccountVerification: Send + Sync {
67    async fn send(
68        &self,
69        user: &UserView,
70        url: &str,
71        token: &str,
72        request: Option<&AuthRequest>,
73    ) -> AuthResult<()>;
74}
75
76// ---------------------------------------------------------------------------
77// Configuration
78// ---------------------------------------------------------------------------
79
80/// Configuration for the change-email feature.
81#[derive(Clone, Default)]
82pub struct ChangeEmailConfig {
83    /// Whether the change-email endpoints are enabled. Omission uses `false`.
84    pub enabled: Option<bool>,
85    /// Update an unverified user's email immediately. A configured verification
86    /// sender still sends to the new address after session-cookie issuance.
87    pub update_without_verification: bool,
88    /// Optional custom callback for sending the confirmation email.
89    pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
90}
91
92impl ChangeEmailConfig {
93    /// Read the change-email setting. Omission disables the endpoints.
94    pub fn enabled(&self) -> bool {
95        self.enabled.unwrap_or(false)
96    }
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    /// Send confirmation instead of immediately deleting the account.
123    pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
124    /// Hook called before the user record is removed.
125    pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
126    /// Hook called after the user record has been removed.
127    pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
128}
129
130impl std::fmt::Debug for DeleteUserConfig {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("DeleteUserConfig")
133            .field("enabled", &self.enabled)
134            .field("delete_token_expires_in", &self.delete_token_expires_in)
135            .field(
136                "send_delete_account_verification",
137                &self.send_delete_account_verification.is_some(),
138            )
139            .field("before_delete", &self.before_delete.is_some())
140            .field("after_delete", &self.after_delete.is_some())
141            .finish()
142    }
143}
144
145impl Default for DeleteUserConfig {
146    fn default() -> Self {
147        Self {
148            enabled: false,
149            delete_token_expires_in: Duration::hours(24),
150            send_delete_account_verification: None,
151            before_delete: None,
152            after_delete: None,
153        }
154    }
155}
156
157/// Combined configuration for the [`UserManagementPlugin`].
158#[derive(Debug, Clone, Default)]
159pub struct UserManagementConfig {
160    pub change_email: ChangeEmailConfig,
161    pub delete_user: DeleteUserConfig,
162}
163
164// ---------------------------------------------------------------------------
165// Plugin
166// ---------------------------------------------------------------------------
167
168/// User self-service management plugin (change email & delete account).
169pub struct UserManagementPlugin {
170    config: UserManagementConfig,
171}
172
173impl UserManagementPlugin {
174    pub fn new() -> Self {
175        Self {
176            config: UserManagementConfig::default(),
177        }
178    }
179
180    pub fn with_config(config: UserManagementConfig) -> Self {
181        Self { config }
182    }
183
184    // -- builder helpers --
185
186    pub fn change_email_enabled(mut self, enabled: bool) -> Self {
187        self.config.change_email.enabled = Some(enabled);
188        self
189    }
190
191    pub fn update_without_verification(mut self, flag: bool) -> Self {
192        self.config.change_email.update_without_verification = flag;
193        self
194    }
195
196    pub fn send_change_email_confirmation(
197        mut self,
198        cb: Arc<dyn SendChangeEmailConfirmation>,
199    ) -> Self {
200        self.config.change_email.send_change_email_confirmation = Some(cb);
201        self
202    }
203
204    pub fn delete_user_enabled(mut self, enabled: bool) -> Self {
205        self.config.delete_user.enabled = enabled;
206        self
207    }
208
209    pub fn delete_token_expires_in(mut self, duration: Duration) -> Self {
210        self.config.delete_user.delete_token_expires_in = duration;
211        self
212    }
213
214    pub fn send_delete_account_verification(
215        mut self,
216        sender: Arc<dyn SendDeleteAccountVerification>,
217    ) -> Self {
218        self.config.delete_user.send_delete_account_verification = Some(sender);
219        self
220    }
221
222    pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
223        self.config.delete_user.before_delete = Some(hook);
224        self
225    }
226
227    pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
228        self.config.delete_user.after_delete = Some(hook);
229        self
230    }
231}
232
233impl Default for UserManagementPlugin {
234    fn default() -> Self {
235        Self::new()
236    }
237}
238
239impl UserManagementPlugin {
240    /// `POST /change-email`
241    async fn handle_change_email(
242        &self,
243        req: &AuthRequest,
244        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
245    ) -> AuthResult<AuthResponse> {
246        let (user, session) = ctx
247            .require_authoritative_session(req)
248            .await
249            .map_err(|error| match error {
250                AuthError::Unauthenticated => AuthError::Upstream {
251                    status: 401,
252                    code: "UNAUTHORIZED",
253                    message: "Unauthorized",
254                },
255                error => error,
256            })?;
257        let body = request::change_email(req)?;
258        let response = change_email_core(&body, &user, &session, req, &self.config, ctx).await?;
259        Ok(AuthResponse::json(200, &response)?)
260    }
261
262    /// `POST /delete-user`
263    async fn handle_delete_user(
264        &self,
265        req: &AuthRequest,
266        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
267    ) -> AuthResult<AuthResponse> {
268        let (user, session) = ctx.require_session(req).await?;
269        let user = ctx.user_view(&user).await?;
270        let body = request::delete_user(req)?;
271        let response = delete_user_core(&body, &user, &session, req, &self.config, ctx).await?;
272        let mut response = AuthResponse::json(200, &response)?;
273        for (name, value) in req.take_response_headers()? {
274            response.headers.append(name, value);
275        }
276        Ok(response)
277    }
278
279    /// `GET /delete-user/callback`
280    async fn handle_delete_user_callback(
281        &self,
282        req: &AuthRequest,
283        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
284    ) -> AuthResult<AuthResponse> {
285        let (user, _) = ctx
286            .require_authoritative_session(req)
287            .await
288            .map_err(|error| {
289                if error.status_code() == 401 {
290                    AuthError::not_found("Failed to get user info")
291                } else {
292                    error
293                }
294            })?;
295        let user = ctx.user_view(&user).await?;
296        let query: TokenQuery = serde_json::from_value(serde_json::json!({
297            "token": req.query_string("token")?.map(str::to_owned),
298            "callbackURL": req.query_string("callbackURL")?.map(str::to_owned),
299        }))
300        .map_err(|_| AuthError::bad_request("Verification token is required"))?;
301        let response =
302            delete_user_callback_core(&query.token, &user, req, &self.config, ctx).await?;
303        if let Some(callback_url) = query.callback_url {
304            let mut headers = better_auth_core::Headers::new();
305            _ = headers.insert("Location".to_string(), callback_url);
306            let mut response = AuthResponse::new(302);
307            response.headers = headers;
308            for (name, value) in req.take_response_headers()? {
309                response.headers.append(name, value);
310            }
311            return Ok(response);
312        }
313
314        let mut response = AuthResponse::json(200, &response)?;
315        for (name, value) in req.take_response_headers()? {
316            response.headers.append(name, value);
317        }
318        Ok(response)
319    }
320}
321
322// ---------------------------------------------------------------------------
323// AuthPlugin implementation
324// ---------------------------------------------------------------------------
325
326#[async_trait]
327impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for UserManagementPlugin {
328    fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
329        options.change_email_enabled = self.config.change_email.enabled;
330        options.send_change_email_confirmation = self
331            .config
332            .change_email
333            .send_change_email_confirmation
334            .is_some();
335    }
336
337    fn name(&self) -> &'static str {
338        "user-management"
339    }
340
341    fn telemetry_plugin_id(&self) -> Option<&'static str> {
342        None
343    }
344
345    fn routes(&self) -> Vec<AuthRoute> {
346        let mut routes = vec![
347            AuthRoute::post("/change-email", "changeEmail")
348                .body_validator(request::change_email_body),
349        ];
350        if self.config.delete_user.enabled {
351            routes.push(
352                AuthRoute::post("/delete-user", "deleteUser")
353                    .body_validator(request::delete_user_body),
354            );
355            routes.push(
356                AuthRoute::get("/delete-user/callback", "deleteUserCallback")
357                    .query_validator(crate::plugins::query_input::verify_email),
358            );
359        }
360        routes
361    }
362
363    async fn on_request(
364        &self,
365        req: &AuthRequest,
366        ctx: &AuthContext<S>,
367    ) -> AuthResult<Option<AuthResponse>> {
368        match (req.method(), req.path()) {
369            // -- change email --
370            (HttpMethod::Post, "/change-email") => {
371                Ok(Some(self.handle_change_email(req, ctx).await?))
372            }
373            // -- delete user --
374            (HttpMethod::Post, "/delete-user") if self.config.delete_user.enabled => {
375                Ok(Some(self.handle_delete_user(req, ctx).await?))
376            }
377            (HttpMethod::Get, "/delete-user/callback") if self.config.delete_user.enabled => {
378                Ok(Some(self.handle_delete_user_callback(req, ctx).await?))
379            }
380            _ => Ok(None),
381        }
382    }
383}