Skip to main content

better_auth_api/plugins/
user_management.rs

1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5use uuid::Uuid;
6use validator::Validate;
7
8use better_auth_core::adapters::DatabaseAdapter;
9use better_auth_core::entity::{AuthUser, AuthVerification};
10use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
11use better_auth_core::{AuthError, AuthResult};
12use better_auth_core::{AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser};
13
14// ---------------------------------------------------------------------------
15// User info snapshot (dyn-compatible alternative to &dyn AuthUser)
16// ---------------------------------------------------------------------------
17
18/// A plain-data snapshot of the core user fields, passed to callback hooks.
19///
20/// `AuthUser` is **not** dyn-compatible (it requires `Serialize`), so we
21/// extract the fields the callbacks are most likely to need into this struct.
22#[derive(Debug, Clone)]
23pub struct UserInfo {
24    pub id: String,
25    pub email: Option<String>,
26    pub name: Option<String>,
27    pub email_verified: bool,
28}
29
30impl UserInfo {
31    /// Build a [`UserInfo`] from any type that implements [`AuthUser`].
32    fn from_auth_user(user: &impl AuthUser) -> Self {
33        Self {
34            id: user.id().to_string(),
35            email: user.email().map(|s| s.to_string()),
36            name: user.name().map(|s| s.to_string()),
37            email_verified: user.email_verified(),
38        }
39    }
40}
41
42// ---------------------------------------------------------------------------
43// Callback traits
44// ---------------------------------------------------------------------------
45
46/// Custom callback for sending change-email confirmation emails.
47///
48/// If set on [`ChangeEmailConfig`], this callback is invoked instead of the
49/// default [`EmailProvider`]. This allows callers to customise the email
50/// subject, template, and delivery mechanism.
51#[async_trait]
52pub trait SendChangeEmailConfirmation: Send + Sync {
53    async fn send(
54        &self,
55        user: &UserInfo,
56        new_email: &str,
57        url: &str,
58        token: &str,
59    ) -> AuthResult<()>;
60}
61
62/// Hook invoked **before** a user is deleted.
63///
64/// Return `Err(…)` from [`before_delete`](BeforeDeleteUser::before_delete) to
65/// abort the deletion.
66#[async_trait]
67pub trait BeforeDeleteUser: Send + Sync {
68    async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
69}
70
71/// Hook invoked **after** a user has been deleted.
72#[async_trait]
73pub trait AfterDeleteUser: Send + Sync {
74    async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
75}
76
77// ---------------------------------------------------------------------------
78// Configuration
79// ---------------------------------------------------------------------------
80
81/// Configuration for the change-email feature.
82#[derive(Clone, Default)]
83pub struct ChangeEmailConfig {
84    /// Whether the change-email endpoints are enabled. Default: `false`.
85    pub enabled: bool,
86    /// If `true`, the new email is updated immediately without sending a
87    /// verification email. Default: `false`.
88    pub update_without_verification: bool,
89    /// Optional custom callback for sending the confirmation email.
90    pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
91}
92
93impl std::fmt::Debug for ChangeEmailConfig {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.debug_struct("ChangeEmailConfig")
96            .field("enabled", &self.enabled)
97            .field(
98                "update_without_verification",
99                &self.update_without_verification,
100            )
101            .field(
102                "send_change_email_confirmation",
103                &self.send_change_email_confirmation.is_some(),
104            )
105            .finish()
106    }
107}
108
109/// Configuration for the delete-user feature.
110#[derive(Clone)]
111pub struct DeleteUserConfig {
112    /// Whether the delete-user endpoints are enabled. Default: `false`.
113    pub enabled: bool,
114    /// How long a delete-confirmation token remains valid. Default: 1 day.
115    pub delete_token_expires_in: Duration,
116    /// If `true`, a verification email must be confirmed before the account is
117    /// deleted. Default: `true`.
118    pub require_verification: bool,
119    /// Hook called before the user record is removed.
120    pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
121    /// Hook called after the user record has been removed.
122    pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
123}
124
125impl std::fmt::Debug for DeleteUserConfig {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("DeleteUserConfig")
128            .field("enabled", &self.enabled)
129            .field("delete_token_expires_in", &self.delete_token_expires_in)
130            .field("require_verification", &self.require_verification)
131            .field("before_delete", &self.before_delete.is_some())
132            .field("after_delete", &self.after_delete.is_some())
133            .finish()
134    }
135}
136
137impl Default for DeleteUserConfig {
138    fn default() -> Self {
139        Self {
140            enabled: false,
141            delete_token_expires_in: Duration::hours(24),
142            require_verification: true,
143            before_delete: None,
144            after_delete: None,
145        }
146    }
147}
148
149/// Combined configuration for the [`UserManagementPlugin`].
150#[derive(Debug, Clone, Default)]
151pub struct UserManagementConfig {
152    pub change_email: ChangeEmailConfig,
153    pub delete_user: DeleteUserConfig,
154}
155
156// ---------------------------------------------------------------------------
157// Request / response DTOs
158// ---------------------------------------------------------------------------
159
160#[derive(Debug, Deserialize, Validate)]
161struct ChangeEmailRequest {
162    #[serde(rename = "newEmail")]
163    #[validate(email(message = "Invalid email address"))]
164    new_email: String,
165    #[serde(rename = "callbackURL")]
166    callback_url: Option<String>,
167}
168
169#[derive(Debug, Serialize)]
170struct StatusMessageResponse {
171    status: bool,
172    message: String,
173}
174
175// ---------------------------------------------------------------------------
176// Plugin
177// ---------------------------------------------------------------------------
178
179/// User self-service management plugin (change email & delete account).
180pub struct UserManagementPlugin {
181    config: UserManagementConfig,
182}
183
184impl UserManagementPlugin {
185    pub fn new() -> Self {
186        Self {
187            config: UserManagementConfig::default(),
188        }
189    }
190
191    pub fn with_config(config: UserManagementConfig) -> Self {
192        Self { config }
193    }
194
195    // -- builder helpers --
196
197    pub fn change_email_enabled(mut self, enabled: bool) -> Self {
198        self.config.change_email.enabled = enabled;
199        self
200    }
201
202    pub fn update_without_verification(mut self, flag: bool) -> Self {
203        self.config.change_email.update_without_verification = flag;
204        self
205    }
206
207    pub fn send_change_email_confirmation(
208        mut self,
209        cb: Arc<dyn SendChangeEmailConfirmation>,
210    ) -> Self {
211        self.config.change_email.send_change_email_confirmation = Some(cb);
212        self
213    }
214
215    pub fn delete_user_enabled(mut self, enabled: bool) -> Self {
216        self.config.delete_user.enabled = enabled;
217        self
218    }
219
220    pub fn delete_token_expires_in(mut self, duration: Duration) -> Self {
221        self.config.delete_user.delete_token_expires_in = duration;
222        self
223    }
224
225    pub fn require_delete_verification(mut self, require: bool) -> Self {
226        self.config.delete_user.require_verification = require;
227        self
228    }
229
230    pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
231        self.config.delete_user.before_delete = Some(hook);
232        self
233    }
234
235    pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
236        self.config.delete_user.after_delete = Some(hook);
237        self
238    }
239}
240
241impl Default for UserManagementPlugin {
242    fn default() -> Self {
243        Self::new()
244    }
245}
246
247// ---------------------------------------------------------------------------
248// AuthPlugin implementation
249// ---------------------------------------------------------------------------
250
251#[async_trait]
252impl<DB: DatabaseAdapter> AuthPlugin<DB> for UserManagementPlugin {
253    fn name(&self) -> &'static str {
254        "user-management"
255    }
256
257    fn routes(&self) -> Vec<AuthRoute> {
258        let mut routes = Vec::new();
259        if self.config.change_email.enabled {
260            routes.push(AuthRoute::post("/change-email", "change_email"));
261            routes.push(AuthRoute::get(
262                "/change-email/verify",
263                "change_email_verify",
264            ));
265        }
266        if self.config.delete_user.enabled {
267            routes.push(AuthRoute::post("/delete-user", "delete_user"));
268            routes.push(AuthRoute::get("/delete-user/verify", "delete_user_verify"));
269        }
270        routes
271    }
272
273    async fn on_request(
274        &self,
275        req: &AuthRequest,
276        ctx: &AuthContext<DB>,
277    ) -> AuthResult<Option<AuthResponse>> {
278        match (req.method(), req.path()) {
279            // -- change email --
280            (HttpMethod::Post, "/change-email") if self.config.change_email.enabled => {
281                Ok(Some(self.handle_change_email(req, ctx).await?))
282            }
283            (HttpMethod::Get, "/change-email/verify") if self.config.change_email.enabled => {
284                Ok(Some(self.handle_change_email_verify(req, ctx).await?))
285            }
286            // -- delete user --
287            (HttpMethod::Post, "/delete-user") if self.config.delete_user.enabled => {
288                Ok(Some(self.handle_delete_user(req, ctx).await?))
289            }
290            (HttpMethod::Get, "/delete-user/verify") if self.config.delete_user.enabled => {
291                Ok(Some(self.handle_delete_user_verify(req, ctx).await?))
292            }
293            _ => Ok(None),
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Shared helpers (DRY: token creation, token verification, email sending)
300// ---------------------------------------------------------------------------
301
302impl UserManagementPlugin {
303    /// Create a verification token, persist it, and return `(token_value, verification_url)`.
304    async fn create_verification_token<DB: DatabaseAdapter>(
305        ctx: &AuthContext<DB>,
306        identifier: &str,
307        token_prefix: &str,
308        expires_at: chrono::DateTime<Utc>,
309        callback_url: Option<&str>,
310        default_path: &str,
311    ) -> AuthResult<(String, String)> {
312        let token_value = format!("{}_{}", token_prefix, Uuid::new_v4());
313
314        let create_verification = CreateVerification {
315            identifier: identifier.to_string(),
316            value: token_value.clone(),
317            expires_at,
318        };
319
320        ctx.database
321            .create_verification(create_verification)
322            .await?;
323
324        let verification_url = if let Some(cb_url) = callback_url {
325            format!("{}?token={}", cb_url, token_value)
326        } else {
327            format!(
328                "{}/{}?token={}",
329                ctx.config.base_url, default_path, token_value
330            )
331        };
332
333        Ok((token_value, verification_url))
334    }
335
336    /// Retrieve and validate a verification token from the request query string.
337    ///
338    /// Returns the parsed identifier parts and the verification record ID.
339    /// The caller specifies `expected_prefix` (e.g. `"change_email"` or `"delete_user"`)
340    /// and `expected_parts` (how many colon-separated segments the identifier should have).
341    async fn consume_verification_token<DB: DatabaseAdapter>(
342        ctx: &AuthContext<DB>,
343        req: &AuthRequest,
344        expected_prefix: &str,
345        expected_parts: usize,
346    ) -> AuthResult<(Vec<String>, String)> {
347        let token = req
348            .query
349            .get("token")
350            .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
351
352        let verification = ctx
353            .database
354            .get_verification_by_value(token)
355            .await?
356            .ok_or_else(|| AuthError::bad_request("Invalid or expired verification token"))?;
357
358        if verification.expires_at() < Utc::now() {
359            ctx.database.delete_verification(verification.id()).await?;
360            return Err(AuthError::bad_request("Verification token has expired"));
361        }
362
363        let identifier = verification.identifier();
364        let parts: Vec<String> = identifier
365            .splitn(expected_parts, ':')
366            .map(|s| s.to_string())
367            .collect();
368        if parts.len() != expected_parts || parts[0] != expected_prefix {
369            return Err(AuthError::bad_request("Invalid verification token"));
370        }
371
372        let verification_id = verification.id().to_string();
373        Ok((parts, verification_id))
374    }
375
376    /// Send an email using the configured email provider, logging on failure.
377    async fn send_email_or_log<DB: DatabaseAdapter>(
378        ctx: &AuthContext<DB>,
379        to: &str,
380        subject: &str,
381        html: &str,
382        text: &str,
383        action: &str,
384    ) {
385        if let Ok(provider) = ctx.email_provider() {
386            if let Err(e) = provider.send(to, subject, html, text).await {
387                tracing::warn!(
388                    plugin = "user-management",
389                    action = action,
390                    email = to,
391                    error = %e,
392                    "Failed to send email"
393                );
394            }
395        } else {
396            tracing::warn!(
397                plugin = "user-management",
398                action = action,
399                email = to,
400                "No email provider configured, skipping email"
401            );
402        }
403    }
404}
405
406// ---------------------------------------------------------------------------
407// Route handlers
408// ---------------------------------------------------------------------------
409
410impl UserManagementPlugin {
411    // ── change email ──────────────────────────────────────────────────
412
413    /// `POST /change-email`
414    async fn handle_change_email<DB: DatabaseAdapter>(
415        &self,
416        req: &AuthRequest,
417        ctx: &AuthContext<DB>,
418    ) -> AuthResult<AuthResponse> {
419        let (user, _session) = ctx.require_session(req).await?;
420
421        let change_req: ChangeEmailRequest = match better_auth_core::validate_request_body(req) {
422            Ok(v) => v,
423            Err(resp) => return Ok(resp),
424        };
425
426        // Prevent changing to the same email
427        if user
428            .email()
429            .map(|e| e == change_req.new_email)
430            .unwrap_or(false)
431        {
432            return Err(AuthError::bad_request(
433                "New email must be different from the current email",
434            ));
435        }
436
437        // Check if the new email is already in use
438        if ctx
439            .database
440            .get_user_by_email(&change_req.new_email)
441            .await?
442            .is_some()
443        {
444            return Err(AuthError::bad_request(
445                "Email is already in use by another account",
446            ));
447        }
448
449        // If update_without_verification is true, update the email immediately
450        // without creating a verification token or sending an email.
451        if self.config.change_email.update_without_verification {
452            let update_user = UpdateUser {
453                email: Some(change_req.new_email.clone()),
454                email_verified: Some(false),
455                ..Default::default()
456            };
457            ctx.database.update_user(user.id(), update_user).await?;
458
459            let response = StatusMessageResponse {
460                status: true,
461                message: "Email updated successfully".to_string(),
462            };
463            return Ok(AuthResponse::json(200, &response)?);
464        }
465
466        // Create verification token
467        let identifier = format!("change_email:{}:{}", user.id(), change_req.new_email);
468        let expires_at = Utc::now() + Duration::hours(24);
469        let (verification_token, verification_url) = Self::create_verification_token(
470            ctx,
471            &identifier,
472            "ce",
473            expires_at,
474            change_req.callback_url.as_deref(),
475            "change-email/verify",
476        )
477        .await?;
478
479        // Send confirmation email via custom callback or default provider
480        if let Some(ref cb) = self.config.change_email.send_change_email_confirmation {
481            let user_info = UserInfo::from_auth_user(&user);
482            cb.send(
483                &user_info,
484                &change_req.new_email,
485                &verification_url,
486                &verification_token,
487            )
488            .await?;
489        } else {
490            let subject = "Confirm your email change";
491            let html = format!(
492                "<p>Click the link below to confirm your new email address:</p>\
493                 <p><a href=\"{url}\">Confirm Email Change</a></p>",
494                url = verification_url
495            );
496            let text = format!("Confirm your email change: {}", verification_url);
497
498            Self::send_email_or_log(
499                ctx,
500                &change_req.new_email,
501                subject,
502                &html,
503                &text,
504                "change-email",
505            )
506            .await;
507        }
508
509        let response = StatusMessageResponse {
510            status: true,
511            message: "Verification email sent to your new email address".to_string(),
512        };
513        Ok(AuthResponse::json(200, &response)?)
514    }
515
516    /// `GET /change-email/verify`
517    async fn handle_change_email_verify<DB: DatabaseAdapter>(
518        &self,
519        req: &AuthRequest,
520        ctx: &AuthContext<DB>,
521    ) -> AuthResult<AuthResponse> {
522        // Parse and validate token: identifier format is change_email:{user_id}:{new_email}
523        let (parts, verification_id) =
524            Self::consume_verification_token(ctx, req, "change_email", 3).await?;
525
526        let user_id = &parts[1];
527        let new_email = &parts[2];
528
529        // Fetch the user
530        let user = ctx
531            .database
532            .get_user_by_id(user_id)
533            .await?
534            .ok_or_else(|| AuthError::not_found("User not found"))?;
535
536        // Check if the new email is still available
537        if ctx.database.get_user_by_email(new_email).await?.is_some() {
538            ctx.database.delete_verification(&verification_id).await?;
539            return Err(AuthError::bad_request(
540                "Email is already in use by another account",
541            ));
542        }
543
544        // When going through the verification flow, the new email is always
545        // marked as verified (the user proved ownership by clicking the link).
546        let new_verified = true;
547
548        let update_user = UpdateUser {
549            email: Some(new_email.to_string()),
550            email_verified: Some(new_verified),
551            ..Default::default()
552        };
553
554        ctx.database.update_user(user.id(), update_user).await?;
555
556        // Consume the verification token
557        ctx.database.delete_verification(&verification_id).await?;
558
559        let response = StatusMessageResponse {
560            status: true,
561            message: "Email updated successfully".to_string(),
562        };
563        Ok(AuthResponse::json(200, &response)?)
564    }
565
566    // ── delete user ───────────────────────────────────────────────────
567
568    /// `POST /delete-user`
569    async fn handle_delete_user<DB: DatabaseAdapter>(
570        &self,
571        req: &AuthRequest,
572        ctx: &AuthContext<DB>,
573    ) -> AuthResult<AuthResponse> {
574        let (user, _session) = ctx.require_session(req).await?;
575
576        if self.config.delete_user.require_verification {
577            // Verification requires a valid email to send the token to.
578            let email = user.email().filter(|e| !e.is_empty()).ok_or_else(|| {
579                AuthError::bad_request("Cannot send verification email: user has no email address")
580            })?;
581            let email = email.to_string();
582
583            // Send a verification email; actual deletion happens on GET /delete-user/verify.
584            let identifier = format!("delete_user:{}", user.id());
585            let expires_at = Utc::now() + self.config.delete_user.delete_token_expires_in;
586            let (_delete_token, verification_url) = Self::create_verification_token(
587                ctx,
588                &identifier,
589                "del",
590                expires_at,
591                None,
592                "delete-user/verify",
593            )
594            .await?;
595
596            // Send confirmation email
597            let subject = "Confirm account deletion";
598            let html = format!(
599                "<p>Click the link below to confirm the deletion of your account:</p>\
600                 <p><a href=\"{url}\">Confirm Account Deletion</a></p>\
601                 <p>If you did not request this, please ignore this email.</p>",
602                url = verification_url
603            );
604            let text = format!("Confirm account deletion: {}", verification_url);
605
606            Self::send_email_or_log(ctx, &email, subject, &html, &text, "delete-user").await;
607
608            let response = StatusMessageResponse {
609                status: true,
610                message: "Verification email sent. Please confirm to delete your account."
611                    .to_string(),
612            };
613            Ok(AuthResponse::json(200, &response)?)
614        } else {
615            // Immediate deletion (no verification required)
616            self.perform_user_deletion(&user, ctx).await?;
617
618            let response = StatusMessageResponse {
619                status: true,
620                message: "Account deleted successfully".to_string(),
621            };
622            Ok(AuthResponse::json(200, &response)?)
623        }
624    }
625
626    /// `GET /delete-user/verify`
627    async fn handle_delete_user_verify<DB: DatabaseAdapter>(
628        &self,
629        req: &AuthRequest,
630        ctx: &AuthContext<DB>,
631    ) -> AuthResult<AuthResponse> {
632        // Parse and validate token: identifier format is delete_user:{user_id}
633        let (parts, verification_id) =
634            Self::consume_verification_token(ctx, req, "delete_user", 2).await?;
635
636        let user_id = &parts[1];
637
638        // Fetch the user
639        let user = ctx
640            .database
641            .get_user_by_id(user_id)
642            .await?
643            .ok_or_else(|| AuthError::not_found("User not found"))?;
644
645        // Perform the actual deletion first, then consume the token.
646        // This ensures the token remains valid for retry if deletion fails
647        // (e.g. before_delete hook rejects or transient DB error).
648        self.perform_user_deletion(&user, ctx).await?;
649
650        // Consume the verification token after successful deletion
651        ctx.database.delete_verification(&verification_id).await?;
652
653        let response = StatusMessageResponse {
654            status: true,
655            message: "Account deleted successfully".to_string(),
656        };
657        Ok(AuthResponse::json(200, &response)?)
658    }
659
660    // ── shared deletion logic ─────────────────────────────────────────
661
662    /// Delete a user together with all their sessions and accounts.
663    ///
664    /// Calls the configured `before_delete` / `after_delete` hooks when
665    /// present.
666    async fn perform_user_deletion<DB: DatabaseAdapter>(
667        &self,
668        user: &DB::User,
669        ctx: &AuthContext<DB>,
670    ) -> AuthResult<()> {
671        let user_info = UserInfo::from_auth_user(user);
672
673        // before_delete hook
674        if let Some(ref hook) = self.config.delete_user.before_delete {
675            hook.before_delete(&user_info).await?;
676        }
677
678        // Delete all sessions
679        ctx.database.delete_user_sessions(user.id()).await?;
680
681        // Delete all linked accounts
682        let accounts = ctx.database.get_user_accounts(user.id()).await?;
683        for account in &accounts {
684            use better_auth_core::entity::AuthAccount;
685            ctx.database.delete_account(account.id()).await?;
686        }
687
688        // Delete the user record
689        ctx.database.delete_user(user.id()).await?;
690
691        // after_delete hook (non-fatal: user is already deleted, so we log
692        // errors instead of failing the request — matching the pattern in
693        // password_management's on_password_reset callback).
694        if let Some(ref hook) = self.config.delete_user.after_delete
695            && let Err(e) = hook.after_delete(&user_info).await
696        {
697            tracing::warn!(
698                error = %e,
699                user_id = %user_info.id,
700                "after_delete hook failed (user already deleted)"
701            );
702        }
703
704        Ok(())
705    }
706}
707
708// ---------------------------------------------------------------------------
709// Tests
710// ---------------------------------------------------------------------------
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::plugins::test_helpers;
716    use better_auth_core::CreateUser;
717    use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps, VerificationOps};
718    use chrono::Duration;
719    use std::collections::HashMap;
720    use std::sync::Arc;
721
722    // ── change email tests ────────────────────────────────────────────
723
724    #[tokio::test]
725    async fn test_change_email_success() {
726        let plugin = UserManagementPlugin::new().change_email_enabled(true);
727        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
728            CreateUser::new()
729                .with_email("test@example.com")
730                .with_name("Test User")
731                .with_email_verified(true),
732            Duration::hours(24),
733        )
734        .await;
735
736        let body = serde_json::json!({ "newEmail": "new@example.com" });
737        let req = test_helpers::create_auth_request(
738            HttpMethod::Post,
739            "/change-email",
740            Some(&session.token),
741            Some(body.to_string().into_bytes()),
742            HashMap::new(),
743        );
744
745        let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
746        assert_eq!(response.status, 200);
747    }
748
749    #[tokio::test]
750    async fn test_change_email_same_email() {
751        let plugin = UserManagementPlugin::new().change_email_enabled(true);
752        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
753            CreateUser::new()
754                .with_email("test@example.com")
755                .with_name("Test User")
756                .with_email_verified(true),
757            Duration::hours(24),
758        )
759        .await;
760
761        let body = serde_json::json!({ "newEmail": "test@example.com" });
762        let req = test_helpers::create_auth_request(
763            HttpMethod::Post,
764            "/change-email",
765            Some(&session.token),
766            Some(body.to_string().into_bytes()),
767            HashMap::new(),
768        );
769
770        let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
771        assert_eq!(err.status_code(), 400);
772    }
773
774    #[tokio::test]
775    async fn test_change_email_unauthenticated() {
776        let plugin = UserManagementPlugin::new().change_email_enabled(true);
777        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
778            CreateUser::new()
779                .with_email("test@example.com")
780                .with_name("Test User")
781                .with_email_verified(true),
782            Duration::hours(24),
783        )
784        .await;
785
786        let body = serde_json::json!({ "newEmail": "new@example.com" });
787        let req = test_helpers::create_auth_request(
788            HttpMethod::Post,
789            "/change-email",
790            None,
791            Some(body.to_string().into_bytes()),
792            HashMap::new(),
793        );
794
795        let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
796        assert_eq!(err.status_code(), 401);
797    }
798
799    #[tokio::test]
800    async fn test_change_email_verify_success() {
801        let plugin = UserManagementPlugin::new().change_email_enabled(true);
802        let (ctx, user, session) = test_helpers::create_test_context_with_user(
803            CreateUser::new()
804                .with_email("test@example.com")
805                .with_name("Test User")
806                .with_email_verified(true),
807            Duration::hours(24),
808        )
809        .await;
810
811        // 1. Initiate the change
812        let body = serde_json::json!({ "newEmail": "new@example.com" });
813        let req = test_helpers::create_auth_request(
814            HttpMethod::Post,
815            "/change-email",
816            Some(&session.token),
817            Some(body.to_string().into_bytes()),
818            HashMap::new(),
819        );
820        plugin.handle_change_email(&req, &ctx).await.unwrap();
821
822        // 2. Find the verification token created
823        let identifier = format!("change_email:{}:new@example.com", user.id);
824        let verification = ctx
825            .database
826            .get_verification_by_identifier(&identifier)
827            .await
828            .unwrap()
829            .expect("verification should exist");
830
831        // 3. Verify the token
832        let mut query = HashMap::new();
833        query.insert("token".to_string(), verification.value.clone());
834        let req = test_helpers::create_auth_request(
835            HttpMethod::Get,
836            "/change-email/verify",
837            None,
838            None,
839            query,
840        );
841        let response = plugin.handle_change_email_verify(&req, &ctx).await.unwrap();
842        assert_eq!(response.status, 200);
843
844        // 4. Confirm the email was updated
845        let updated_user = ctx
846            .database
847            .get_user_by_id(&user.id)
848            .await
849            .unwrap()
850            .unwrap();
851        assert_eq!(updated_user.email.as_deref(), Some("new@example.com"));
852        // Verification flow always marks the new email as verified
853        assert!(updated_user.email_verified);
854    }
855
856    #[tokio::test]
857    async fn test_change_email_immediate_when_update_without_verification() {
858        let plugin = UserManagementPlugin::new()
859            .change_email_enabled(true)
860            .update_without_verification(true);
861        let (ctx, user, session) = test_helpers::create_test_context_with_user(
862            CreateUser::new()
863                .with_email("test@example.com")
864                .with_name("Test User")
865                .with_email_verified(true),
866            Duration::hours(24),
867        )
868        .await;
869
870        // Initiate change — should update immediately, no verification token
871        let body = serde_json::json!({ "newEmail": "new@example.com" });
872        let req = test_helpers::create_auth_request(
873            HttpMethod::Post,
874            "/change-email",
875            Some(&session.token),
876            Some(body.to_string().into_bytes()),
877            HashMap::new(),
878        );
879        let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
880        assert_eq!(response.status, 200);
881
882        // Email should be updated immediately
883        let updated_user = ctx
884            .database
885            .get_user_by_id(&user.id)
886            .await
887            .unwrap()
888            .unwrap();
889        assert_eq!(updated_user.email.as_deref(), Some("new@example.com"));
890        // email_verified should be false (no verification was performed)
891        assert!(!updated_user.email_verified);
892
893        // No verification token should have been created
894        let identifier = format!("change_email:{}:new@example.com", user.id);
895        let verification = ctx
896            .database
897            .get_verification_by_identifier(&identifier)
898            .await
899            .unwrap();
900        assert!(
901            verification.is_none(),
902            "no verification token should be created when update_without_verification=true"
903        );
904    }
905
906    #[tokio::test]
907    async fn test_change_email_verify_invalid_token() {
908        let plugin = UserManagementPlugin::new().change_email_enabled(true);
909        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
910            CreateUser::new()
911                .with_email("test@example.com")
912                .with_name("Test User")
913                .with_email_verified(true),
914            Duration::hours(24),
915        )
916        .await;
917
918        let mut query = HashMap::new();
919        query.insert("token".to_string(), "invalid-token".to_string());
920        let req = test_helpers::create_auth_request(
921            HttpMethod::Get,
922            "/change-email/verify",
923            None,
924            None,
925            query,
926        );
927
928        let err = plugin
929            .handle_change_email_verify(&req, &ctx)
930            .await
931            .unwrap_err();
932        assert_eq!(err.status_code(), 400);
933    }
934
935    // ── delete user tests ─────────────────────────────────────────────
936
937    #[tokio::test]
938    async fn test_delete_user_immediate() {
939        let plugin = UserManagementPlugin::new()
940            .delete_user_enabled(true)
941            .require_delete_verification(false);
942        let (ctx, user, session) = test_helpers::create_test_context_with_user(
943            CreateUser::new()
944                .with_email("test@example.com")
945                .with_name("Test User")
946                .with_email_verified(true),
947            Duration::hours(24),
948        )
949        .await;
950
951        let req = test_helpers::create_auth_request(
952            HttpMethod::Post,
953            "/delete-user",
954            Some(&session.token),
955            Some(b"{}".to_vec()),
956            HashMap::new(),
957        );
958
959        let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
960        assert_eq!(response.status, 200);
961
962        // User should be gone
963        let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
964        assert!(deleted_user.is_none());
965    }
966
967    #[tokio::test]
968    async fn test_delete_user_with_verification() {
969        let plugin = UserManagementPlugin::new()
970            .delete_user_enabled(true)
971            .require_delete_verification(true);
972        let (ctx, user, session) = test_helpers::create_test_context_with_user(
973            CreateUser::new()
974                .with_email("test@example.com")
975                .with_name("Test User")
976                .with_email_verified(true),
977            Duration::hours(24),
978        )
979        .await;
980
981        // 1. Request deletion — should return pending status
982        let req = test_helpers::create_auth_request(
983            HttpMethod::Post,
984            "/delete-user",
985            Some(&session.token),
986            Some(b"{}".to_vec()),
987            HashMap::new(),
988        );
989
990        let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
991        assert_eq!(response.status, 200);
992
993        // User should still exist
994        let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
995        assert!(still_exists.is_some());
996
997        // 2. Find the verification token
998        let identifier = format!("delete_user:{}", user.id);
999        let verification = ctx
1000            .database
1001            .get_verification_by_identifier(&identifier)
1002            .await
1003            .unwrap()
1004            .expect("verification should exist");
1005
1006        // 3. Confirm deletion
1007        let mut query = HashMap::new();
1008        query.insert("token".to_string(), verification.value.clone());
1009        let req = test_helpers::create_auth_request(
1010            HttpMethod::Get,
1011            "/delete-user/verify",
1012            None,
1013            None,
1014            query,
1015        );
1016        let response = plugin.handle_delete_user_verify(&req, &ctx).await.unwrap();
1017        assert_eq!(response.status, 200);
1018
1019        // User should now be gone
1020        let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
1021        assert!(deleted.is_none());
1022    }
1023
1024    #[tokio::test]
1025    async fn test_delete_user_unauthenticated() {
1026        let plugin = UserManagementPlugin::new()
1027            .delete_user_enabled(true)
1028            .require_delete_verification(false);
1029        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1030            CreateUser::new()
1031                .with_email("test@example.com")
1032                .with_name("Test User")
1033                .with_email_verified(true),
1034            Duration::hours(24),
1035        )
1036        .await;
1037
1038        let req = test_helpers::create_auth_request(
1039            HttpMethod::Post,
1040            "/delete-user",
1041            None,
1042            Some(b"{}".to_vec()),
1043            HashMap::new(),
1044        );
1045
1046        let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
1047        assert_eq!(err.status_code(), 401);
1048    }
1049
1050    #[tokio::test]
1051    async fn test_delete_user_verify_invalid_token() {
1052        let plugin = UserManagementPlugin::new().delete_user_enabled(true);
1053        let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
1054            CreateUser::new()
1055                .with_email("test@example.com")
1056                .with_name("Test User")
1057                .with_email_verified(true),
1058            Duration::hours(24),
1059        )
1060        .await;
1061
1062        let mut query = HashMap::new();
1063        query.insert("token".to_string(), "invalid-token".to_string());
1064        let req = test_helpers::create_auth_request(
1065            HttpMethod::Get,
1066            "/delete-user/verify",
1067            None,
1068            None,
1069            query,
1070        );
1071
1072        let err = plugin
1073            .handle_delete_user_verify(&req, &ctx)
1074            .await
1075            .unwrap_err();
1076        assert_eq!(err.status_code(), 400);
1077    }
1078
1079    #[tokio::test]
1080    async fn test_delete_user_before_hook_abort() {
1081        use std::sync::atomic::{AtomicBool, Ordering};
1082
1083        struct AbortHook;
1084        #[async_trait]
1085        impl BeforeDeleteUser for AbortHook {
1086            async fn before_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1087                Err(AuthError::forbidden("Deletion blocked by policy"))
1088            }
1089        }
1090
1091        let called = Arc::new(AtomicBool::new(false));
1092        let called_clone = called.clone();
1093
1094        struct AfterHook(Arc<AtomicBool>);
1095        #[async_trait]
1096        impl AfterDeleteUser for AfterHook {
1097            async fn after_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1098                self.0.store(true, Ordering::SeqCst);
1099                Ok(())
1100            }
1101        }
1102
1103        let plugin = UserManagementPlugin::new()
1104            .delete_user_enabled(true)
1105            .require_delete_verification(false)
1106            .before_delete(Arc::new(AbortHook))
1107            .after_delete(Arc::new(AfterHook(called_clone)));
1108        let (ctx, user, session) = test_helpers::create_test_context_with_user(
1109            CreateUser::new()
1110                .with_email("test@example.com")
1111                .with_name("Test User")
1112                .with_email_verified(true),
1113            Duration::hours(24),
1114        )
1115        .await;
1116
1117        let req = test_helpers::create_auth_request(
1118            HttpMethod::Post,
1119            "/delete-user",
1120            Some(&session.token),
1121            Some(b"{}".to_vec()),
1122            HashMap::new(),
1123        );
1124
1125        let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
1126        assert_eq!(err.status_code(), 403);
1127
1128        // User should still exist
1129        let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
1130        assert!(still_exists.is_some());
1131
1132        // after_delete should NOT have been called
1133        assert!(!called.load(Ordering::SeqCst));
1134    }
1135
1136    #[tokio::test]
1137    async fn test_plugin_routes_conditional() {
1138        // All disabled
1139        let plugin = UserManagementPlugin::new();
1140        assert!(
1141            <UserManagementPlugin as AuthPlugin<MemoryDatabaseAdapter>>::routes(&plugin).is_empty()
1142        );
1143
1144        // Only change-email enabled
1145        let plugin = UserManagementPlugin::new().change_email_enabled(true);
1146        let routes = <UserManagementPlugin as AuthPlugin<MemoryDatabaseAdapter>>::routes(&plugin);
1147        assert_eq!(routes.len(), 2);
1148        assert!(routes.iter().any(|r| r.path == "/change-email"));
1149        assert!(routes.iter().any(|r| r.path == "/change-email/verify"));
1150
1151        // Only delete-user enabled
1152        let plugin = UserManagementPlugin::new().delete_user_enabled(true);
1153        let routes = <UserManagementPlugin as AuthPlugin<MemoryDatabaseAdapter>>::routes(&plugin);
1154        assert_eq!(routes.len(), 2);
1155        assert!(routes.iter().any(|r| r.path == "/delete-user"));
1156        assert!(routes.iter().any(|r| r.path == "/delete-user/verify"));
1157
1158        // Both enabled
1159        let plugin = UserManagementPlugin::new()
1160            .change_email_enabled(true)
1161            .delete_user_enabled(true);
1162        assert_eq!(
1163            <UserManagementPlugin as AuthPlugin<MemoryDatabaseAdapter>>::routes(&plugin).len(),
1164            4
1165        );
1166    }
1167
1168    #[tokio::test]
1169    async fn test_on_request_disabled_routes_passthrough() {
1170        let plugin = UserManagementPlugin::new(); // both disabled
1171        let (ctx, _user, session) = test_helpers::create_test_context_with_user(
1172            CreateUser::new()
1173                .with_email("test@example.com")
1174                .with_name("Test User")
1175                .with_email_verified(true),
1176            Duration::hours(24),
1177        )
1178        .await;
1179
1180        let body = serde_json::json!({ "newEmail": "x@y.com" });
1181        let req = test_helpers::create_auth_request(
1182            HttpMethod::Post,
1183            "/change-email",
1184            Some(&session.token),
1185            Some(body.to_string().into_bytes()),
1186            HashMap::new(),
1187        );
1188
1189        let result = plugin.on_request(&req, &ctx).await.unwrap();
1190        assert!(result.is_none(), "disabled routes should return None");
1191    }
1192}