mas_handlers/graphql/mutations/
user.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use anyhow::Context as _;
8use async_graphql::{Context, Description, Enum, ID, InputObject, Object};
9use mas_storage::{
10    queue::{
11        DeactivateUserJob, ProvisionUserJob, QueueJobRepositoryExt as _,
12        SendAccountRecoveryEmailsJob,
13    },
14    user::UserRepository,
15};
16use tracing::{info, warn};
17use ulid::Ulid;
18use url::Url;
19use zeroize::Zeroizing;
20
21use super::verify_password_if_needed;
22use crate::graphql::{
23    UserId,
24    model::{NodeType, User},
25    state::ContextExt,
26};
27
28#[derive(Default)]
29pub struct UserMutations {
30    _private: (),
31}
32
33/// The input for the `addUser` mutation.
34#[derive(InputObject)]
35struct AddUserInput {
36    /// The username of the user to add.
37    username: String,
38
39    /// Skip checking with the homeserver whether the username is valid.
40    ///
41    /// Use this with caution! The main reason to use this, is when a user used
42    /// by an application service needs to exist in MAS to craft special
43    /// tokens (like with admin access) for them
44    skip_homeserver_check: Option<bool>,
45}
46
47/// The status of the `addUser` mutation.
48#[derive(Enum, Copy, Clone, Eq, PartialEq)]
49enum AddUserStatus {
50    /// The user was added.
51    Added,
52
53    /// The user already exists.
54    Exists,
55
56    /// The username is reserved.
57    Reserved,
58
59    /// The username is invalid.
60    Invalid,
61}
62
63/// The payload for the `addUser` mutation.
64#[derive(Description)]
65enum AddUserPayload {
66    Added(mas_data_model::User),
67    Exists(mas_data_model::User),
68    Reserved,
69    Invalid,
70}
71
72#[Object(use_type_description)]
73impl AddUserPayload {
74    /// Status of the operation
75    async fn status(&self) -> AddUserStatus {
76        match self {
77            Self::Added(_) => AddUserStatus::Added,
78            Self::Exists(_) => AddUserStatus::Exists,
79            Self::Reserved => AddUserStatus::Reserved,
80            Self::Invalid => AddUserStatus::Invalid,
81        }
82    }
83
84    /// The user that was added.
85    async fn user(&self) -> Option<User> {
86        match self {
87            Self::Added(user) | Self::Exists(user) => Some(User(user.clone())),
88            Self::Invalid | Self::Reserved => None,
89        }
90    }
91}
92
93/// The input for the `lockUser` mutation.
94#[derive(InputObject)]
95struct LockUserInput {
96    /// The ID of the user to lock.
97    user_id: ID,
98
99    /// Permanently lock the user.
100    deactivate: Option<bool>,
101}
102
103/// The status of the `lockUser` mutation.
104#[derive(Enum, Copy, Clone, Eq, PartialEq)]
105enum LockUserStatus {
106    /// The user was locked.
107    Locked,
108
109    /// The user was not found.
110    NotFound,
111}
112
113/// The payload for the `lockUser` mutation.
114#[derive(Description)]
115enum LockUserPayload {
116    /// The user was locked.
117    Locked(mas_data_model::User),
118
119    /// The user was not found.
120    NotFound,
121}
122
123#[Object(use_type_description)]
124impl LockUserPayload {
125    /// Status of the operation
126    async fn status(&self) -> LockUserStatus {
127        match self {
128            Self::Locked(_) => LockUserStatus::Locked,
129            Self::NotFound => LockUserStatus::NotFound,
130        }
131    }
132
133    /// The user that was locked.
134    async fn user(&self) -> Option<User> {
135        match self {
136            Self::Locked(user) => Some(User(user.clone())),
137            Self::NotFound => None,
138        }
139    }
140}
141
142/// The input for the `unlockUser` mutation.
143#[derive(InputObject)]
144struct UnlockUserInput {
145    /// The ID of the user to unlock
146    user_id: ID,
147}
148
149/// The status of the `unlockUser` mutation.
150#[derive(Enum, Copy, Clone, Eq, PartialEq)]
151enum UnlockUserStatus {
152    /// The user was unlocked.
153    Unlocked,
154
155    /// The user was not found.
156    NotFound,
157}
158
159/// The payload for the `unlockUser` mutation.
160#[derive(Description)]
161enum UnlockUserPayload {
162    /// The user was unlocked.
163    Unlocked(mas_data_model::User),
164
165    /// The user was not found.
166    NotFound,
167}
168
169#[Object(use_type_description)]
170impl UnlockUserPayload {
171    /// Status of the operation
172    async fn status(&self) -> UnlockUserStatus {
173        match self {
174            Self::Unlocked(_) => UnlockUserStatus::Unlocked,
175            Self::NotFound => UnlockUserStatus::NotFound,
176        }
177    }
178
179    /// The user that was unlocked.
180    async fn user(&self) -> Option<User> {
181        match self {
182            Self::Unlocked(user) => Some(User(user.clone())),
183            Self::NotFound => None,
184        }
185    }
186}
187
188/// The input for the `setCanRequestAdmin` mutation.
189#[derive(InputObject)]
190struct SetCanRequestAdminInput {
191    /// The ID of the user to update.
192    user_id: ID,
193
194    /// Whether the user can request admin.
195    can_request_admin: bool,
196}
197
198/// The payload for the `setCanRequestAdmin` mutation.
199#[derive(Description)]
200enum SetCanRequestAdminPayload {
201    /// The user was updated.
202    Updated(mas_data_model::User),
203
204    /// The user was not found.
205    NotFound,
206}
207
208#[Object(use_type_description)]
209impl SetCanRequestAdminPayload {
210    /// The user that was updated.
211    async fn user(&self) -> Option<User> {
212        match self {
213            Self::Updated(user) => Some(User(user.clone())),
214            Self::NotFound => None,
215        }
216    }
217}
218
219/// The input for the `allowUserCrossSigningReset` mutation.
220#[derive(InputObject)]
221struct AllowUserCrossSigningResetInput {
222    /// The ID of the user to update.
223    user_id: ID,
224}
225
226/// The payload for the `allowUserCrossSigningReset` mutation.
227#[derive(Description)]
228enum AllowUserCrossSigningResetPayload {
229    /// The user was updated.
230    Allowed(mas_data_model::User),
231
232    /// The user was not found.
233    NotFound,
234}
235
236#[Object(use_type_description)]
237impl AllowUserCrossSigningResetPayload {
238    /// The user that was updated.
239    async fn user(&self) -> Option<User> {
240        match self {
241            Self::Allowed(user) => Some(User(user.clone())),
242            Self::NotFound => None,
243        }
244    }
245}
246
247/// The input for the `setPassword` mutation.
248#[derive(InputObject)]
249struct SetPasswordInput {
250    /// The ID of the user to set the password for.
251    /// If you are not a server administrator then this must be your own user
252    /// ID.
253    user_id: ID,
254
255    /// The current password of the user.
256    /// Required if you are not a server administrator.
257    current_password: Option<String>,
258
259    /// The new password for the user.
260    new_password: String,
261}
262
263/// The input for the `setPasswordByRecovery` mutation.
264#[derive(InputObject)]
265struct SetPasswordByRecoveryInput {
266    /// The recovery ticket to use.
267    /// This identifies the user as well as proving authorisation to perform the
268    /// recovery operation.
269    ticket: String,
270
271    /// The new password for the user.
272    new_password: String,
273}
274
275/// The return type for the `setPassword` mutation.
276#[derive(Description)]
277struct SetPasswordPayload {
278    status: SetPasswordStatus,
279}
280
281/// The status of the `setPassword` mutation.
282#[derive(Enum, Copy, Clone, Eq, PartialEq)]
283enum SetPasswordStatus {
284    /// The password was updated.
285    Allowed,
286
287    /// The user was not found.
288    NotFound,
289
290    /// The user doesn't have a current password to attempt to match against.
291    NoCurrentPassword,
292
293    /// The supplied current password was wrong.
294    WrongPassword,
295
296    /// The new password is invalid. For example, it may not meet configured
297    /// security requirements.
298    InvalidNewPassword,
299
300    /// You aren't allowed to set the password for that user.
301    /// This happens if you aren't setting your own password and you aren't a
302    /// server administrator.
303    NotAllowed,
304
305    /// Password support has been disabled.
306    /// This usually means that login is handled by an upstream identity
307    /// provider.
308    PasswordChangesDisabled,
309
310    /// The specified recovery ticket does not exist.
311    NoSuchRecoveryTicket,
312
313    /// The specified recovery ticket has already been used and cannot be used
314    /// again.
315    RecoveryTicketAlreadyUsed,
316
317    /// The specified recovery ticket has expired.
318    ExpiredRecoveryTicket,
319
320    /// Your account is locked and you can't change its password.
321    AccountLocked,
322}
323
324#[Object(use_type_description)]
325impl SetPasswordPayload {
326    /// Status of the operation
327    async fn status(&self) -> SetPasswordStatus {
328        self.status
329    }
330}
331
332/// The input for the `resendRecoveryEmail` mutation.
333#[derive(InputObject)]
334pub struct ResendRecoveryEmailInput {
335    /// The recovery ticket to use.
336    ticket: String,
337}
338
339/// The return type for the `resendRecoveryEmail` mutation.
340#[derive(Description)]
341pub enum ResendRecoveryEmailPayload {
342    NoSuchRecoveryTicket,
343    RateLimited,
344    Sent { recovery_session_id: Ulid },
345}
346
347/// The status of the `resendRecoveryEmail` mutation.
348#[derive(Enum, Copy, Clone, Eq, PartialEq)]
349pub enum ResendRecoveryEmailStatus {
350    /// The recovery ticket was not found.
351    NoSuchRecoveryTicket,
352
353    /// The rate limit was exceeded.
354    RateLimited,
355
356    /// The recovery email was sent.
357    Sent,
358}
359
360#[Object(use_type_description)]
361impl ResendRecoveryEmailPayload {
362    /// Status of the operation
363    async fn status(&self) -> ResendRecoveryEmailStatus {
364        match self {
365            Self::NoSuchRecoveryTicket => ResendRecoveryEmailStatus::NoSuchRecoveryTicket,
366            Self::RateLimited => ResendRecoveryEmailStatus::RateLimited,
367            Self::Sent { .. } => ResendRecoveryEmailStatus::Sent,
368        }
369    }
370
371    /// URL to continue the recovery process
372    async fn progress_url(&self, context: &Context<'_>) -> Option<Url> {
373        let state = context.state();
374        let url_builder = state.url_builder();
375        match self {
376            Self::NoSuchRecoveryTicket | Self::RateLimited => None,
377            Self::Sent {
378                recovery_session_id,
379            } => {
380                let route = mas_router::AccountRecoveryProgress::new(*recovery_session_id);
381                Some(url_builder.absolute_url_for(&route))
382            }
383        }
384    }
385}
386
387/// The input for the `deactivateUser` mutation.
388#[derive(InputObject)]
389pub struct DeactivateUserInput {
390    /// Whether to ask the homeserver to GDPR-erase the user
391    ///
392    /// This is equivalent to the `erase` parameter on the
393    /// `/_matrix/client/v3/account/deactivate` C-S API, which is
394    /// implementation-specific.
395    ///
396    /// What Synapse does is documented here:
397    /// <https://element-hq.github.io/synapse/latest/admin_api/user_admin_api.html#deactivate-account>
398    hs_erase: bool,
399
400    /// The password of the user to deactivate.
401    password: Option<String>,
402}
403
404/// The payload for the `deactivateUser` mutation.
405#[derive(Description)]
406pub enum DeactivateUserPayload {
407    /// The user was deactivated.
408    Deactivated(mas_data_model::User),
409
410    /// The password was wrong or missing.
411    IncorrectPassword,
412}
413
414/// The status of the `deactivateUser` mutation.
415#[derive(Enum, Copy, Clone, Eq, PartialEq)]
416pub enum DeactivateUserStatus {
417    /// The user was deactivated.
418    Deactivated,
419
420    /// The password was wrong.
421    IncorrectPassword,
422}
423
424#[Object(use_type_description)]
425impl DeactivateUserPayload {
426    /// Status of the operation
427    async fn status(&self) -> DeactivateUserStatus {
428        match self {
429            Self::Deactivated(_) => DeactivateUserStatus::Deactivated,
430            Self::IncorrectPassword => DeactivateUserStatus::IncorrectPassword,
431        }
432    }
433
434    async fn user(&self) -> Option<User> {
435        match self {
436            Self::Deactivated(user) => Some(User(user.clone())),
437            Self::IncorrectPassword => None,
438        }
439    }
440}
441
442fn valid_username_character(c: char) -> bool {
443    c.is_ascii_lowercase()
444        || c.is_ascii_digit()
445        || c == '='
446        || c == '_'
447        || c == '-'
448        || c == '.'
449        || c == '/'
450        || c == '+'
451}
452
453// XXX: this should probably be moved somewhere else
454fn username_valid(username: &str) -> bool {
455    if username.is_empty() || username.len() > 255 {
456        return false;
457    }
458
459    // Should not start with an underscore
460    if username.starts_with('_') {
461        return false;
462    }
463
464    // Should only contain valid characters
465    if !username.chars().all(valid_username_character) {
466        return false;
467    }
468
469    true
470}
471
472#[Object]
473impl UserMutations {
474    /// Add a user. This is only available to administrators.
475    async fn add_user(
476        &self,
477        ctx: &Context<'_>,
478        input: AddUserInput,
479    ) -> Result<AddUserPayload, async_graphql::Error> {
480        let state = ctx.state();
481        let requester = ctx.requester();
482        let clock = state.clock();
483        let mut rng = state.rng();
484
485        if !requester.is_admin() {
486            return Err(async_graphql::Error::new("Unauthorized"));
487        }
488
489        let mut repo = state.repository().await?;
490
491        if let Some(user) = repo.user().find_by_username(&input.username).await? {
492            return Ok(AddUserPayload::Exists(user));
493        }
494
495        // Do some basic check on the username
496        if !username_valid(&input.username) {
497            return Ok(AddUserPayload::Invalid);
498        }
499
500        // Ask the homeserver if the username is available
501        let homeserver_available = state
502            .homeserver_connection()
503            .is_localpart_available(&input.username)
504            .await?;
505
506        if !homeserver_available {
507            if !input.skip_homeserver_check.unwrap_or(false) {
508                return Ok(AddUserPayload::Reserved);
509            }
510
511            // If we skipped the check, we still want to shout about it
512            warn!("Skipped homeserver check for username {}", input.username);
513        }
514
515        let user = repo.user().add(&mut rng, &clock, input.username).await?;
516
517        repo.queue_job()
518            .schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
519            .await?;
520
521        repo.save().await?;
522
523        Ok(AddUserPayload::Added(user))
524    }
525
526    /// Lock a user. This is only available to administrators.
527    async fn lock_user(
528        &self,
529        ctx: &Context<'_>,
530        input: LockUserInput,
531    ) -> Result<LockUserPayload, async_graphql::Error> {
532        let state = ctx.state();
533        let clock = state.clock();
534        let mut rng = state.rng();
535        let requester = ctx.requester();
536
537        if !requester.is_admin() {
538            return Err(async_graphql::Error::new("Unauthorized"));
539        }
540
541        let mut repo = state.repository().await?;
542
543        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
544        let user = repo.user().lookup(user_id).await?;
545
546        let Some(user) = user else {
547            return Ok(LockUserPayload::NotFound);
548        };
549
550        let deactivate = input.deactivate.unwrap_or(false);
551
552        let user = repo.user().lock(&state.clock(), user).await?;
553
554        if deactivate {
555            info!("Scheduling deactivation of user {}", user.id);
556            repo.queue_job()
557                .schedule_job(&mut rng, &clock, DeactivateUserJob::new(&user, deactivate))
558                .await?;
559        }
560
561        repo.save().await?;
562
563        Ok(LockUserPayload::Locked(user))
564    }
565
566    /// Unlock a user. This is only available to administrators.
567    async fn unlock_user(
568        &self,
569        ctx: &Context<'_>,
570        input: UnlockUserInput,
571    ) -> Result<UnlockUserPayload, async_graphql::Error> {
572        let state = ctx.state();
573        let requester = ctx.requester();
574        let matrix = state.homeserver_connection();
575
576        if !requester.is_admin() {
577            return Err(async_graphql::Error::new("Unauthorized"));
578        }
579
580        let mut repo = state.repository().await?;
581        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
582        let user = repo.user().lookup(user_id).await?;
583
584        let Some(user) = user else {
585            return Ok(UnlockUserPayload::NotFound);
586        };
587
588        // Call the homeserver synchronously to unlock the user
589        let mxid = matrix.mxid(&user.username);
590        matrix.reactivate_user(&mxid).await?;
591
592        // Now unlock the user in our database
593        let user = repo.user().unlock(user).await?;
594
595        repo.save().await?;
596
597        Ok(UnlockUserPayload::Unlocked(user))
598    }
599
600    /// Set whether a user can request admin. This is only available to
601    /// administrators.
602    async fn set_can_request_admin(
603        &self,
604        ctx: &Context<'_>,
605        input: SetCanRequestAdminInput,
606    ) -> Result<SetCanRequestAdminPayload, async_graphql::Error> {
607        let state = ctx.state();
608        let requester = ctx.requester();
609
610        if !requester.is_admin() {
611            return Err(async_graphql::Error::new("Unauthorized"));
612        }
613
614        let mut repo = state.repository().await?;
615
616        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
617        let user = repo.user().lookup(user_id).await?;
618
619        let Some(user) = user else {
620            return Ok(SetCanRequestAdminPayload::NotFound);
621        };
622
623        let user = repo
624            .user()
625            .set_can_request_admin(user, input.can_request_admin)
626            .await?;
627
628        repo.save().await?;
629
630        Ok(SetCanRequestAdminPayload::Updated(user))
631    }
632
633    /// Temporarily allow user to reset their cross-signing keys.
634    async fn allow_user_cross_signing_reset(
635        &self,
636        ctx: &Context<'_>,
637        input: AllowUserCrossSigningResetInput,
638    ) -> Result<AllowUserCrossSigningResetPayload, async_graphql::Error> {
639        let state = ctx.state();
640        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
641        let requester = ctx.requester();
642
643        if !requester.is_owner_or_admin(&UserId(user_id)) {
644            return Err(async_graphql::Error::new("Unauthorized"));
645        }
646
647        let mut repo = state.repository().await?;
648        let user = repo.user().lookup(user_id).await?;
649        repo.cancel().await?;
650
651        let Some(user) = user else {
652            return Ok(AllowUserCrossSigningResetPayload::NotFound);
653        };
654
655        let conn = state.homeserver_connection();
656        let mxid = conn.mxid(&user.username);
657
658        conn.allow_cross_signing_reset(&mxid)
659            .await
660            .context("Failed to allow cross-signing reset")?;
661
662        Ok(AllowUserCrossSigningResetPayload::Allowed(user))
663    }
664
665    /// Set the password for a user.
666    ///
667    /// This can be used by server administrators to set any user's password,
668    /// or, provided the capability hasn't been disabled on this server,
669    /// by a user to change their own password as long as they know their
670    /// current password.
671    async fn set_password(
672        &self,
673        ctx: &Context<'_>,
674        input: SetPasswordInput,
675    ) -> Result<SetPasswordPayload, async_graphql::Error> {
676        let state = ctx.state();
677        let user_id = NodeType::User.extract_ulid(&input.user_id)?;
678        let requester = ctx.requester();
679
680        if !requester.is_owner_or_admin(&UserId(user_id)) {
681            return Err(async_graphql::Error::new("Unauthorized"));
682        }
683
684        if input.new_password.is_empty() {
685            // TODO Expose the reason for the policy violation
686            // This involves redesigning the error handling
687            // Idea would be to expose an errors array in the response,
688            // with a list of union of different error kinds.
689            return Ok(SetPasswordPayload {
690                status: SetPasswordStatus::InvalidNewPassword,
691            });
692        }
693
694        let password_manager = state.password_manager();
695
696        if !password_manager.is_enabled() {
697            return Ok(SetPasswordPayload {
698                status: SetPasswordStatus::PasswordChangesDisabled,
699            });
700        }
701
702        if !password_manager.is_password_complex_enough(&input.new_password)? {
703            return Ok(SetPasswordPayload {
704                status: SetPasswordStatus::InvalidNewPassword,
705            });
706        }
707
708        let mut repo = state.repository().await?;
709        let Some(user) = repo.user().lookup(user_id).await? else {
710            return Ok(SetPasswordPayload {
711                status: SetPasswordStatus::NotFound,
712            });
713        };
714
715        if !requester.is_admin() {
716            // If the user isn't an admin, we:
717            // - check that password changes are enabled
718            // - check that they know their current password
719
720            if !state.site_config().password_change_allowed {
721                return Ok(SetPasswordPayload {
722                    status: SetPasswordStatus::PasswordChangesDisabled,
723                });
724            }
725
726            let Some(active_password) = repo.user_password().active(&user).await? else {
727                // The user has no current password, so can't verify against one.
728                // In the future, it may be desirable to let the user set a password without any
729                // other verification instead.
730
731                return Ok(SetPasswordPayload {
732                    status: SetPasswordStatus::NoCurrentPassword,
733                });
734            };
735
736            let Some(current_password_attempt) = input.current_password else {
737                return Err(async_graphql::Error::new(
738                    "You must supply `currentPassword` to change your own password if you are not an administrator",
739                ));
740            };
741
742            if let Err(_err) = password_manager
743                .verify(
744                    active_password.version,
745                    Zeroizing::new(current_password_attempt.into_bytes()),
746                    active_password.hashed_password,
747                )
748                .await
749            {
750                return Ok(SetPasswordPayload {
751                    status: SetPasswordStatus::WrongPassword,
752                });
753            }
754        }
755
756        let (new_password_version, new_password_hash) = password_manager
757            .hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
758            .await?;
759
760        repo.user_password()
761            .add(
762                &mut state.rng(),
763                &state.clock(),
764                &user,
765                new_password_version,
766                new_password_hash,
767                None,
768            )
769            .await?;
770
771        repo.save().await?;
772
773        Ok(SetPasswordPayload {
774            status: SetPasswordStatus::Allowed,
775        })
776    }
777
778    /// Set the password for yourself, using a recovery ticket sent by e-mail.
779    async fn set_password_by_recovery(
780        &self,
781        ctx: &Context<'_>,
782        input: SetPasswordByRecoveryInput,
783    ) -> Result<SetPasswordPayload, async_graphql::Error> {
784        let state = ctx.state();
785        let requester = ctx.requester();
786        let clock = state.clock();
787        if !requester.is_unauthenticated() {
788            return Err(async_graphql::Error::new(
789                "Account recovery is only for anonymous users.",
790            ));
791        }
792
793        let password_manager = state.password_manager();
794
795        if !password_manager.is_enabled() || !state.site_config().account_recovery_allowed {
796            return Ok(SetPasswordPayload {
797                status: SetPasswordStatus::PasswordChangesDisabled,
798            });
799        }
800
801        if !password_manager.is_password_complex_enough(&input.new_password)? {
802            return Ok(SetPasswordPayload {
803                status: SetPasswordStatus::InvalidNewPassword,
804            });
805        }
806
807        let mut repo = state.repository().await?;
808
809        let Some(ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else {
810            return Ok(SetPasswordPayload {
811                status: SetPasswordStatus::NoSuchRecoveryTicket,
812            });
813        };
814
815        let session = repo
816            .user_recovery()
817            .lookup_session(ticket.user_recovery_session_id)
818            .await?
819            .context("Unknown session")?;
820
821        if session.consumed_at.is_some() {
822            return Ok(SetPasswordPayload {
823                status: SetPasswordStatus::RecoveryTicketAlreadyUsed,
824            });
825        }
826
827        if !ticket.active(clock.now()) {
828            return Ok(SetPasswordPayload {
829                status: SetPasswordStatus::ExpiredRecoveryTicket,
830            });
831        }
832
833        let user_email = repo
834            .user_email()
835            .lookup(ticket.user_email_id)
836            .await?
837            .context("Unknown email address")?;
838
839        let user = repo
840            .user()
841            .lookup(user_email.user_id)
842            .await?
843            .context("Invalid user")?;
844
845        if !user.is_valid() {
846            return Ok(SetPasswordPayload {
847                status: SetPasswordStatus::AccountLocked,
848            });
849        }
850
851        let (new_password_version, new_password_hash) = password_manager
852            .hash(state.rng(), Zeroizing::new(input.new_password.into_bytes()))
853            .await?;
854
855        repo.user_password()
856            .add(
857                &mut state.rng(),
858                &state.clock(),
859                &user,
860                new_password_version,
861                new_password_hash,
862                None,
863            )
864            .await?;
865
866        // Mark the session as consumed
867        repo.user_recovery()
868            .consume_ticket(&clock, ticket, session)
869            .await?;
870
871        repo.save().await?;
872
873        Ok(SetPasswordPayload {
874            status: SetPasswordStatus::Allowed,
875        })
876    }
877
878    /// Resend a user recovery email
879    ///
880    /// This is used when a user opens a recovery link that has expired. In this
881    /// case, we display a link for them to get a new recovery email, which
882    /// calls this mutation.
883    pub async fn resend_recovery_email(
884        &self,
885        ctx: &Context<'_>,
886        input: ResendRecoveryEmailInput,
887    ) -> Result<ResendRecoveryEmailPayload, async_graphql::Error> {
888        let state = ctx.state();
889        let requester = ctx.requester();
890        let clock = state.clock();
891        let mut rng = state.rng();
892        let limiter = state.limiter();
893        let mut repo = state.repository().await?;
894
895        let Some(recovery_ticket) = repo.user_recovery().find_ticket(&input.ticket).await? else {
896            return Ok(ResendRecoveryEmailPayload::NoSuchRecoveryTicket);
897        };
898
899        let recovery_session = repo
900            .user_recovery()
901            .lookup_session(recovery_ticket.user_recovery_session_id)
902            .await?
903            .context("Could not load recovery session")?;
904
905        if let Err(e) =
906            limiter.check_account_recovery(requester.fingerprint(), &recovery_session.email)
907        {
908            tracing::warn!(error = &e as &dyn std::error::Error);
909            return Ok(ResendRecoveryEmailPayload::RateLimited);
910        }
911
912        // Schedule a new batch of emails
913        repo.queue_job()
914            .schedule_job(
915                &mut rng,
916                &clock,
917                SendAccountRecoveryEmailsJob::new(&recovery_session),
918            )
919            .await?;
920
921        repo.save().await?;
922
923        Ok(ResendRecoveryEmailPayload::Sent {
924            recovery_session_id: recovery_session.id,
925        })
926    }
927
928    /// Deactivate the current user account
929    ///
930    /// If the user has a password, it *must* be supplied in the `password`
931    /// field.
932    async fn deactivate_user(
933        &self,
934        ctx: &Context<'_>,
935        input: DeactivateUserInput,
936    ) -> Result<DeactivateUserPayload, async_graphql::Error> {
937        let state = ctx.state();
938        let mut rng = state.rng();
939        let clock = state.clock();
940        let requester = ctx.requester();
941        let site_config = state.site_config();
942
943        // Only allow calling this if the requester is a browser session
944        let Some(browser_session) = requester.browser_session() else {
945            return Err(async_graphql::Error::new("Unauthorized"));
946        };
947
948        if !site_config.account_deactivation_allowed {
949            return Err(async_graphql::Error::new(
950                "Account deactivation is not allowed on this server",
951            ));
952        }
953
954        let mut repo = state.repository().await?;
955        if !verify_password_if_needed(
956            requester,
957            site_config,
958            &state.password_manager(),
959            input.password,
960            &browser_session.user,
961            &mut repo,
962        )
963        .await?
964        {
965            return Ok(DeactivateUserPayload::IncorrectPassword);
966        }
967
968        // Deactivate the user right away
969        let user = repo
970            .user()
971            .deactivate(&state.clock(), browser_session.user.clone())
972            .await?;
973
974        // and then schedule a job to deactivate it fully
975        repo.queue_job()
976            .schedule_job(
977                &mut rng,
978                &clock,
979                DeactivateUserJob::new(&user, input.hs_erase),
980            )
981            .await?;
982
983        repo.save().await?;
984
985        Ok(DeactivateUserPayload::Deactivated(user))
986    }
987}