From 83995728349a2a589408d45078bbe9de474b3195 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 4 Jun 2024 13:41:37 +0200
Subject: [PATCH 01/34] add is_active column to the database

---
 ...20240604104038_add_user_active_flag.down.sql |  1 +
 .../20240604104038_add_user_active_flag.up.sql  |  5 +++++
 src/db/models/user.rs                           | 17 +++++++----------
 3 files changed, 13 insertions(+), 10 deletions(-)
 create mode 100644 migrations/20240604104038_add_user_active_flag.down.sql
 create mode 100644 migrations/20240604104038_add_user_active_flag.up.sql

diff --git a/migrations/20240604104038_add_user_active_flag.down.sql b/migrations/20240604104038_add_user_active_flag.down.sql
new file mode 100644
index 0000000000..0151cefaeb
--- /dev/null
+++ b/migrations/20240604104038_add_user_active_flag.down.sql
@@ -0,0 +1 @@
+ALTER TABLE "user" DROP COLUMN is_active;
\ No newline at end of file
diff --git a/migrations/20240604104038_add_user_active_flag.up.sql b/migrations/20240604104038_add_user_active_flag.up.sql
new file mode 100644
index 0000000000..a49a1c0b59
--- /dev/null
+++ b/migrations/20240604104038_add_user_active_flag.up.sql
@@ -0,0 +1,5 @@
+ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT false;
+
+-- Update the user table to keep the old behaviour
+-- Previously: active user = user with password set
+UPDATE "user" SET is_active = TRUE WHERE password_hash IS NOT NULL;
\ No newline at end of file
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index 65cf433465..611ed5955d 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -71,6 +71,7 @@ pub struct User {
     pub email: String,
     pub phone: Option<String>,
     pub mfa_enabled: bool,
+    pub is_active: bool,
     // secret has been verified and TOTP can be used
     pub(crate) totp_enabled: bool,
     pub(crate) email_mfa_enabled: bool,
@@ -116,6 +117,7 @@ impl User {
             email_mfa_secret: None,
             mfa_method: MFAMethod::None,
             recovery_codes: Vec::new(),
+            is_active: false,
         }
     }
 
@@ -136,11 +138,6 @@ impl User {
         }
     }
 
-    #[must_use]
-    pub fn has_password(&self) -> bool {
-        self.password_hash.is_some()
-    }
-
     #[must_use]
     pub fn name(&self) -> String {
         format!("{} {}", self.first_name, self.last_name)
@@ -447,7 +444,7 @@ impl User {
     ) -> Result<Vec<UserDiagnostic>, SqlxError> {
         let users = query!(
             "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, \
-                mfa_method as \"mfa_method: MFAMethod\", password_hash \
+                mfa_method as \"mfa_method: MFAMethod\", is_active \
             FROM \"user\""
         )
         .fetch_all(pool)
@@ -460,7 +457,7 @@ impl User {
                 email_mfa_enabled: u.email_mfa_enabled,
                 mfa_enabled: u.mfa_enabled,
                 id: u.id,
-                is_active: u.password_hash.is_some(),
+                is_active: u.is_active,
             })
             .collect();
         Ok(res)
@@ -476,7 +473,7 @@ impl User {
             "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \
             phone, mfa_enabled, totp_enabled, totp_secret, \
             email_mfa_enabled, email_mfa_secret, \
-            mfa_method \"mfa_method: _\", recovery_codes \
+            mfa_method \"mfa_method: _\", recovery_codes, is_active \
             FROM \"user\"
             INNER JOIN \"group_user\" ON \"user\".id = \"group_user\".user_id
             INNER JOIN \"group\" ON \"group_user\".group_id = \"group\".id
@@ -567,7 +564,7 @@ impl User {
             Self,
             "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \
             phone, mfa_enabled, totp_enabled, email_mfa_enabled, \
-            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes \
+            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \
             FROM \"user\" WHERE username = $1",
             username
         )
@@ -583,7 +580,7 @@ impl User {
             Self,
             "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \
             phone, mfa_enabled, totp_enabled, email_mfa_enabled, \
-            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes \
+            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \
             FROM \"user\" WHERE email = $1",
             email
         )

From 0c88ac439536992b9d42570bd48e7e4ef839a655 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 4 Jun 2024 13:43:06 +0200
Subject: [PATCH 02/34] use is_active field to check if the user is active

---
 src/db/models/enrollment.rs | 2 +-
 src/db/models/group.rs      | 2 +-
 src/db/models/mod.rs        | 2 +-
 src/grpc/enrollment.rs      | 5 ++---
 src/grpc/password_reset.rs  | 4 ++--
 src/handlers/group.rs       | 2 +-
 src/handlers/user.rs        | 2 +-
 7 files changed, 9 insertions(+), 10 deletions(-)

diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs
index 767af4fe9f..45c66b0fe2 100644
--- a/src/db/models/enrollment.rs
+++ b/src/db/models/enrollment.rs
@@ -376,7 +376,7 @@ impl User {
             "User {} starting enrollment for user {}, notification enabled: {send_user_notification}",
             admin.username, self.username
         );
-        if self.has_password() {
+        if self.is_active {
             return Err(TokenError::AlreadyActive);
         }
 
diff --git a/src/db/models/group.rs b/src/db/models/group.rs
index ce8f00aad7..73f4476188 100644
--- a/src/db/models/group.rs
+++ b/src/db/models/group.rs
@@ -60,7 +60,7 @@ impl Group {
                 User,
                 "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, \
                 phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, \
-                mfa_method \"mfa_method: _\", recovery_codes \
+                mfa_method \"mfa_method: _\", recovery_codes, is_active \
                 FROM \"user\" \
                 JOIN group_user ON \"user\".id = group_user.user_id \
                 WHERE group_user.group_id = $1",
diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index e60dfdfe99..4f6e6b9e4e 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -96,7 +96,7 @@ impl UserInfo {
             groups,
             mfa_method: user.mfa_method.clone(),
             authorized_apps,
-            is_active: user.has_password(),
+            is_active: user.is_active,
         })
     }
 
diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs
index 683677baa0..37514c13d3 100644
--- a/src/grpc/enrollment.rs
+++ b/src/grpc/enrollment.rs
@@ -212,7 +212,7 @@ impl EnrollmentServer {
         // fetch related users
         let mut user = enrollment.fetch_user(&self.pool).await?;
         info!("Activating user account for {}", user.username);
-        if user.has_password() {
+        if user.is_active {
             error!("User {} already activated", user.username);
             return Err(Status::invalid_argument("user already activated"));
         }
@@ -465,7 +465,6 @@ impl From<User> for AdminInfo {
 
 impl InitialUserInfo {
     async fn from_user(pool: &DbPool, user: User) -> Result<Self, sqlx::Error> {
-        let is_active = user.has_password();
         let devices = user.devices(pool).await?;
         let device_names = devices.into_iter().map(|dev| dev.device.name).collect();
         Ok(Self {
@@ -474,7 +473,7 @@ impl InitialUserInfo {
             login: user.username,
             email: user.email,
             phone_number: user.phone,
-            is_active,
+            is_active: user.is_active,
             device_names,
         })
     }
diff --git a/src/grpc/password_reset.rs b/src/grpc/password_reset.rs
index c68a15c3c0..176f9e8298 100644
--- a/src/grpc/password_reset.rs
+++ b/src/grpc/password_reset.rs
@@ -89,7 +89,7 @@ impl PasswordResetServer {
         };
 
         // Do not allow password change if user is not active
-        if !user.has_password() {
+        if !user.is_active {
             return Ok(());
         }
 
@@ -144,7 +144,7 @@ impl PasswordResetServer {
 
         let user = enrollment.fetch_user(&self.pool).await?;
 
-        if !user.has_password() {
+        if !user.is_active {
             return Err(Status::permission_denied("user inactive"));
         }
 
diff --git a/src/handlers/group.rs b/src/handlers/group.rs
index ba529874f3..748bdc5388 100644
--- a/src/handlers/group.rs
+++ b/src/handlers/group.rs
@@ -47,7 +47,7 @@ pub(crate) async fn bulk_assign_to_groups(
         User,
         "SELECT id \"id?\", username, password_hash, last_name, first_name, email, \
             phone, mfa_enabled, totp_enabled, email_mfa_enabled, \
-            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes \
+            totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active \
             FROM \"user\" WHERE id = ANY($1)",
         &data.users
     )
diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index 78803fe551..9f9f35e03f 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -165,7 +165,7 @@ pub async fn add_user(
     let user_info = UserInfo::from_user(&appstate.pool, &user).await?;
     appstate.trigger_action(AppEvent::UserCreated(user_info.clone()));
     info!("User {} added user {username}", session.user.username);
-    if !user.has_password() {
+    if !user.is_active {
         warn!("User {username} is not active yet. Please proceed with enrollment.");
     };
     Ok(ApiResponse {

From 04e71a84553453e8862c91fa477a753ddc43079b Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Thu, 6 Jun 2024 12:28:09 +0200
Subject: [PATCH 03/34] add backend logic

---
 ...20240604104038_add_user_active_flag.up.sql |  6 +-----
 src/db/models/enrollment.rs                   |  9 ++++++++-
 src/db/models/mod.rs                          | 19 +++++++++++++++++++
 src/db/models/user.rs                         |  9 ++++++++-
 src/db/models/wireguard.rs                    | 12 ++++++++++--
 src/error.rs                                  |  7 ++++---
 src/grpc/enrollment.rs                        | 16 +++++++++++++++-
 src/grpc/gateway.rs                           |  2 ++
 src/grpc/password_reset.rs                    | 10 ++++++----
 src/handlers/auth.rs                          |  9 ++++++++-
 src/handlers/user.rs                          |  7 +++++--
 src/handlers/wireguard.rs                     | 11 +++++++++++
 12 files changed, 97 insertions(+), 20 deletions(-)

diff --git a/migrations/20240604104038_add_user_active_flag.up.sql b/migrations/20240604104038_add_user_active_flag.up.sql
index a49a1c0b59..b426087f7b 100644
--- a/migrations/20240604104038_add_user_active_flag.up.sql
+++ b/migrations/20240604104038_add_user_active_flag.up.sql
@@ -1,5 +1 @@
-ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT false;
-
--- Update the user table to keep the old behaviour
--- Previously: active user = user with password set
-UPDATE "user" SET is_active = TRUE WHERE password_hash IS NOT NULL;
\ No newline at end of file
+ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT true;
\ No newline at end of file
diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs
index 45c66b0fe2..36a3658ce7 100644
--- a/src/db/models/enrollment.rs
+++ b/src/db/models/enrollment.rs
@@ -35,6 +35,8 @@ pub enum TokenError {
     TokenUsed,
     #[error("Enrollment user not found")]
     UserNotFound,
+    #[error("Enrollment user is disabled")]
+    UserDisabled,
     #[error("Enrollment admin not found")]
     AdminNotFound,
     #[error("User account is already activated")]
@@ -58,6 +60,7 @@ impl From<TokenError> for Status {
             TokenError::DbError(_)
             | TokenError::AdminNotFound
             | TokenError::UserNotFound
+            | TokenError::UserDisabled
             | TokenError::NotificationError(_)
             | TokenError::WelcomeMsgNotConfigured
             | TokenError::WelcomeEmailNotConfigured
@@ -376,10 +379,14 @@ impl User {
             "User {} starting enrollment for user {}, notification enabled: {send_user_notification}",
             admin.username, self.username
         );
-        if self.is_active {
+        if self.has_password() {
             return Err(TokenError::AlreadyActive);
         }
 
+        if !self.is_active {
+            return Err(TokenError::UserDisabled);
+        }
+
         let user_id = self.id.expect("User without ID");
         let admin_id = admin.id.expect("Admin user without ID");
 
diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index 4f6e6b9e4e..edac80e9c8 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -76,6 +76,7 @@ pub struct UserInfo {
     pub mfa_method: MFAMethod,
     pub authorized_apps: Vec<OAuth2AuthorizedAppInfo>,
     pub is_active: bool,
+    pub enrolled: bool,
 }
 
 impl UserInfo {
@@ -97,9 +98,27 @@ impl UserInfo {
             mfa_method: user.mfa_method.clone(),
             authorized_apps,
             is_active: user.is_active,
+            enrolled: user.has_password(),
         })
     }
 
+    /// Copy status to [`User`]. This function should be used by administrators.
+    ///
+    /// Return `true` if status was changed, `false` otherwise.
+    pub(crate) async fn handle_status_change(
+        &self,
+        transaction: &mut PgConnection,
+        user: &mut User,
+    ) -> Result<bool, SqlxError> {
+        if self.is_active != user.is_active {
+            user.is_active = self.is_active;
+            user.save(transaction).await?;
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+
     /// Copy groups to [`User`]. This function should be used by administrators.
     ///
     /// Return `true` if groups were changed, `false` otherwise.
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index 611ed5955d..6f1f3d3d40 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -59,6 +59,7 @@ pub struct UserDiagnostic {
     pub email_mfa_enabled: bool,
     pub mfa_method: MFAMethod,
     pub is_active: bool,
+    pub enrolled: bool,
 }
 
 #[derive(Model, PartialEq, Serialize, Clone, Debug)]
@@ -138,6 +139,11 @@ impl User {
         }
     }
 
+    #[must_use]
+    pub fn has_password(&self) -> bool {
+        self.password_hash.is_some()
+    }
+
     #[must_use]
     pub fn name(&self) -> String {
         format!("{} {}", self.first_name, self.last_name)
@@ -444,7 +450,7 @@ impl User {
     ) -> Result<Vec<UserDiagnostic>, SqlxError> {
         let users = query!(
             "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, \
-                mfa_method as \"mfa_method: MFAMethod\", is_active \
+                mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active \
             FROM \"user\""
         )
         .fetch_all(pool)
@@ -458,6 +464,7 @@ impl User {
                 mfa_enabled: u.mfa_enabled,
                 id: u.id,
                 is_active: u.is_active,
+                enrolled: u.password_hash.is_some(),
             })
             .collect();
         Ok(res)
diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs
index 72a7b2e58b..d85c17f213 100644
--- a/src/db/models/wireguard.rs
+++ b/src/db/models/wireguard.rs
@@ -328,15 +328,23 @@ impl WireguardNetwork {
                     JOIN group_user gu ON u.id = gu.user_id \
                     JOIN \"group\" g ON gu.group_id = g.id \
                     WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[]))
+                    AND u.is_active = true
                     ORDER BY d.id ASC",
                     &allowed_groups
                 )
                 .fetch_all(&mut *transaction)
                 .await?
             },
-            // all devices are allowed
+            // all devices of enabled users are allowed
             None => {
-                Device::all(&mut *transaction).await?
+                query_as!(
+                    Device,
+                    "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created \
+                    FROM device d \
+                    JOIN \"user\" u ON d.user_id = u.id \
+                    WHERE u.is_active = true \
+                    ORDER BY d.id ASC"
+                ).fetch_all(&mut *transaction).await?
             }
         };
 
diff --git a/src/error.rs b/src/error.rs
index 3c003b0f99..00db0dee6d 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -134,9 +134,10 @@ impl From<TokenError> for WebError {
             TokenError::NotFound | TokenError::UserNotFound | TokenError::AdminNotFound => {
                 WebError::ObjectNotFound(err.to_string())
             }
-            TokenError::TokenExpired | TokenError::SessionExpired | TokenError::TokenUsed => {
-                WebError::Authorization(err.to_string())
-            }
+            TokenError::TokenExpired
+            | TokenError::SessionExpired
+            | TokenError::TokenUsed
+            | TokenError::UserDisabled => WebError::Authorization(err.to_string()),
             TokenError::AlreadyActive => WebError::BadRequest(err.to_string()),
             TokenError::NotificationError(_)
             | TokenError::WelcomeMsgNotConfigured
diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs
index 37514c13d3..f0304b11fd 100644
--- a/src/grpc/enrollment.rs
+++ b/src/grpc/enrollment.rs
@@ -212,11 +212,16 @@ impl EnrollmentServer {
         // fetch related users
         let mut user = enrollment.fetch_user(&self.pool).await?;
         info!("Activating user account for {}", user.username);
-        if user.is_active {
+        if user.has_password() {
             error!("User {} already activated", user.username);
             return Err(Status::invalid_argument("user already activated"));
         }
 
+        if !user.is_active {
+            error!("User {} is disabled", user.username);
+            return Err(Status::invalid_argument("user is disabled"));
+        }
+
         let mut transaction = self.pool.begin().await.map_err(|_| {
             error!("Failed to begin transaction");
             Status::internal("unexpected error")
@@ -289,6 +294,13 @@ impl EnrollmentServer {
         // add device
         info!("Adding new device for user {}", user.username);
 
+        if !user.is_active {
+            error!("Can't create device for a disabled user {}", user.username);
+            return Err(Status::invalid_argument(
+                "can't add device to disabled user",
+            ));
+        }
+
         let ip_address;
         let device_info;
         if let Some(info) = req_device_info {
@@ -465,6 +477,7 @@ impl From<User> for AdminInfo {
 
 impl InitialUserInfo {
     async fn from_user(pool: &DbPool, user: User) -> Result<Self, sqlx::Error> {
+        let is_enrolled = user.has_password();
         let devices = user.devices(pool).await?;
         let device_names = devices.into_iter().map(|dev| dev.device.name).collect();
         Ok(Self {
@@ -475,6 +488,7 @@ impl InitialUserInfo {
             phone_number: user.phone,
             is_active: user.is_active,
             device_names,
+            enrolled: is_enrolled,
         })
     }
 }
diff --git a/src/grpc/gateway.rs b/src/grpc/gateway.rs
index ea994fcb66..a22c800471 100644
--- a/src/grpc/gateway.rs
+++ b/src/grpc/gateway.rs
@@ -49,7 +49,9 @@ impl WireguardNetwork {
                 array[host(wnd.wireguard_ip)] as \"allowed_ips!: Vec<String>\" \
             FROM wireguard_network_device wnd \
             JOIN device d ON wnd.device_id = d.id \
+            JOIN \"user\" u ON d.user_id = u.id \
             WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) \
+            AND u.is_active = true \
             ORDER BY d.id ASC",
             self.id,
             self.mfa_enabled
diff --git a/src/grpc/password_reset.rs b/src/grpc/password_reset.rs
index 176f9e8298..df8093f36a 100644
--- a/src/grpc/password_reset.rs
+++ b/src/grpc/password_reset.rs
@@ -88,8 +88,8 @@ impl PasswordResetServer {
             return Ok(());
         };
 
-        // Do not allow password change if user is not active
-        if !user.is_active {
+        // Do not allow password change if user is disabled or not enrolled
+        if !user.has_password() || !user.is_active {
             return Ok(());
         }
 
@@ -144,8 +144,10 @@ impl PasswordResetServer {
 
         let user = enrollment.fetch_user(&self.pool).await?;
 
-        if !user.is_active {
-            return Err(Status::permission_denied("user inactive"));
+        if !user.has_password() || !user.is_active {
+            return Err(Status::permission_denied(
+                "user disabled or not enrolled yet",
+            ));
         }
 
         let mut transaction = self.pool.begin().await.map_err(|_| {
diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs
index a4a1cbffb7..1da00d9094 100644
--- a/src/handlers/auth.rs
+++ b/src/handlers/auth.rs
@@ -59,7 +59,14 @@ pub async fn authenticate(
 
     let user = match User::find_by_username(&appstate.pool, &username).await {
         Ok(Some(user)) => match user.verify_password(&data.password) {
-            Ok(()) => user,
+            Ok(()) => {
+                if user.is_active {
+                    user
+                } else {
+                    info!("Failed to authenticate user {username}: user is inactive");
+                    return Err(WebError::Authorization("user not found".into()));
+                }
+            }
             Err(err) => {
                 info!("Failed to authenticate user {username}: {err}");
                 log_failed_login_attempt(&appstate.failed_logins, &username);
diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index 9f9f35e03f..fac38c41aa 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -165,8 +165,8 @@ pub async fn add_user(
     let user_info = UserInfo::from_user(&appstate.pool, &user).await?;
     appstate.trigger_action(AppEvent::UserCreated(user_info.clone()));
     info!("User {} added user {username}", session.user.username);
-    if !user.is_active {
-        warn!("User {username} is not active yet. Please proceed with enrollment.");
+    if !user_info.enrolled {
+        warn!("User {username} hasn't been enrolled yet. Please proceed with enrollment.");
     };
     Ok(ApiResponse {
         json: json!(&user_info),
@@ -326,6 +326,9 @@ pub async fn modify_user(
         if user_info
             .handle_user_groups(&mut transaction, &mut user)
             .await?
+            || user_info
+                .handle_status_change(&mut transaction, &mut user)
+                .await?
         {
             let networks = WireguardNetwork::all(&mut *transaction).await?;
             for network in networks {
diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs
index 9350061aa6..7dc9aa146f 100644
--- a/src/handlers/wireguard.rs
+++ b/src/handlers/wireguard.rs
@@ -458,6 +458,17 @@ pub async fn add_device(
     );
 
     let user = user_for_admin_or_self(&appstate.pool, &session, &username).await?;
+
+    // Let admins manage devices for disabled users
+    if !user.is_active && !session.is_admin {
+        debug!(
+            "User {} tried to add a device for a disabled user {username}",
+            session.user.username
+        );
+
+        return Err(WebError::Forbidden("User is disabled.".into()));
+    }
+
     let networks = WireguardNetwork::all(&appstate.pool).await?;
     if networks.is_empty() {
         error!("No network found, can't add device");

From eaffb43e1b9d5f53e6f048c0ea57fcd20766d753 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Thu, 6 Jun 2024 12:30:58 +0200
Subject: [PATCH 04/34] handle user disabling on the frontend

---
 web/src/i18n/en/index.ts                      | 20 ++++
 web/src/i18n/i18n-types.ts                    | 92 ++++++++++++++++++
 web/src/i18n/pl/index.ts                      | 20 ++++
 .../pages/users/UserProfile/UserProfile.tsx   | 36 +++++++
 .../UserEditButton/UserEditButton.tsx         | 19 +++-
 web/src/pages/users/UsersSharedModals.tsx     |  2 +
 .../ToggleUserModal/ToggleUserModal.tsx       | 97 +++++++++++++++++++
 web/src/shared/hooks/store/useModalStore.ts   | 10 ++
 web/src/shared/types.ts                       | 10 ++
 9 files changed, 304 insertions(+), 2 deletions(-)
 create mode 100644 web/src/pages/users/shared/modals/ToggleUserModal/ToggleUserModal.tsx

diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts
index b17b71b4e5..c54161b12f 100644
--- a/web/src/i18n/en/index.ts
+++ b/web/src/i18n/en/index.ts
@@ -273,6 +273,26 @@ const en: BaseTranslation = {
         success: '{username: string} deleted.',
       },
     },
+    disableUser: {
+      title: 'Disable account',
+      controls: {
+        submit: 'Disable account',
+      },
+      message: 'Do you want to disable {username: string} account ?',
+      messages: {
+        success: '{username: string} disabled.',
+      },
+    },
+    enableUser: {
+      title: 'Enable account',
+      controls: {
+        submit: 'Enable account',
+      },
+      message: 'Do you want to enable {username: string} account ?',
+      messages: {
+        success: '{username: string} enabled.',
+      },
+    },
     deleteProvisioner: {
       title: 'Delete provisioner',
       controls: {
diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts
index 40142d1f62..cac929a9ad 100644
--- a/web/src/i18n/i18n-types.ts
+++ b/web/src/i18n/i18n-types.ts
@@ -648,6 +648,54 @@ type RootTranslation = {
 				success: RequiredParams<'username'>
 			}
 		}
+		disableUser: {
+			/**
+			 * D​i​s​a​b​l​e​ ​a​c​c​o​u​n​t
+			 */
+			title: string
+			controls: {
+				/**
+				 * D​i​s​a​b​l​e​ ​a​c​c​o​u​n​t
+				 */
+				submit: string
+			}
+			/**
+			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​i​s​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​?
+			 * @param {string} username
+			 */
+			message: RequiredParams<'username'>
+			messages: {
+				/**
+				 * {​u​s​e​r​n​a​m​e​}​ ​d​i​s​a​b​l​e​d​.
+				 * @param {string} username
+				 */
+				success: RequiredParams<'username'>
+			}
+		}
+		enableUser: {
+			/**
+			 * E​n​a​b​l​e​ ​a​c​c​o​u​n​t
+			 */
+			title: string
+			controls: {
+				/**
+				 * E​n​a​b​l​e​ ​a​c​c​o​u​n​t
+				 */
+				submit: string
+			}
+			/**
+			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​e​n​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​?
+			 * @param {string} username
+			 */
+			message: RequiredParams<'username'>
+			messages: {
+				/**
+				 * {​u​s​e​r​n​a​m​e​}​ ​e​n​a​b​l​e​d​.
+				 * @param {string} username
+				 */
+				success: RequiredParams<'username'>
+			}
+		}
 		deleteProvisioner: {
 			/**
 			 * D​e​l​e​t​e​ ​p​r​o​v​i​s​i​o​n​e​r
@@ -4488,6 +4536,50 @@ export type TranslationFunctions = {
 				success: (arg: { username: string }) => LocalizedString
 			}
 		}
+		disableUser: {
+			/**
+			 * Disable account
+			 */
+			title: () => LocalizedString
+			controls: {
+				/**
+				 * Disable account
+				 */
+				submit: () => LocalizedString
+			}
+			/**
+			 * Do you want to disable {username} account ?
+			 */
+			message: (arg: { username: string }) => LocalizedString
+			messages: {
+				/**
+				 * {username} disabled.
+				 */
+				success: (arg: { username: string }) => LocalizedString
+			}
+		}
+		enableUser: {
+			/**
+			 * Enable account
+			 */
+			title: () => LocalizedString
+			controls: {
+				/**
+				 * Enable account
+				 */
+				submit: () => LocalizedString
+			}
+			/**
+			 * Do you want to enable {username} account ?
+			 */
+			message: (arg: { username: string }) => LocalizedString
+			messages: {
+				/**
+				 * {username} enabled.
+				 */
+				success: (arg: { username: string }) => LocalizedString
+			}
+		}
 		deleteProvisioner: {
 			/**
 			 * Delete provisioner
diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts
index 698fa3f439..53680cca2d 100644
--- a/web/src/i18n/pl/index.ts
+++ b/web/src/i18n/pl/index.ts
@@ -275,6 +275,26 @@ const pl: Translation = {
         success: '{username} usunięte.',
       },
     },
+    disableUser: {
+      title: 'Wyłącz użytkownika',
+      controls: {
+        submit: 'Wyłącz użytkownika',
+      },
+      message: 'Czy chcesz wyłączyć użytkownika {username}?',
+      messages: {
+        success: 'Użytkownik {username} został wyłączony.',
+      },
+    },
+    enableUser: {
+      title: 'Włącz użytkownika',
+      controls: {
+        submit: 'Włącz użytkownika',
+      },
+      message: 'Czy chcesz włączyć użytkownika {username}?',
+      messages: {
+        success: 'Użytkownik {username} został włączony.',
+      },
+    },
     deleteProvisioner: {
       title: 'Usuń provisionera',
       controls: {
diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx
index fc44b44b03..86211c3d12 100644
--- a/web/src/pages/users/UserProfile/UserProfile.tsx
+++ b/web/src/pages/users/UserProfile/UserProfile.tsx
@@ -133,6 +133,7 @@ const EditModeControls = () => {
   const isMe = useUserProfileStore((state) => state.isMe);
   const setUserProfileState = useUserProfileStore((state) => state.setState);
   const setDeleteUserModalState = useModalStore((state) => state.setDeleteUserModal);
+  const setToggleUserModalState = useModalStore((state) => state.setToggleUserModal);
   const loading = useUserProfileStore((state) => state.loading);
 
   const submitSubject = useUserProfileStore((state) => state.submitSubject);
@@ -143,6 +144,12 @@ const EditModeControls = () => {
     }
   };
 
+  const handleToggleUser = () => {
+    if (userProfile) {
+      setToggleUserModalState({ visible: true, user: userProfile.user });
+    }
+  };
+
   return (
     <>
       {isAdmin && !isMe && breakpoint === 'desktop' ? (
@@ -153,6 +160,20 @@ const EditModeControls = () => {
             styleVariant={ButtonStyleVariant.CONFIRM}
             onClick={handleDeleteUser}
           />
+          <Button
+            text={
+              userProfile?.user?.is_active
+                ? LL.modals.disableUser.title()
+                : LL.modals.enableUser.title()
+            }
+            size={ButtonSize.SMALL}
+            styleVariant={
+              userProfile?.user?.is_active
+                ? ButtonStyleVariant.CONFIRM
+                : ButtonStyleVariant.STANDARD
+            }
+            onClick={handleToggleUser}
+          />
         </div>
       ) : null}
       <div className="right">
@@ -165,6 +186,21 @@ const EditModeControls = () => {
               disabled={!isAdmin || isMe}
               onClick={handleDeleteUser}
             />
+            <EditButtonOption
+              data-testid="user-edit-disable-acccount"
+              text={
+                userProfile?.user?.is_active
+                  ? LL.modals.disableUser.title()
+                  : LL.modals.enableUser.title()
+              }
+              styleVariant={
+                userProfile?.user?.is_active
+                  ? EditButtonOptionStyleVariant.WARNING
+                  : EditButtonOptionStyleVariant.STANDARD
+              }
+              disabled={!isAdmin || isMe}
+              onClick={handleToggleUser}
+            />
           </EditButton>
         )}
         <Button
diff --git a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
index a2bd0c447d..947d0b6510 100644
--- a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
+++ b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
@@ -21,6 +21,7 @@ export const UserEditButton = ({ user }: Props) => {
   const { LL } = useI18nContext();
   const navigate = useNavigate();
   const setDeleteUserModal = useModalStore((state) => state.setDeleteUserModal);
+  const setToggleUserModal = useModalStore((state) => state.setToggleUserModal);
   const setChangePasswordModal = useModalStore((state) => state.setChangePasswordModal);
   const setUserProfile = useUserProfileStore((state) => state.setState);
   const setAddUserModal = useAddUserModal((state) => state.setState);
@@ -79,7 +80,7 @@ export const UserEditButton = ({ user }: Props) => {
           }
         />
       )}
-      {user.is_active === true && (
+      {user.enrolled === true && user.is_active && (
         <EditButtonOption
           disabled={!networkPresent}
           key="start-dekstop-activation"
@@ -94,7 +95,7 @@ export const UserEditButton = ({ user }: Props) => {
           }
         />
       )}
-      {!user.is_active && (
+      {!user.enrolled && user.is_active && (
         <EditButtonOption
           key="start-enrollment"
           text={LL.usersOverview.list.editButton.startEnrollment()}
@@ -107,6 +108,20 @@ export const UserEditButton = ({ user }: Props) => {
           }
         />
       )}
+      {user.username !== currentUser?.username && (
+        <EditButtonOption
+          key="toggle-user"
+          text={
+            user.is_active ? LL.modals.disableUser.title() : LL.modals.enableUser.title()
+          }
+          onClick={() =>
+            setToggleUserModal({
+              visible: true,
+              user,
+            })
+          }
+        />
+      )}
       {user.username !== currentUser?.username && (
         <EditButtonOption
           key="delete-user"
diff --git a/web/src/pages/users/UsersSharedModals.tsx b/web/src/pages/users/UsersSharedModals.tsx
index 807fea312f..386c6905a7 100644
--- a/web/src/pages/users/UsersSharedModals.tsx
+++ b/web/src/pages/users/UsersSharedModals.tsx
@@ -1,6 +1,7 @@
 import { AddAuthenticationKeyModal } from './shared/modals/AddAuthenticationKeyModal/AddAuthenticationKeyModal';
 import { ChangePasswordModal } from './shared/modals/ChangeUserPasswordModal/ChangeUserPasswordModal';
 import { DeleteUserModal } from './shared/modals/DeleteUserModal/DeleteUserModal';
+import { ToggleUserModal } from './shared/modals/ToggleUserModal/ToggleUserModal';
 
 /***
  * Shared modals for /users and /me
@@ -10,6 +11,7 @@ export const UsersSharedModals = () => {
     <>
       <ChangePasswordModal />
       <DeleteUserModal />
+      <ToggleUserModal />
       <AddAuthenticationKeyModal />
     </>
   );
diff --git a/web/src/pages/users/shared/modals/ToggleUserModal/ToggleUserModal.tsx b/web/src/pages/users/shared/modals/ToggleUserModal/ToggleUserModal.tsx
new file mode 100644
index 0000000000..c3a7937c1f
--- /dev/null
+++ b/web/src/pages/users/shared/modals/ToggleUserModal/ToggleUserModal.tsx
@@ -0,0 +1,97 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNavigate } from 'react-router';
+import { shallow } from 'zustand/shallow';
+
+import { cloneDeep, isUndefined } from 'lodash-es';
+import { useI18nContext } from '../../../../../i18n/i18n-react';
+import { ConfirmModal } from '../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/ConfirmModal';
+import { ConfirmModalType } from '../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/types';
+import { useModalStore } from '../../../../../shared/hooks/store/useModalStore';
+import useApi from '../../../../../shared/hooks/useApi';
+import { useToaster } from '../../../../../shared/hooks/useToaster';
+import { QueryKeys } from '../../../../../shared/queries';
+
+export const ToggleUserModal = () => {
+  const {
+    user: { editUser },
+  } = useApi();
+
+  const navigate = useNavigate();
+  const queryClient = useQueryClient();
+  const { LL } = useI18nContext();
+
+  const [modalState, setModalState] = useModalStore(
+    (state) => [state.toggleUserModal, state.setToggleUserModal],
+    shallow,
+  );
+
+  const toaster = useToaster();
+
+  const { mutate, isLoading } = useMutation(editUser, {
+    onSuccess: (_, variables) => {
+      toaster.success(
+        variables.data.is_active
+          ? LL.modals.enableUser.messages.success({
+              username: variables.username,
+            })
+          : LL.modals.disableUser.messages.success({
+              username: variables.username,
+            }),
+      );
+      queryClient.invalidateQueries([QueryKeys.FETCH_USERS_LIST]);
+      queryClient.invalidateQueries([QueryKeys.FETCH_USER_PROFILE]);
+      setModalState({ visible: false, user: undefined });
+      navigate('/admin/users', { replace: true });
+    },
+    onError: (err) => {
+      toaster.error(LL.messages.error());
+      setModalState({ visible: false, user: undefined });
+      console.error(err);
+    },
+  });
+
+  const toggleUserState = () => {
+    if (!isUndefined(modalState.user)) {
+      const userClone = cloneDeep(modalState.user);
+      userClone.is_active = !userClone?.is_active;
+      mutate({
+        username: userClone.username,
+        data: userClone,
+      });
+    }
+  };
+
+  return (
+    <ConfirmModal
+      isOpen={modalState.visible}
+      setIsOpen={(v: boolean) => setModalState({ visible: v })}
+      type={
+        modalState.user?.is_active ? ConfirmModalType.WARNING : ConfirmModalType.NORMAL
+      }
+      subTitle={
+        modalState.user?.is_active
+          ? LL.modals.disableUser.message({
+              username: modalState.user?.username || '',
+            })
+          : LL.modals.enableUser.message({
+              username: modalState.user?.username || '',
+            })
+      }
+      submitText={
+        modalState.user?.is_active
+          ? LL.modals.disableUser.controls.submit()
+          : LL.modals.enableUser.controls.submit()
+      }
+      title={
+        modalState.user?.is_active
+          ? LL.modals.disableUser.title()
+          : LL.modals.enableUser.title()
+      }
+      cancelText={LL.form.cancel()}
+      onSubmit={() => {
+        toggleUserState();
+      }}
+      loading={isLoading}
+    />
+  );
+};
diff --git a/web/src/shared/hooks/store/useModalStore.ts b/web/src/shared/hooks/store/useModalStore.ts
index 48eca89682..153d17472d 100644
--- a/web/src/shared/hooks/store/useModalStore.ts
+++ b/web/src/shared/hooks/store/useModalStore.ts
@@ -47,6 +47,11 @@ export const useModalStore = createWithEqualityFn<UseModalStore>(
       user: undefined,
     },
     // DO NOT EXTEND THIS STORE
+    toggleUserModal: {
+      visible: false,
+      user: undefined,
+    },
+    // DO NOT EXTEND THIS STORE
     changePasswordModal: {
       visible: false,
       user: undefined,
@@ -131,6 +136,11 @@ export const useModalStore = createWithEqualityFn<UseModalStore>(
         deleteUserModal: { ...state.deleteUserModal, ...data },
       })),
     // DO NOT EXTEND THIS STORE
+    setToggleUserModal: (data) =>
+      set((state) => ({
+        toggleUserModal: { ...state.toggleUserModal, ...data },
+      })),
+    // DO NOT EXTEND THIS STORE
     setProvisionKeyModal: (data) =>
       set((state) => ({
         provisionKeyModal: { ...state.provisionKeyModal, ...data },
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
index 6d91636d2d..2807921d4b 100644
--- a/web/src/shared/types.ts
+++ b/web/src/shared/types.ts
@@ -46,6 +46,7 @@ export type User = {
   groups: string[];
   authorized_apps?: OAuth2AuthorizedApps[];
   is_active: boolean;
+  enrolled: boolean;
 };
 
 export type UserProfile = {
@@ -177,6 +178,11 @@ export interface DeleteUserModal {
   user?: User;
 }
 
+export interface ToggleUserModal {
+  visible: boolean;
+  user?: User;
+}
+
 export interface ProvisionKeyModal {
   visible: boolean;
   user?: User;
@@ -714,6 +720,8 @@ export interface UseModalStore {
   // DO NOT EXTEND THIS STORE
   deleteUserModal: DeleteUserModal;
   // DO NOT EXTEND THIS STORE
+  toggleUserModal: ToggleUserModal;
+  // DO NOT EXTEND THIS STORE
   changePasswordModal: ChangePasswordModal;
   // DO NOT EXTEND THIS STORE
   changeWalletModal: ChangeWalletModal;
@@ -750,6 +758,8 @@ export interface UseModalStore {
   // DO NOT EXTEND THIS STORE
   setDeleteUserModal: ModalSetter<DeleteUserModal>;
   // DO NOT EXTEND THIS STORE
+  setToggleUserModal: ModalSetter<ToggleUserModal>;
+  // DO NOT EXTEND THIS STORE
   setProvisionKeyModal: ModalSetter<ProvisionKeyModal>;
   // DO NOT EXTEND THIS STORE
   setChangePasswordModal: ModalSetter<ChangePasswordModal>;

From 454448c47d93a22d5aa5c203fb838fc466d15153 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Thu, 6 Jun 2024 12:51:54 +0200
Subject: [PATCH 05/34] sqlx prepare

---
 ...72929d6a612846acf4a5489c36a04cb57de59.json | 22 ----------
 ...61d90dd3786e69ab3eddac7e143b023b5a5de.json | 44 +++++++++++++++++++
 ...07069048ba01f8c3c5988f044e788200f9f8.json} | 10 ++++-
 ...ab52c14293fa64d260db812c89ed43a5a7cc8.json | 15 -------
 ...e490fea37010d98f3eff2ba15f50e8a6d7a9.json} | 20 ++++++---
 ...ebfbeb42e6a4949a10b2f23ea4d968aeb736.json} |  5 ++-
 ...dfb15404030a4f28406cbaa344ab279300d7.json} |  4 +-
 ...71f6ccc4f5ecc7ec32f0b3a9f64398f3686e.json} | 20 ++++++---
 ...70c93c46740cc778825583e060cdee73a212.json} | 10 ++++-
 ...117ae77dd226c42ee175544f0ba5eccb315d.json} | 10 ++++-
 ...a49107df5e8d1452796c0bb4f2f657e2ec77.json} |  4 +-
 ...6c9dd358479e5791b672369d3c0496d51269.json} |  5 ++-
 ...0a6dfaf336b5416dafb8d634b521d3dce8d3.json} | 10 ++++-
 ...fc196d59c356d69c0d06e9782cb1bc73e6f3.json} | 10 ++++-
 ...80d70a714e7a02f15e8a1d7b165814cf85d1.json} | 12 +++--
 15 files changed, 129 insertions(+), 72 deletions(-)
 delete mode 100644 .sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
 create mode 100644 .sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json
 rename .sqlx/{query-8389fe286dc72ef17a37015b9de4a0b53c03359b79b57106c14ac819ddf333df.json => query-1b29a6b1d3741ede2d85271f0f0e07069048ba01f8c3c5988f044e788200f9f8.json} (89%)
 delete mode 100644 .sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json
 rename .sqlx/{query-e8b8eec04b6b8d93aa508344c1a84adf68be8946253f9b42599f55c2a58646ad.json => query-2b9b3aa210fa00d43bece1acbd3ae490fea37010d98f3eff2ba15f50e8a6d7a9.json} (84%)
 rename .sqlx/{query-30342cf1a832d4ecffd1f77b6ca3d936a05fa60bae4a903ef7d2d37121b177d8.json => query-3063672b53bf0bada26d6d1b0adbebfbeb42e6a4949a10b2f23ea4d968aeb736.json} (71%)
 rename .sqlx/{query-fdbb9308a58ade3fd1cba272fe3979ed4bafb1ee079fd8b23ac9c2ef95db2312.json => query-393cacddf87582e0a72156580765dfb15404030a4f28406cbaa344ab279300d7.json} (83%)
 rename .sqlx/{query-f7ca16ebd3aae0812c44eca690638f235ad660495ec529dd79d97c38f3336174.json => query-43ac4b3a375a4756d77b015c0bb871f6ccc4f5ecc7ec32f0b3a9f64398f3686e.json} (85%)
 rename .sqlx/{query-96af08182e72ed1798480d4bba8063c7c185be78e72b633208a3ef2436e373b1.json => query-52c659862cef5cd45bbe7bdc58cf70c93c46740cc778825583e060cdee73a212.json} (86%)
 rename .sqlx/{query-50e79d640f2c5c55a78f125e11e31e5cf05383f2fe8eabbc8ce3af668152f3d7.json => query-6fc45edc38cf3f590daec8f930a4117ae77dd226c42ee175544f0ba5eccb315d.json} (89%)
 rename .sqlx/{query-caf97c3a058eac0f9deb4e474b0d76a2d14a75d04f4ea5474e06adc9466a544d.json => query-b773bf99f9e3aafcade8bf57c6f9a49107df5e8d1452796c0bb4f2f657e2ec77.json} (75%)
 rename .sqlx/{query-84ed9302c404a5e6b056988b345d8e3d6e2cb2b3df71dc5ead7d03f22ba46683.json => query-c9773715f70d267a2bc1e23b86d06c9dd358479e5791b672369d3c0496d51269.json} (75%)
 rename .sqlx/{query-467f699c41d75683c6e07b8367cf2899b47213b9b4c301898d548ce643516a49.json => query-e253723a52a1632fd190ad5404cc0a6dfaf336b5416dafb8d634b521d3dce8d3.json} (83%)
 rename .sqlx/{query-e2b78727c2ce57810cc9631a353e226b7dd03ad5917ac35a497d86977835aa4a.json => query-f5ed6899054f1915882741843733fc196d59c356d69c0d06e9782cb1bc73e6f3.json} (89%)
 rename .sqlx/{query-cbe6cdf1b9dd1d13bbb460726e33001ee45dea8486fb02e8c375d7b513ac0d6d.json => query-fd950a860fe104136816e1605ed080d70a714e7a02f15e8a1d7b165814cf85d1.json} (80%)

diff --git a/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json b/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
deleted file mode 100644
index ce67e42930..0000000000
--- a/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
-  "db_name": "PostgreSQL",
-  "query": "SELECT recovery_codes FROM \"user\" WHERE id = $1",
-  "describe": {
-    "columns": [
-      {
-        "ordinal": 0,
-        "name": "recovery_codes",
-        "type_info": "TextArray"
-      }
-    ],
-    "parameters": {
-      "Left": [
-        "Int8"
-      ]
-    },
-    "nullable": [
-      false
-    ]
-  },
-  "hash": "0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59"
-}
diff --git a/.sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json b/.sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json
new file mode 100644
index 0000000000..4a6d8f9e4c
--- /dev/null
+++ b/.sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json
@@ -0,0 +1,44 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id WHERE u.is_active = true ORDER BY d.id ASC",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id?",
+        "type_info": "Int8"
+      },
+      {
+        "ordinal": 1,
+        "name": "name",
+        "type_info": "Text"
+      },
+      {
+        "ordinal": 2,
+        "name": "wireguard_pubkey",
+        "type_info": "Text"
+      },
+      {
+        "ordinal": 3,
+        "name": "user_id",
+        "type_info": "Int8"
+      },
+      {
+        "ordinal": 4,
+        "name": "created",
+        "type_info": "Timestamp"
+      }
+    ],
+    "parameters": {
+      "Left": []
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de"
+}
diff --git a/.sqlx/query-8389fe286dc72ef17a37015b9de4a0b53c03359b79b57106c14ac819ddf333df.json b/.sqlx/query-1b29a6b1d3741ede2d85271f0f0e07069048ba01f8c3c5988f044e788200f9f8.json
similarity index 89%
rename from .sqlx/query-8389fe286dc72ef17a37015b9de4a0b53c03359b79b57106c14ac819ddf333df.json
rename to .sqlx/query-1b29a6b1d3741ede2d85271f0f0e07069048ba01f8c3c5988f044e788200f9f8.json
index d85df17816..ee2196861b 100644
--- a/.sqlx/query-8389fe286dc72ef17a37015b9de4a0b53c03359b79b57106c14ac819ddf333df.json
+++ b/.sqlx/query-1b29a6b1d3741ede2d85271f0f0e07069048ba01f8c3c5988f044e788200f9f8.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\" WHERE email = $1",
+  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active FROM \"user\" WHERE username = $1",
   "describe": {
     "columns": [
       {
@@ -85,6 +85,11 @@
         "ordinal": 13,
         "name": "recovery_codes",
         "type_info": "TextArray"
+      },
+      {
+        "ordinal": 14,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -106,8 +111,9 @@
       true,
       true,
       false,
+      false,
       false
     ]
   },
-  "hash": "8389fe286dc72ef17a37015b9de4a0b53c03359b79b57106c14ac819ddf333df"
+  "hash": "1b29a6b1d3741ede2d85271f0f0e07069048ba01f8c3c5988f044e788200f9f8"
 }
diff --git a/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json b/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json
deleted file mode 100644
index 8d314a5faa..0000000000
--- a/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "db_name": "PostgreSQL",
-  "query": "UPDATE session SET expires = $1 WHERE id = $2",
-  "describe": {
-    "columns": [],
-    "parameters": {
-      "Left": [
-        "Timestamp",
-        "Text"
-      ]
-    },
-    "nullable": []
-  },
-  "hash": "1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8"
-}
diff --git a/.sqlx/query-e8b8eec04b6b8d93aa508344c1a84adf68be8946253f9b42599f55c2a58646ad.json b/.sqlx/query-2b9b3aa210fa00d43bece1acbd3ae490fea37010d98f3eff2ba15f50e8a6d7a9.json
similarity index 84%
rename from .sqlx/query-e8b8eec04b6b8d93aa508344c1a84adf68be8946253f9b42599f55c2a58646ad.json
rename to .sqlx/query-2b9b3aa210fa00d43bece1acbd3ae490fea37010d98f3eff2ba15f50e8a6d7a9.json
index f09d62528c..c3182f082b 100644
--- a/.sqlx/query-e8b8eec04b6b8d93aa508344c1a84adf68be8946253f9b42599f55c2a58646ad.json
+++ b/.sqlx/query-2b9b3aa210fa00d43bece1acbd3ae490fea37010d98f3eff2ba15f50e8a6d7a9.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id \"id?\", \"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\" \"mfa_method: _\",\"recovery_codes\" \"recovery_codes: _\" FROM \"user\" WHERE id = $1",
+  "query": "SELECT id \"id?\", \"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"is_active\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\" \"mfa_method: _\",\"recovery_codes\" \"recovery_codes: _\" FROM \"user\" WHERE id = $1",
   "describe": {
     "columns": [
       {
@@ -45,26 +45,31 @@
       },
       {
         "ordinal": 8,
-        "name": "totp_enabled",
+        "name": "is_active",
         "type_info": "Bool"
       },
       {
         "ordinal": 9,
-        "name": "email_mfa_enabled",
+        "name": "totp_enabled",
         "type_info": "Bool"
       },
       {
         "ordinal": 10,
+        "name": "email_mfa_enabled",
+        "type_info": "Bool"
+      },
+      {
+        "ordinal": 11,
         "name": "totp_secret",
         "type_info": "Bytea"
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
         "name": "email_mfa_secret",
         "type_info": "Bytea"
       },
       {
-        "ordinal": 12,
+        "ordinal": 13,
         "name": "mfa_method: _",
         "type_info": {
           "Custom": {
@@ -82,7 +87,7 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 14,
         "name": "recovery_codes: _",
         "type_info": "TextArray"
       }
@@ -103,11 +108,12 @@
       false,
       false,
       false,
+      false,
       true,
       true,
       false,
       false
     ]
   },
-  "hash": "e8b8eec04b6b8d93aa508344c1a84adf68be8946253f9b42599f55c2a58646ad"
+  "hash": "2b9b3aa210fa00d43bece1acbd3ae490fea37010d98f3eff2ba15f50e8a6d7a9"
 }
diff --git a/.sqlx/query-30342cf1a832d4ecffd1f77b6ca3d936a05fa60bae4a903ef7d2d37121b177d8.json b/.sqlx/query-3063672b53bf0bada26d6d1b0adbebfbeb42e6a4949a10b2f23ea4d968aeb736.json
similarity index 71%
rename from .sqlx/query-30342cf1a832d4ecffd1f77b6ca3d936a05fa60bae4a903ef7d2d37121b177d8.json
rename to .sqlx/query-3063672b53bf0bada26d6d1b0adbebfbeb42e6a4949a10b2f23ea4d968aeb736.json
index 6f43c0d659..e4f3bf2de0 100644
--- a/.sqlx/query-30342cf1a832d4ecffd1f77b6ca3d936a05fa60bae4a903ef7d2d37121b177d8.json
+++ b/.sqlx/query-3063672b53bf0bada26d6d1b0adbebfbeb42e6a4949a10b2f23ea4d968aeb736.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "UPDATE \"user\" SET \"username\" = $2,\"password_hash\" = $3,\"last_name\" = $4,\"first_name\" = $5,\"email\" = $6,\"phone\" = $7,\"mfa_enabled\" = $8,\"totp_enabled\" = $9,\"email_mfa_enabled\" = $10,\"totp_secret\" = $11,\"email_mfa_secret\" = $12,\"mfa_method\" = $13,\"recovery_codes\" = $14 WHERE id = $1",
+  "query": "UPDATE \"user\" SET \"username\" = $2,\"password_hash\" = $3,\"last_name\" = $4,\"first_name\" = $5,\"email\" = $6,\"phone\" = $7,\"mfa_enabled\" = $8,\"is_active\" = $9,\"totp_enabled\" = $10,\"email_mfa_enabled\" = $11,\"totp_secret\" = $12,\"email_mfa_secret\" = $13,\"mfa_method\" = $14,\"recovery_codes\" = $15 WHERE id = $1",
   "describe": {
     "columns": [],
     "parameters": {
@@ -15,6 +15,7 @@
         "Bool",
         "Bool",
         "Bool",
+        "Bool",
         "Bytea",
         "Bytea",
         {
@@ -36,5 +37,5 @@
     },
     "nullable": []
   },
-  "hash": "30342cf1a832d4ecffd1f77b6ca3d936a05fa60bae4a903ef7d2d37121b177d8"
+  "hash": "3063672b53bf0bada26d6d1b0adbebfbeb42e6a4949a10b2f23ea4d968aeb736"
 }
diff --git a/.sqlx/query-fdbb9308a58ade3fd1cba272fe3979ed4bafb1ee079fd8b23ac9c2ef95db2312.json b/.sqlx/query-393cacddf87582e0a72156580765dfb15404030a4f28406cbaa344ab279300d7.json
similarity index 83%
rename from .sqlx/query-fdbb9308a58ade3fd1cba272fe3979ed4bafb1ee079fd8b23ac9c2ef95db2312.json
rename to .sqlx/query-393cacddf87582e0a72156580765dfb15404030a4f28406cbaa344ab279300d7.json
index dd54ba7fc4..6fa96fee85 100644
--- a/.sqlx/query-fdbb9308a58ade3fd1cba272fe3979ed4bafb1ee079fd8b23ac9c2ef95db2312.json
+++ b/.sqlx/query-393cacddf87582e0a72156580765dfb15404030a4f28406cbaa344ab279300d7.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id JOIN group_user gu ON u.id = gu.user_id JOIN \"group\" g ON gu.group_id = g.id WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[]))\n                    ORDER BY d.id ASC",
+  "query": "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id JOIN group_user gu ON u.id = gu.user_id JOIN \"group\" g ON gu.group_id = g.id WHERE g.\"name\" IN (SELECT * FROM UNNEST($1::text[]))\n                    AND u.is_active = true\n                    ORDER BY d.id ASC",
   "describe": {
     "columns": [
       {
@@ -42,5 +42,5 @@
       false
     ]
   },
-  "hash": "fdbb9308a58ade3fd1cba272fe3979ed4bafb1ee079fd8b23ac9c2ef95db2312"
+  "hash": "393cacddf87582e0a72156580765dfb15404030a4f28406cbaa344ab279300d7"
 }
diff --git a/.sqlx/query-f7ca16ebd3aae0812c44eca690638f235ad660495ec529dd79d97c38f3336174.json b/.sqlx/query-43ac4b3a375a4756d77b015c0bb871f6ccc4f5ecc7ec32f0b3a9f64398f3686e.json
similarity index 85%
rename from .sqlx/query-f7ca16ebd3aae0812c44eca690638f235ad660495ec529dd79d97c38f3336174.json
rename to .sqlx/query-43ac4b3a375a4756d77b015c0bb871f6ccc4f5ecc7ec32f0b3a9f64398f3686e.json
index 8deb500b49..0e0a82ef1b 100644
--- a/.sqlx/query-f7ca16ebd3aae0812c44eca690638f235ad660495ec529dd79d97c38f3336174.json
+++ b/.sqlx/query-43ac4b3a375a4756d77b015c0bb871f6ccc4f5ecc7ec32f0b3a9f64398f3686e.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id \"id?\", \"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\" \"mfa_method: _\",\"recovery_codes\" \"recovery_codes: _\" FROM \"user\"",
+  "query": "SELECT id \"id?\", \"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"is_active\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\" \"mfa_method: _\",\"recovery_codes\" \"recovery_codes: _\" FROM \"user\"",
   "describe": {
     "columns": [
       {
@@ -45,26 +45,31 @@
       },
       {
         "ordinal": 8,
-        "name": "totp_enabled",
+        "name": "is_active",
         "type_info": "Bool"
       },
       {
         "ordinal": 9,
-        "name": "email_mfa_enabled",
+        "name": "totp_enabled",
         "type_info": "Bool"
       },
       {
         "ordinal": 10,
+        "name": "email_mfa_enabled",
+        "type_info": "Bool"
+      },
+      {
+        "ordinal": 11,
         "name": "totp_secret",
         "type_info": "Bytea"
       },
       {
-        "ordinal": 11,
+        "ordinal": 12,
         "name": "email_mfa_secret",
         "type_info": "Bytea"
       },
       {
-        "ordinal": 12,
+        "ordinal": 13,
         "name": "mfa_method: _",
         "type_info": {
           "Custom": {
@@ -82,7 +87,7 @@
         }
       },
       {
-        "ordinal": 13,
+        "ordinal": 14,
         "name": "recovery_codes: _",
         "type_info": "TextArray"
       }
@@ -101,11 +106,12 @@
       false,
       false,
       false,
+      false,
       true,
       true,
       false,
       false
     ]
   },
-  "hash": "f7ca16ebd3aae0812c44eca690638f235ad660495ec529dd79d97c38f3336174"
+  "hash": "43ac4b3a375a4756d77b015c0bb871f6ccc4f5ecc7ec32f0b3a9f64398f3686e"
 }
diff --git a/.sqlx/query-96af08182e72ed1798480d4bba8063c7c185be78e72b633208a3ef2436e373b1.json b/.sqlx/query-52c659862cef5cd45bbe7bdc58cf70c93c46740cc778825583e060cdee73a212.json
similarity index 86%
rename from .sqlx/query-96af08182e72ed1798480d4bba8063c7c185be78e72b633208a3ef2436e373b1.json
rename to .sqlx/query-52c659862cef5cd45bbe7bdc58cf70c93c46740cc778825583e060cdee73a212.json
index 6a61f1a3a6..72a1d6e0f5 100644
--- a/.sqlx/query-96af08182e72ed1798480d4bba8063c7c185be78e72b633208a3ef2436e373b1.json
+++ b/.sqlx/query-52c659862cef5cd45bbe7bdc58cf70c93c46740cc778825583e060cdee73a212.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\" JOIN group_user ON \"user\".id = group_user.user_id WHERE group_user.group_id = $1",
+  "query": "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active FROM \"user\" JOIN group_user ON \"user\".id = group_user.user_id WHERE group_user.group_id = $1",
   "describe": {
     "columns": [
       {
@@ -85,6 +85,11 @@
         "ordinal": 13,
         "name": "recovery_codes",
         "type_info": "TextArray"
+      },
+      {
+        "ordinal": 14,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -106,8 +111,9 @@
       false,
       true,
       false,
+      false,
       false
     ]
   },
-  "hash": "96af08182e72ed1798480d4bba8063c7c185be78e72b633208a3ef2436e373b1"
+  "hash": "52c659862cef5cd45bbe7bdc58cf70c93c46740cc778825583e060cdee73a212"
 }
diff --git a/.sqlx/query-50e79d640f2c5c55a78f125e11e31e5cf05383f2fe8eabbc8ce3af668152f3d7.json b/.sqlx/query-6fc45edc38cf3f590daec8f930a4117ae77dd226c42ee175544f0ba5eccb315d.json
similarity index 89%
rename from .sqlx/query-50e79d640f2c5c55a78f125e11e31e5cf05383f2fe8eabbc8ce3af668152f3d7.json
rename to .sqlx/query-6fc45edc38cf3f590daec8f930a4117ae77dd226c42ee175544f0ba5eccb315d.json
index cfeeb2f163..256dd6ac1c 100644
--- a/.sqlx/query-50e79d640f2c5c55a78f125e11e31e5cf05383f2fe8eabbc8ce3af668152f3d7.json
+++ b/.sqlx/query-6fc45edc38cf3f590daec8f930a4117ae77dd226c42ee175544f0ba5eccb315d.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\" WHERE username = $1",
+  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active FROM \"user\" WHERE email = $1",
   "describe": {
     "columns": [
       {
@@ -85,6 +85,11 @@
         "ordinal": 13,
         "name": "recovery_codes",
         "type_info": "TextArray"
+      },
+      {
+        "ordinal": 14,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -106,8 +111,9 @@
       true,
       true,
       false,
+      false,
       false
     ]
   },
-  "hash": "50e79d640f2c5c55a78f125e11e31e5cf05383f2fe8eabbc8ce3af668152f3d7"
+  "hash": "6fc45edc38cf3f590daec8f930a4117ae77dd226c42ee175544f0ba5eccb315d"
 }
diff --git a/.sqlx/query-caf97c3a058eac0f9deb4e474b0d76a2d14a75d04f4ea5474e06adc9466a544d.json b/.sqlx/query-b773bf99f9e3aafcade8bf57c6f9a49107df5e8d1452796c0bb4f2f657e2ec77.json
similarity index 75%
rename from .sqlx/query-caf97c3a058eac0f9deb4e474b0d76a2d14a75d04f4ea5474e06adc9466a544d.json
rename to .sqlx/query-b773bf99f9e3aafcade8bf57c6f9a49107df5e8d1452796c0bb4f2f657e2ec77.json
index 72c3f6bd63..3c480bc095 100644
--- a/.sqlx/query-caf97c3a058eac0f9deb4e474b0d76a2d14a75d04f4ea5474e06adc9466a544d.json
+++ b/.sqlx/query-b773bf99f9e3aafcade8bf57c6f9a49107df5e8d1452796c0bb4f2f657e2ec77.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT d.wireguard_pubkey as pubkey, preshared_key, array[host(wnd.wireguard_ip)] as \"allowed_ips!: Vec<String>\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) ORDER BY d.id ASC",
+  "query": "SELECT d.wireguard_pubkey as pubkey, preshared_key, array[host(wnd.wireguard_ip)] as \"allowed_ips!: Vec<String>\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id JOIN \"user\" u ON d.user_id = u.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) AND u.is_active = true ORDER BY d.id ASC",
   "describe": {
     "columns": [
       {
@@ -31,5 +31,5 @@
       null
     ]
   },
-  "hash": "caf97c3a058eac0f9deb4e474b0d76a2d14a75d04f4ea5474e06adc9466a544d"
+  "hash": "b773bf99f9e3aafcade8bf57c6f9a49107df5e8d1452796c0bb4f2f657e2ec77"
 }
diff --git a/.sqlx/query-84ed9302c404a5e6b056988b345d8e3d6e2cb2b3df71dc5ead7d03f22ba46683.json b/.sqlx/query-c9773715f70d267a2bc1e23b86d06c9dd358479e5791b672369d3c0496d51269.json
similarity index 75%
rename from .sqlx/query-84ed9302c404a5e6b056988b345d8e3d6e2cb2b3df71dc5ead7d03f22ba46683.json
rename to .sqlx/query-c9773715f70d267a2bc1e23b86d06c9dd358479e5791b672369d3c0496d51269.json
index a959958ca1..7111120273 100644
--- a/.sqlx/query-84ed9302c404a5e6b056988b345d8e3d6e2cb2b3df71dc5ead7d03f22ba46683.json
+++ b/.sqlx/query-c9773715f70d267a2bc1e23b86d06c9dd358479e5791b672369d3c0496d51269.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "INSERT INTO \"user\" (\"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\",\"recovery_codes\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id",
+  "query": "INSERT INTO \"user\" (\"username\",\"password_hash\",\"last_name\",\"first_name\",\"email\",\"phone\",\"mfa_enabled\",\"is_active\",\"totp_enabled\",\"email_mfa_enabled\",\"totp_secret\",\"email_mfa_secret\",\"mfa_method\",\"recovery_codes\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id",
   "describe": {
     "columns": [
       {
@@ -20,6 +20,7 @@
         "Bool",
         "Bool",
         "Bool",
+        "Bool",
         "Bytea",
         "Bytea",
         {
@@ -43,5 +44,5 @@
       false
     ]
   },
-  "hash": "84ed9302c404a5e6b056988b345d8e3d6e2cb2b3df71dc5ead7d03f22ba46683"
+  "hash": "c9773715f70d267a2bc1e23b86d06c9dd358479e5791b672369d3c0496d51269"
 }
diff --git a/.sqlx/query-467f699c41d75683c6e07b8367cf2899b47213b9b4c301898d548ce643516a49.json b/.sqlx/query-e253723a52a1632fd190ad5404cc0a6dfaf336b5416dafb8d634b521d3dce8d3.json
similarity index 83%
rename from .sqlx/query-467f699c41d75683c6e07b8367cf2899b47213b9b4c301898d548ce643516a49.json
rename to .sqlx/query-e253723a52a1632fd190ad5404cc0a6dfaf336b5416dafb8d634b521d3dce8d3.json
index 2dee9d17f3..98488e7959 100644
--- a/.sqlx/query-467f699c41d75683c6e07b8367cf2899b47213b9b4c301898d548ce643516a49.json
+++ b/.sqlx/query-e253723a52a1632fd190ad5404cc0a6dfaf336b5416dafb8d634b521d3dce8d3.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\"\n            INNER JOIN \"group_user\" ON \"user\".id = \"group_user\".user_id\n            INNER JOIN \"group\" ON \"group_user\".group_id = \"group\".id\n            WHERE \"group\".name = $1",
+  "query": "SELECT \"user\".id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, totp_secret, email_mfa_enabled, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active FROM \"user\"\n            INNER JOIN \"group_user\" ON \"user\".id = \"group_user\".user_id\n            INNER JOIN \"group\" ON \"group_user\".group_id = \"group\".id\n            WHERE \"group\".name = $1",
   "describe": {
     "columns": [
       {
@@ -85,6 +85,11 @@
         "ordinal": 13,
         "name": "recovery_codes",
         "type_info": "TextArray"
+      },
+      {
+        "ordinal": 14,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -106,8 +111,9 @@
       false,
       true,
       false,
+      false,
       false
     ]
   },
-  "hash": "467f699c41d75683c6e07b8367cf2899b47213b9b4c301898d548ce643516a49"
+  "hash": "e253723a52a1632fd190ad5404cc0a6dfaf336b5416dafb8d634b521d3dce8d3"
 }
diff --git a/.sqlx/query-e2b78727c2ce57810cc9631a353e226b7dd03ad5917ac35a497d86977835aa4a.json b/.sqlx/query-f5ed6899054f1915882741843733fc196d59c356d69c0d06e9782cb1bc73e6f3.json
similarity index 89%
rename from .sqlx/query-e2b78727c2ce57810cc9631a353e226b7dd03ad5917ac35a497d86977835aa4a.json
rename to .sqlx/query-f5ed6899054f1915882741843733fc196d59c356d69c0d06e9782cb1bc73e6f3.json
index 7930f4a4c1..d4dfe1b6b1 100644
--- a/.sqlx/query-e2b78727c2ce57810cc9631a353e226b7dd03ad5917ac35a497d86977835aa4a.json
+++ b/.sqlx/query-f5ed6899054f1915882741843733fc196d59c356d69c0d06e9782cb1bc73e6f3.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes FROM \"user\" WHERE id = ANY($1)",
+  "query": "SELECT id \"id?\", username, password_hash, last_name, first_name, email, phone, mfa_enabled, totp_enabled, email_mfa_enabled, totp_secret, email_mfa_secret, mfa_method \"mfa_method: _\", recovery_codes, is_active FROM \"user\" WHERE id = ANY($1)",
   "describe": {
     "columns": [
       {
@@ -85,6 +85,11 @@
         "ordinal": 13,
         "name": "recovery_codes",
         "type_info": "TextArray"
+      },
+      {
+        "ordinal": 14,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -106,8 +111,9 @@
       true,
       true,
       false,
+      false,
       false
     ]
   },
-  "hash": "e2b78727c2ce57810cc9631a353e226b7dd03ad5917ac35a497d86977835aa4a"
+  "hash": "f5ed6899054f1915882741843733fc196d59c356d69c0d06e9782cb1bc73e6f3"
 }
diff --git a/.sqlx/query-cbe6cdf1b9dd1d13bbb460726e33001ee45dea8486fb02e8c375d7b513ac0d6d.json b/.sqlx/query-fd950a860fe104136816e1605ed080d70a714e7a02f15e8a1d7b165814cf85d1.json
similarity index 80%
rename from .sqlx/query-cbe6cdf1b9dd1d13bbb460726e33001ee45dea8486fb02e8c375d7b513ac0d6d.json
rename to .sqlx/query-fd950a860fe104136816e1605ed080d70a714e7a02f15e8a1d7b165814cf85d1.json
index 26232ef8b7..07b07f5449 100644
--- a/.sqlx/query-cbe6cdf1b9dd1d13bbb460726e33001ee45dea8486fb02e8c375d7b513ac0d6d.json
+++ b/.sqlx/query-fd950a860fe104136816e1605ed080d70a714e7a02f15e8a1d7b165814cf85d1.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, mfa_method as \"mfa_method: MFAMethod\", password_hash FROM \"user\"",
+  "query": "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, mfa_method as \"mfa_method: MFAMethod\", password_hash, is_active FROM \"user\"",
   "describe": {
     "columns": [
       {
@@ -45,6 +45,11 @@
         "ordinal": 5,
         "name": "password_hash",
         "type_info": "Text"
+      },
+      {
+        "ordinal": 6,
+        "name": "is_active",
+        "type_info": "Bool"
       }
     ],
     "parameters": {
@@ -56,8 +61,9 @@
       false,
       false,
       false,
-      true
+      true,
+      false
     ]
   },
-  "hash": "cbe6cdf1b9dd1d13bbb460726e33001ee45dea8486fb02e8c375d7b513ac0d6d"
+  "hash": "fd950a860fe104136816e1605ed080d70a714e7a02f15e8a1d7b165814cf85d1"
 }

From 4fcc853c90e0059b64f41fe69e14aa486e2dd5a0 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Thu, 6 Jun 2024 14:28:49 +0200
Subject: [PATCH 06/34] prevent disabled users from starting the enrollment

---
 src/grpc/enrollment.rs | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs
index f0304b11fd..15ad856181 100644
--- a/src/grpc/enrollment.rs
+++ b/src/grpc/enrollment.rs
@@ -129,6 +129,11 @@ impl EnrollmentServer {
             let user = enrollment.fetch_user(&self.pool).await?;
             let admin = enrollment.fetch_admin(&self.pool).await?;
 
+            if !user.is_active {
+                debug!("User {} tried to enroll, but is disabled.", user.username);
+                return Err(Status::permission_denied("user is disabled"));
+            };
+
             let mut transaction = self.pool.begin().await.map_err(|_| {
                 error!("Failed to begin transaction");
                 Status::internal("unexpected error")

From 5d44f3d7156754d753551924a6358d0e6ea93eac Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 09:05:10 +0200
Subject: [PATCH 07/34] set active by default

---
 src/db/models/user.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index 6f1f3d3d40..cb6cc558c4 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -118,7 +118,7 @@ impl User {
             email_mfa_secret: None,
             mfa_method: MFAMethod::None,
             recovery_codes: Vec::new(),
-            is_active: false,
+            is_active: true,
         }
     }
 

From 8bb70edcf3226079dbe8ba0dab859a6f3126b96b Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 09:33:07 +0200
Subject: [PATCH 08/34] prevent starting desktop configuration for disabled
 users

---
 src/db/models/enrollment.rs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs
index 36a3658ce7..4ad003f646 100644
--- a/src/db/models/enrollment.rs
+++ b/src/db/models/enrollment.rs
@@ -461,6 +461,10 @@ impl User {
         let user_id = self.id.expect("User without ID");
         let admin_id = admin.id.expect("Admin user without ID");
 
+        if !self.is_active {
+            return Err(TokenError::UserDisabled);
+        }
+
         self.clear_unused_enrollment_tokens(&mut *transaction)
             .await?;
 

From 0a2928775e7b09520881f0dcdea7e22a120d8a77 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:25:44 +0200
Subject: [PATCH 09/34] logout disabled users

---
 src/db/models/mod.rs     |  7 ++++++-
 src/db/models/session.rs | 10 ++++++++++
 src/db/models/user.rs    | 14 ++++++++++++++
 3 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index edac80e9c8..04a3d2c9ca 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -112,7 +112,12 @@ impl UserInfo {
     ) -> Result<bool, SqlxError> {
         if self.is_active != user.is_active {
             user.is_active = self.is_active;
-            user.save(transaction).await?;
+            user.save(&mut *transaction).await?;
+
+            if !user.is_active {
+                user.logout_all_sessions(&mut *transaction).await?;
+            }
+
             Ok(true)
         } else {
             Ok(false)
diff --git a/src/db/models/session.rs b/src/db/models/session.rs
index 79a9062096..db80fcbb68 100644
--- a/src/db/models/session.rs
+++ b/src/db/models/session.rs
@@ -192,4 +192,14 @@ impl Session {
             .await?;
         Ok(())
     }
+
+    pub async fn delete_all_for_user<'e, E>(executor: E, user_id: i64) -> Result<(), SqlxError>
+    where
+        E: PgExecutor<'e>,
+    {
+        query!("DELETE FROM session WHERE user_id = $1", user_id)
+            .execute(executor)
+            .await?;
+        Ok(())
+    }
 }
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index cb6cc558c4..e802b8ec6c 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -21,6 +21,7 @@ use super::{
 };
 use crate::{
     auth::TOTP_CODE_VALIDITY_PERIOD,
+    db::Session,
     error::WebError,
     random::{gen_alphanumeric, gen_totp_secret},
     server_config,
@@ -797,6 +798,19 @@ impl User {
 
         Ok(())
     }
+
+    pub async fn logout_all_sessions<'e, E>(&self, executor: E) -> Result<(), SqlxError>
+    where
+        E: PgExecutor<'e>,
+    {
+        Session::delete_all_for_user(
+            executor,
+            self.id
+                .expect("User ID must be set to log out all sessions"),
+        )
+        .await?;
+        Ok(())
+    }
 }
 
 #[cfg(test)]

From 30637d2481e60a9270fa300d31eb4900d9885934 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:31:16 +0200
Subject: [PATCH 10/34] Apply suggestions from code review

Co-authored-by: Jacek Chmielewski <jchmielewski@teonite.com>
---
 src/grpc/enrollment.rs                                        | 4 ++--
 src/handlers/wireguard.rs                                     | 2 +-
 web/src/i18n/en/index.ts                                      | 4 ++--
 web/src/pages/users/UserProfile/UserProfile.tsx               | 2 +-
 .../components/UserEditButton/UserEditButton.tsx              | 2 +-
 5 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs
index 15ad856181..112ba3a865 100644
--- a/src/grpc/enrollment.rs
+++ b/src/grpc/enrollment.rs
@@ -130,7 +130,7 @@ impl EnrollmentServer {
             let admin = enrollment.fetch_admin(&self.pool).await?;
 
             if !user.is_active {
-                debug!("User {} tried to enroll, but is disabled.", user.username);
+                warn!("Can't start enrollment for disabled user {}", user.username);
                 return Err(Status::permission_denied("user is disabled"));
             };
 
@@ -223,7 +223,7 @@ impl EnrollmentServer {
         }
 
         if !user.is_active {
-            error!("User {} is disabled", user.username);
+            warn!("Can't finalize enrollment for disabled user {}", user.username);
             return Err(Status::invalid_argument("user is disabled"));
         }
 
diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs
index 7dc9aa146f..f7c1dfa602 100644
--- a/src/handlers/wireguard.rs
+++ b/src/handlers/wireguard.rs
@@ -461,7 +461,7 @@ pub async fn add_device(
 
     // Let admins manage devices for disabled users
     if !user.is_active && !session.is_admin {
-        debug!(
+        info!(
             "User {} tried to add a device for a disabled user {username}",
             session.user.username
         );
diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts
index c54161b12f..0b339e454f 100644
--- a/web/src/i18n/en/index.ts
+++ b/web/src/i18n/en/index.ts
@@ -278,7 +278,7 @@ const en: BaseTranslation = {
       controls: {
         submit: 'Disable account',
       },
-      message: 'Do you want to disable {username: string} account ?',
+      message: 'Do you want to disable {username: string} account?',
       messages: {
         success: '{username: string} disabled.',
       },
@@ -288,7 +288,7 @@ const en: BaseTranslation = {
       controls: {
         submit: 'Enable account',
       },
-      message: 'Do you want to enable {username: string} account ?',
+      message: 'Do you want to enable {username: string} account?',
       messages: {
         success: '{username: string} enabled.',
       },
diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx
index 86211c3d12..beb802a7d0 100644
--- a/web/src/pages/users/UserProfile/UserProfile.tsx
+++ b/web/src/pages/users/UserProfile/UserProfile.tsx
@@ -187,7 +187,7 @@ const EditModeControls = () => {
               onClick={handleDeleteUser}
             />
             <EditButtonOption
-              data-testid="user-edit-disable-acccount"
+              data-testid="user-edit-disable-account"
               text={
                 userProfile?.user?.is_active
                   ? LL.modals.disableUser.title()
diff --git a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
index 947d0b6510..7b7ab3fe40 100644
--- a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
+++ b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
@@ -80,7 +80,7 @@ export const UserEditButton = ({ user }: Props) => {
           }
         />
       )}
-      {user.enrolled === true && user.is_active && (
+      {user.enrolled && user.is_active && (
         <EditButtonOption
           disabled={!networkPresent}
           key="start-dekstop-activation"

From b0ef46c3c0ea42aa6faa0de9d92c2c778ef00f16 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:37:08 +0200
Subject: [PATCH 11/34] remove distinct on

---
 src/db/models/wireguard.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs
index d85c17f213..5721cb32ee 100644
--- a/src/db/models/wireguard.rs
+++ b/src/db/models/wireguard.rs
@@ -339,7 +339,7 @@ impl WireguardNetwork {
             None => {
                 query_as!(
                     Device,
-                    "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created \
+                    "SELECT d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created \
                     FROM device d \
                     JOIN \"user\" u ON d.user_id = u.id \
                     WHERE u.is_active = true \

From 56d04be3aed30b25e8130165309c4d6db8ea2bbd Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:37:33 +0200
Subject: [PATCH 12/34] logout, then change status

---
 src/db/models/mod.rs | 9 ++++-----
 1 file changed, 4 insertions(+), 5 deletions(-)

diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index 04a3d2c9ca..fe7b8d4dd8 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -105,19 +105,18 @@ impl UserInfo {
     /// Copy status to [`User`]. This function should be used by administrators.
     ///
     /// Return `true` if status was changed, `false` otherwise.
+    /// If status was changed to inactive, all user sessions will be invalidated.
     pub(crate) async fn handle_status_change(
         &self,
         transaction: &mut PgConnection,
         user: &mut User,
     ) -> Result<bool, SqlxError> {
         if self.is_active != user.is_active {
-            user.is_active = self.is_active;
-            user.save(&mut *transaction).await?;
-
-            if !user.is_active {
+            if !self.is_active {
                 user.logout_all_sessions(&mut *transaction).await?;
             }
-
+            user.is_active = self.is_active;
+            user.save(&mut *transaction).await?;
             Ok(true)
         } else {
             Ok(false)

From 34b50d1f7eb1f4261c873189e1d362ef1decb0b7 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:38:02 +0200
Subject: [PATCH 13/34] update generated localization

---
 web/src/i18n/i18n-types.ts | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts
index cac929a9ad..3fc114a96d 100644
--- a/web/src/i18n/i18n-types.ts
+++ b/web/src/i18n/i18n-types.ts
@@ -660,7 +660,7 @@ type RootTranslation = {
 				submit: string
 			}
 			/**
-			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​i​s​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​?
+			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​i​s​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​?
 			 * @param {string} username
 			 */
 			message: RequiredParams<'username'>
@@ -684,7 +684,7 @@ type RootTranslation = {
 				submit: string
 			}
 			/**
-			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​e​n​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​ ​?
+			 * D​o​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​e​n​a​b​l​e​ ​{​u​s​e​r​n​a​m​e​}​ ​a​c​c​o​u​n​t​?
 			 * @param {string} username
 			 */
 			message: RequiredParams<'username'>
@@ -4548,7 +4548,7 @@ export type TranslationFunctions = {
 				submit: () => LocalizedString
 			}
 			/**
-			 * Do you want to disable {username} account ?
+			 * Do you want to disable {username} account?
 			 */
 			message: (arg: { username: string }) => LocalizedString
 			messages: {
@@ -4570,7 +4570,7 @@ export type TranslationFunctions = {
 				submit: () => LocalizedString
 			}
 			/**
-			 * Do you want to enable {username} account ?
+			 * Do you want to enable {username} account?
 			 */
 			message: (arg: { username: string }) => LocalizedString
 			messages: {

From df5acd6fedbace08b9a294d1797b75a19f69ed4f Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 11:41:41 +0200
Subject: [PATCH 14/34] add warnings

---
 src/db/models/enrollment.rs | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs
index 4ad003f646..40d4f10719 100644
--- a/src/db/models/enrollment.rs
+++ b/src/db/models/enrollment.rs
@@ -384,6 +384,10 @@ impl User {
         }
 
         if !self.is_active {
+            warn!(
+                "Can't create enrollment token for disabled user {}",
+                self.username
+            );
             return Err(TokenError::UserDisabled);
         }
 
@@ -462,6 +466,10 @@ impl User {
         let admin_id = admin.id.expect("Admin user without ID");
 
         if !self.is_active {
+            warn!(
+                "Can't create desktop configuration enrollment token for disabled user {}",
+                self.username
+            );
             return Err(TokenError::UserDisabled);
         }
 

From 758704750b86e6f22e2c1a5c9ca226f5410f76b5 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Fri, 7 Jun 2024 14:23:44 +0200
Subject: [PATCH 15/34] modify frontend handling of disabled users

---
 web/src/i18n/en/index.ts                      |  5 +++
 web/src/i18n/i18n-types.ts                    | 28 ++++++++++++++
 web/src/i18n/pl/index.ts                      | 21 ++++++----
 .../ProfileDetails/ProfileDetails.tsx         | 10 +++++
 .../ProfileDetailsForm/ProfileDetailsForm.tsx | 38 +++++++++++++++++++
 .../pages/users/UserProfile/UserProfile.tsx   | 36 ------------------
 .../UsersList/components/UserListRow.tsx      |  8 +++-
 .../components/UsersList/style.scss           | 19 +++++++---
 8 files changed, 115 insertions(+), 50 deletions(-)

diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts
index 0b339e454f..fac618863b 100644
--- a/web/src/i18n/en/index.ts
+++ b/web/src/i18n/en/index.ts
@@ -556,6 +556,11 @@ const en: BaseTranslation = {
         email: {
           label: 'E-mail',
         },
+        status: {
+          label: 'Status',
+          active: 'Active',
+          disabled: 'Disabled',
+        },
         groups: {
           label: 'User groups',
           noData: 'No groups',
diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts
index 3fc114a96d..edfee8010f 100644
--- a/web/src/i18n/i18n-types.ts
+++ b/web/src/i18n/i18n-types.ts
@@ -1253,6 +1253,20 @@ type RootTranslation = {
 					 */
 					label: string
 				}
+				status: {
+					/**
+					 * S​t​a​t​u​s
+					 */
+					label: string
+					/**
+					 * A​c​t​i​v​e
+					 */
+					active: string
+					/**
+					 * D​i​s​a​b​l​e​d
+					 */
+					disabled: string
+				}
 				groups: {
 					/**
 					 * U​s​e​r​ ​g​r​o​u​p​s
@@ -5133,6 +5147,20 @@ export type TranslationFunctions = {
 					 */
 					label: () => LocalizedString
 				}
+				status: {
+					/**
+					 * Status
+					 */
+					label: () => LocalizedString
+					/**
+					 * Active
+					 */
+					active: () => LocalizedString
+					/**
+					 * Disabled
+					 */
+					disabled: () => LocalizedString
+				}
 				groups: {
 					/**
 					 * User groups
diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts
index 53680cca2d..8006c3719c 100644
--- a/web/src/i18n/pl/index.ts
+++ b/web/src/i18n/pl/index.ts
@@ -276,23 +276,23 @@ const pl: Translation = {
       },
     },
     disableUser: {
-      title: 'Wyłącz użytkownika',
+      title: 'Dezaktywuj użytkownika',
       controls: {
-        submit: 'Wyłącz użytkownika',
+        submit: 'Dezaktywuj użytkownika',
       },
-      message: 'Czy chcesz wyłączyć użytkownika {username}?',
+      message: 'Czy chcesz dezaktywować użytkownika {username}?',
       messages: {
-        success: 'Użytkownik {username} został wyłączony.',
+        success: 'Użytkownik {username} został dezaktywowany.',
       },
     },
     enableUser: {
-      title: 'Włącz użytkownika',
+      title: 'Aktywuj użytkownika',
       controls: {
-        submit: 'Włącz użytkownika',
+        submit: 'Aktywuj użytkownika',
       },
-      message: 'Czy chcesz włączyć użytkownika {username}?',
+      message: 'Czy chcesz aktywować użytkownika {username}?',
       messages: {
-        success: 'Użytkownik {username} został włączony.',
+        success: 'Użytkownik {username} został aktywowany.',
       },
     },
     deleteProvisioner: {
@@ -542,6 +542,11 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe
         email: {
           label: 'E-mail',
         },
+        status: {
+          label: 'Status',
+          active: 'Aktywny',
+          disabled: 'Nieaktywny',
+        },
         groups: {
           label: 'Grupy użytkowników',
           noData: 'Brak grup',
diff --git a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
index 687d4b74bb..d4b19c7794 100644
--- a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
+++ b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
@@ -106,6 +106,16 @@ const ViewMode = () => {
           <p>{user.email}</p>
         </div>
       </div>
+      <div className="row">
+        <div className="info">
+          <Label>{LL.userPage.userDetails.fields.status.label()}</Label>
+          <p>
+            {user.is_active
+              ? LL.userPage.userDetails.fields.status.active()
+              : LL.userPage.userDetails.fields.status.disabled()}
+          </p>
+        </div>
+      </div>
       <div className="row tags">
         <Label>{LL.userPage.userDetails.fields.groups.label()}</Label>
         <div className="tags">
diff --git a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx
index 160f82bb3c..d221aa0143 100644
--- a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx
+++ b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx
@@ -35,6 +35,7 @@ interface Inputs {
   email: string;
   groups: string[];
   authorized_apps: OAuth2AuthorizedApps[];
+  is_active: boolean;
 }
 
 const defaultValues: Inputs = {
@@ -45,6 +46,7 @@ const defaultValues: Inputs = {
   email: '',
   groups: [],
   authorized_apps: [],
+  is_active: true,
 };
 
 export const ProfileDetailsForm = () => {
@@ -56,6 +58,7 @@ export const ProfileDetailsForm = () => {
   const submitButton = useRef<HTMLButtonElement | null>(null);
   const queryClient = useQueryClient();
   const isAdmin = useAuthStore((state) => state.isAdmin);
+  const isMe = useUserProfileStore((state) => state.isMe);
   const [fetchGroups, setFetchGroups] = useState(false);
   const {
     user: { editUser },
@@ -96,6 +99,7 @@ export const ProfileDetailsForm = () => {
             user_id: z.number().min(1, LL.form.error.required()),
           }),
         ),
+        is_active: z.boolean(),
       }),
     [LL.form.error],
   );
@@ -155,6 +159,21 @@ export const ProfileDetailsForm = () => {
     return [];
   }, [availableGroups, groupsLoading]);
 
+  const statusOptions = useMemo(() => {
+    return [
+      {
+        key: 'active',
+        value: true,
+        label: LL.userPage.userDetails.fields.status.active(),
+      },
+      {
+        key: 'inactive',
+        value: false,
+        label: LL.userPage.userDetails.fields.status.disabled(),
+      },
+    ];
+  }, [LL.userPage.userDetails.fields.status]);
+
   const onValidSubmit: SubmitHandler<Inputs> = (values) => {
     values = trimObjectStrings(values);
     if (userProfile && userProfile.user) {
@@ -246,6 +265,25 @@ export const ProfileDetailsForm = () => {
           />
         </div>
       </div>
+      {isAdmin && !isMe && (
+        <div className="row">
+          <div className="item">
+            <FormSelect
+              data-testid="status-select"
+              options={statusOptions}
+              controller={{ control, name: 'is_active' }}
+              label={LL.userPage.userDetails.fields.status.label()}
+              disabled={userEditLoading || !isAdmin}
+              renderSelected={(val) => ({
+                key: val ? 'active' : 'inactive',
+                displayValue: val
+                  ? LL.userPage.userDetails.fields.status.active()
+                  : LL.userPage.userDetails.fields.status.disabled(),
+              })}
+            />
+          </div>
+        </div>
+      )}
       <div className="row">
         <div className="item">
           <FormSelect
diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx
index beb802a7d0..fc44b44b03 100644
--- a/web/src/pages/users/UserProfile/UserProfile.tsx
+++ b/web/src/pages/users/UserProfile/UserProfile.tsx
@@ -133,7 +133,6 @@ const EditModeControls = () => {
   const isMe = useUserProfileStore((state) => state.isMe);
   const setUserProfileState = useUserProfileStore((state) => state.setState);
   const setDeleteUserModalState = useModalStore((state) => state.setDeleteUserModal);
-  const setToggleUserModalState = useModalStore((state) => state.setToggleUserModal);
   const loading = useUserProfileStore((state) => state.loading);
 
   const submitSubject = useUserProfileStore((state) => state.submitSubject);
@@ -144,12 +143,6 @@ const EditModeControls = () => {
     }
   };
 
-  const handleToggleUser = () => {
-    if (userProfile) {
-      setToggleUserModalState({ visible: true, user: userProfile.user });
-    }
-  };
-
   return (
     <>
       {isAdmin && !isMe && breakpoint === 'desktop' ? (
@@ -160,20 +153,6 @@ const EditModeControls = () => {
             styleVariant={ButtonStyleVariant.CONFIRM}
             onClick={handleDeleteUser}
           />
-          <Button
-            text={
-              userProfile?.user?.is_active
-                ? LL.modals.disableUser.title()
-                : LL.modals.enableUser.title()
-            }
-            size={ButtonSize.SMALL}
-            styleVariant={
-              userProfile?.user?.is_active
-                ? ButtonStyleVariant.CONFIRM
-                : ButtonStyleVariant.STANDARD
-            }
-            onClick={handleToggleUser}
-          />
         </div>
       ) : null}
       <div className="right">
@@ -186,21 +165,6 @@ const EditModeControls = () => {
               disabled={!isAdmin || isMe}
               onClick={handleDeleteUser}
             />
-            <EditButtonOption
-              data-testid="user-edit-disable-account"
-              text={
-                userProfile?.user?.is_active
-                  ? LL.modals.disableUser.title()
-                  : LL.modals.enableUser.title()
-              }
-              styleVariant={
-                userProfile?.user?.is_active
-                  ? EditButtonOptionStyleVariant.WARNING
-                  : EditButtonOptionStyleVariant.STANDARD
-              }
-              disabled={!isAdmin || isMe}
-              onClick={handleToggleUser}
-            />
           </EditButton>
         )}
         <Button
diff --git a/web/src/pages/users/UsersOverview/components/UsersList/components/UserListRow.tsx b/web/src/pages/users/UsersOverview/components/UsersList/components/UserListRow.tsx
index 0594c4603f..79ea028ad6 100644
--- a/web/src/pages/users/UsersOverview/components/UsersList/components/UserListRow.tsx
+++ b/web/src/pages/users/UsersOverview/components/UsersList/components/UserListRow.tsx
@@ -1,3 +1,4 @@
+import classNames from 'classnames';
 import { useCallback } from 'react';
 import { useNavigate } from 'react-router';
 
@@ -33,7 +34,12 @@ export const UserListRow = ({ user, onSelect, selected = false }: Props) => {
   );
 
   return (
-    <div className="users-list-row" data-testid={`user-${user.id}`}>
+    <div
+      className={classNames('users-list-row', {
+        'user-disabled': !user.is_active,
+      })}
+      data-testid={`user-${user.id}`}
+    >
       <div className="select-cell" onClick={() => onSelect(user.id)}>
         <CheckBox value={selected} />
       </div>
diff --git a/web/src/pages/users/UsersOverview/components/UsersList/style.scss b/web/src/pages/users/UsersOverview/components/UsersList/style.scss
index cd4d2b5e69..15fc7d7c67 100644
--- a/web/src/pages/users/UsersOverview/components/UsersList/style.scss
+++ b/web/src/pages/users/UsersOverview/components/UsersList/style.scss
@@ -1,11 +1,14 @@
 @mixin list-layout {
   display: inline-grid;
   grid-template-columns: 1fr 40px;
-  justify-content: space-between;
   align-items: center;
 
   @include media-breakpoint-up(lg) {
-    grid-template-columns: 33px minmax(100px, 250px) 128px 128px 350px 40px;
+    grid-template-columns:
+      33px minmax(100px, 1fr)
+      repeat(2, minmax(128px, 1fr))
+      minmax(300px, 1fr)
+      50px;
   }
 }
 
@@ -26,7 +29,7 @@
       @include media-breakpoint-up(lg) {
         @include list-layout;
 
-        :nth-child(4) {
+        :last-child {
           justify-content: center;
         }
       }
@@ -74,8 +77,10 @@
         }
       }
 
-      [class*='-cell'] {
-        min-width: 100px;
+      &.user-disabled {
+        :not(.user-initials-box > *, .user-edit-cell > *) {
+          color: var(--text-body-tertiary);
+        }
       }
 
       .name-cell {
@@ -93,6 +98,10 @@
         }
       }
 
+      .edit-button {
+        margin: 0 auto;
+      }
+
       .groups-cell {
         display: flex;
         flex-flow: row nowrap;

From 179e2430bba4ff0ef703eb718438d7db57576009 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Mon, 10 Jun 2024 11:45:57 +0200
Subject: [PATCH 16/34] sqlx prepare

---
 ...fc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e.json | 14 ++++++++++++++
 ...2f92afbac37e0c581e96285340196841504ed3cf0.json} |  4 ++--
 2 files changed, 16 insertions(+), 2 deletions(-)
 create mode 100644 .sqlx/query-0ddcc26ec82294f650ad1afc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e.json
 rename .sqlx/{query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json => query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json} (71%)

diff --git a/.sqlx/query-0ddcc26ec82294f650ad1afc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e.json b/.sqlx/query-0ddcc26ec82294f650ad1afc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e.json
new file mode 100644
index 0000000000..7389f53fea
--- /dev/null
+++ b/.sqlx/query-0ddcc26ec82294f650ad1afc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e.json
@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM session WHERE user_id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Int8"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "0ddcc26ec82294f650ad1afc5b6c2ea8a9b8825a1f9e19c1795c564117d8e24e"
+}
diff --git a/.sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json b/.sqlx/query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json
similarity index 71%
rename from .sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json
rename to .sqlx/query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json
index 4a6d8f9e4c..1f364cea31 100644
--- a/.sqlx/query-16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de.json
+++ b/.sqlx/query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json
@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "SELECT DISTINCT ON (d.id) d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id WHERE u.is_active = true ORDER BY d.id ASC",
+  "query": "SELECT d.id as \"id?\", d.name, d.wireguard_pubkey, d.user_id, d.created FROM device d JOIN \"user\" u ON d.user_id = u.id WHERE u.is_active = true ORDER BY d.id ASC",
   "describe": {
     "columns": [
       {
@@ -40,5 +40,5 @@
       false
     ]
   },
-  "hash": "16172ccc519f0411faa469ac8f261d90dd3786e69ab3eddac7e143b023b5a5de"
+  "hash": "59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0"
 }

From 73d0517f1cd7f412751f4be0381d75f10503ee5f Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Mon, 10 Jun 2024 12:25:45 +0200
Subject: [PATCH 17/34] add tests

---
 e2e/tests/auth.spec.ts                   | 30 +++++++++++
 e2e/tests/enrollment.spec.ts             | 42 +++++++++++++++
 e2e/tests/passwordReset.spec.ts          | 28 +++++++++-
 e2e/utils/controllers/toggleUserState.ts | 32 +++++++++++
 tests/auth.rs                            | 68 +++++++++++++++++++++++-
 tests/enrollment.rs                      | 39 ++++++++++++++
 6 files changed, 237 insertions(+), 2 deletions(-)
 create mode 100644 e2e/utils/controllers/toggleUserState.ts

diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts
index 7477aee07a..de79f47e39 100644
--- a/e2e/tests/auth.spec.ts
+++ b/e2e/tests/auth.spec.ts
@@ -10,8 +10,10 @@ import { logout } from '../utils/controllers/logout';
 import { enableEmailMFA } from '../utils/controllers/mfa/enableEmail';
 import { enableTOTP } from '../utils/controllers/mfa/enableTOTP';
 import { changePassword, changePasswordByAdmin } from '../utils/controllers/profile';
+import { disableUser } from '../utils/controllers/toggleUserState';
 import { dockerDown, dockerRestart } from '../utils/docker';
 import { waitForBase } from '../utils/waitForBase';
+import { waitForPromise } from '../utils/waitForPromise';
 import { waitForRoute } from '../utils/waitForRoute';
 
 test.describe('Test user authentication', () => {
@@ -84,6 +86,34 @@ test.describe('Test user authentication', () => {
     await page.locator('button[type="submit"]').click();
     await waitForRoute(page, routes.me);
   });
+
+  test('Login as disabled user', async ({ page, browser }) => {
+    await waitForBase(page);
+    await createUser(browser, testUser);
+    await disableUser(browser, testUser);
+    await page.goto(routes.base);
+    await waitForRoute(page, routes.auth.login);
+    await page.getByTestId('login-form-username').type(testUser.username);
+    await page.getByTestId('login-form-password').type(testUser.password);
+    const responsePromise = page.waitForResponse('**/auth');
+    await page.getByTestId('login-form-submit').click();
+    const response = await responsePromise;
+    expect(response.status()).toBe(401);
+    expect(page.url()).toBe(routes.base + routes.auth.login);
+  });
+
+  test('Logout when disabled', async ({ page, browser }) => {
+    await waitForBase(page);
+    await createUser(browser, testUser);
+    await loginBasic(page, testUser);
+    await waitForRoute(page, routes.me);
+    expect(page.url()).toBe(routes.base + routes.me);
+    await disableUser(browser, testUser);
+    // The user should be logged out when the admin disables him
+    await page.reload();
+    await waitForPromise(2000);
+    expect(page.url()).toBe(routes.base + routes.auth.login);
+  });
 });
 
 test.describe('Test password change', () => {
diff --git a/e2e/tests/enrollment.spec.ts b/e2e/tests/enrollment.spec.ts
index 2e6725c7c5..eb69d82a46 100644
--- a/e2e/tests/enrollment.spec.ts
+++ b/e2e/tests/enrollment.spec.ts
@@ -13,6 +13,7 @@ import {
   validateData,
 } from '../utils/controllers/enrollment';
 import { loginBasic } from '../utils/controllers/login';
+import { disableUser, enableUser } from '../utils/controllers/toggleUserState';
 import { createNetwork } from '../utils/controllers/vpn/createNetwork';
 import { dockerDown, dockerRestart } from '../utils/docker';
 import { waitForBase } from '../utils/waitForBase';
@@ -40,6 +41,47 @@ test.describe('Create user with enrollment enabled', () => {
     dockerDown();
   });
 
+  test('Try to complete enrollment with disabled user', async ({ page, browser }) => {
+    await disableUser(browser, user);
+    expect(token).toBeDefined();
+    await waitForBase(page);
+    await page.goto(testsConfig.ENROLLMENT_URL);
+    await waitForPromise(2000);
+    // Test if we can send the token
+    await selectEnrollment(page);
+    await setToken(token, page);
+    await expect(page.getByText('Field is invalid')).toBeVisible();
+    // Check if we are still on the token page
+    expect(page.url()).toBe(`${testsConfig.ENROLLMENT_URL}/token`);
+    await enableUser(browser, user);
+    await page.reload();
+    await setToken(token, page);
+    // Welcome page
+    await page.getByTestId('enrollment-next').click();
+    // Data validation
+    await validateData(user, page);
+    await page.getByTestId('enrollment-next').click();
+    await disableUser(browser, user);
+    // Set password
+    await setPassword(page);
+    // VPN
+    await page.getByTestId('enrollment-next').click();
+    const deviceCreationMessage = page.waitForEvent('console');
+    // Test if we can create a device configuration, if the admin has disabled us after the token validation
+    await createDevice(page);
+    // Creating a new device config should fail with a 400 error
+    expect((await deviceCreationMessage).text()).toBe(
+      'Failed to load resource: the server responded with a status of 400 (Bad Request)'
+    );
+    const userActivationMessage = page.waitForEvent('console');
+    // Try to finish the enrollment
+    await page.getByTestId('enrollment-next').click({ timeout: 2000 });
+    // Activating the user should fail with a 400 error
+    expect((await userActivationMessage).text()).toBe(
+      'Failed to load resource: the server responded with a status of 400 (Bad Request)'
+    );
+  });
+
   test('Complete enrollment with created user', async ({ page }) => {
     expect(token).toBeDefined();
     await waitForBase(page);
diff --git a/e2e/tests/passwordReset.spec.ts b/e2e/tests/passwordReset.spec.ts
index 351405ef4b..814d78c320 100644
--- a/e2e/tests/passwordReset.spec.ts
+++ b/e2e/tests/passwordReset.spec.ts
@@ -1,4 +1,4 @@
-import { test } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 import { testsConfig, testUserTemplate } from '../config';
 import { User } from '../types';
@@ -10,6 +10,7 @@ import {
   setEmail,
   setPassword,
 } from '../utils/controllers/passwordReset';
+import { disableUser } from '../utils/controllers/toggleUserState';
 import { getPasswordResetToken } from '../utils/db/getPasswordResetToken';
 import { dockerDown, dockerRestart } from '../utils/docker';
 import { waitForBase } from '../utils/waitForBase';
@@ -48,4 +49,29 @@ test.describe('Reset password', () => {
     await loginBasic(page, { ...user, password: newPassword });
     await logout(page);
   });
+
+  test('Reset disabled user password', async ({ page, browser }) => {
+    await waitForBase(page);
+    await page.goto(testsConfig.ENROLLMENT_URL);
+    await waitForPromise(2000);
+    await selectPasswordReset(page);
+    await setEmail(user.mail, page);
+    await waitForPromise(2000);
+    const token = await getPasswordResetToken(user.mail);
+    await disableUser(browser, user);
+    await page.goto(`${testsConfig.ENROLLMENT_URL}/password-reset/?token=${token}`);
+    await waitForPromise(2000);
+
+    // A message should be displayed that the code is invalid
+    const message = await page.locator('.message').textContent();
+    expect(message).toBe(
+      'The entered code is invalid. Please start the process from the beginning.'
+    );
+
+    // The password input should not be visible
+    const passwordInputVisible = await page
+      .locator('[data-testid="field-password"]')
+      .isVisible();
+    expect(passwordInputVisible).toBe(false);
+  });
 });
diff --git a/e2e/utils/controllers/toggleUserState.ts b/e2e/utils/controllers/toggleUserState.ts
new file mode 100644
index 0000000000..50e2d085a4
--- /dev/null
+++ b/e2e/utils/controllers/toggleUserState.ts
@@ -0,0 +1,32 @@
+import { Browser } from 'playwright';
+
+import { defaultUserAdmin, routes } from '../../config';
+import { User } from '../../types';
+import { waitForBase } from '../waitForBase';
+import { loginBasic } from './login';
+
+export const enableUser = async (browser: Browser, user: User): Promise<void> => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await waitForBase(page);
+  await loginBasic(page, defaultUserAdmin);
+  await page.goto(routes.base + '/admin/users/' + user.username);
+  await page.getByTestId('edit-user').click();
+  await page.getByTestId('status-select').locator('.select-container').click();
+  await page.locator('.select-option:has-text("Active")').click();
+  await page.getByTestId('user-edit-save').click();
+  await context.close();
+};
+
+export const disableUser = async (browser: Browser, user: User): Promise<void> => {
+  const context = await browser.newContext();
+  const page = await context.newPage();
+  await waitForBase(page);
+  await loginBasic(page, defaultUserAdmin);
+  await page.goto(routes.base + '/admin/users/' + user.username);
+  await page.getByTestId('edit-user').click();
+  await page.getByTestId('status-select').locator('.select-container').click();
+  await page.locator('.select-option:has-text("Disabled")').click();
+  await page.getByTestId('user-edit-save').click();
+  await context.close();
+};
diff --git a/tests/auth.rs b/tests/auth.rs
index fe82f16efc..1789441384 100644
--- a/tests/auth.rs
+++ b/tests/auth.rs
@@ -4,9 +4,12 @@ use std::{str::FromStr, time::SystemTime};
 
 use chrono::NaiveDateTime;
 use claims::assert_err;
+use common::fetch_user_details;
 use defguard::{
     auth::TOTP_CODE_VALIDITY_PERIOD,
-    db::{models::wallet::keccak256, DbPool, MFAInfo, MFAMethod, Settings, UserDetails, Wallet},
+    db::{
+        models::wallet::keccak256, DbPool, MFAInfo, MFAMethod, Settings, User, UserDetails, Wallet,
+    },
     handlers::{Auth, AuthCode, AuthResponse, AuthTotp, WalletChallenge},
     hex::to_lower_hex,
     secret::SecretString,
@@ -131,6 +134,46 @@ async fn test_login_bruteforce() {
     }
 }
 
+#[tokio::test]
+async fn test_login_disabled() {
+    let client = make_client().await;
+
+    let user_auth = Auth::new("hpotter", "pass123");
+    let admin_auth = Auth::new("admin", "pass123");
+
+    let response = client.post("/api/v1/auth").json(&admin_auth).send().await;
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let mut user_details = fetch_user_details(&client, "hpotter").await;
+    user_details.user.is_active = false;
+    let response = client
+        .put("/api/v1/user/hpotter")
+        .json(&user_details.user)
+        .send()
+        .await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let response = client.post("/api/v1/auth").json(&user_auth).send().await;
+
+    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+    client.post("/api/v1/auth").json(&admin_auth).send().await;
+    let mut user_details = fetch_user_details(&client, "hpotter").await;
+    user_details.user.is_active = true;
+    let response = client
+        .put("/api/v1/user/hpotter")
+        .json(&user_details.user)
+        .send()
+        .await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let response = client.post("/api/v1/auth").json(&user_auth).send().await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+}
+
 #[tokio::test]
 async fn test_cannot_enable_mfa() {
     let client = make_client().await;
@@ -1103,3 +1146,26 @@ async fn test_session_cookie() {
     let auth_cookie = response.cookies().find(|c| c.name() == SESSION_COOKIE_NAME);
     assert!(auth_cookie.is_none());
 }
+
+#[tokio::test]
+async fn test_all_session_logout() {
+    let (client, pool) = make_client_with_db().await;
+
+    let auth = Auth::new("hpotter", "pass123");
+    let response = client.post("/api/v1/auth").json(&auth).send().await;
+    assert_eq!(response.status(), StatusCode::OK);
+
+    // Disable the user, effectively logging them out
+    let user = User::find_by_username(&pool, "hpotter")
+        .await
+        .unwrap()
+        .unwrap();
+
+    user.logout_all_sessions(&pool).await.unwrap();
+
+    let response = client.get("/api/v1/me").send().await;
+    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+    let auth_cookie = response.cookies().find(|c| c.name() == SESSION_COOKIE_NAME);
+    assert!(auth_cookie.is_none());
+}
diff --git a/tests/enrollment.rs b/tests/enrollment.rs
index 1561e990b8..ec34283d53 100644
--- a/tests/enrollment.rs
+++ b/tests/enrollment.rs
@@ -1,5 +1,6 @@
 mod common;
 
+use common::fetch_user_details;
 use defguard::{
     db::{models::enrollment::Token, DbPool},
     handlers::{AddUserData, Auth},
@@ -84,3 +85,41 @@ async fn test_initialize_enrollment() {
     assert_eq!(enrollment.admin_id, Some(1));
     assert_eq!(enrollment.used_at, None);
 }
+
+#[tokio::test]
+async fn test_enroll_disabled_user() {
+    let (client, _) = make_client().await;
+
+    let auth = Auth::new("admin", "pass123");
+    let response = client.post("/api/v1/auth").json(&auth).send().await;
+    assert_eq!(response.status(), StatusCode::OK);
+
+    let new_user = AddUserData {
+        username: "adumbledore".into(),
+        last_name: "Dumbledore".into(),
+        first_name: "Albus".into(),
+        email: "a.dumbledore@hogwart.edu.uk".into(),
+        phone: Some("1234".into()),
+        password: None,
+    };
+    let response = client.post("/api/v1/user").json(&new_user).send().await;
+    assert_eq!(response.status(), StatusCode::CREATED);
+
+    let mut user_details = fetch_user_details(&client, "adumbledore").await;
+    user_details.user.is_active = false;
+    let response = client
+        .put(format!("/api/v1/user/{}", "adumbledore"))
+        .json(&user_details.user)
+        .send()
+        .await;
+
+    assert_eq!(response.status(), StatusCode::OK);
+
+    // enrollment should fail, because user is disabled
+    let response = client
+        .post("/api/v1/user/adumbledore/start_enrollment")
+        .json(&json!({}))
+        .send()
+        .await;
+    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+}

From 7240ce03b5f1e5fa4686028449fe10def65fcc7a Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Mon, 10 Jun 2024 12:25:59 +0200
Subject: [PATCH 18/34] prevent from starting password reset on disabled user

---
 src/grpc/password_reset.rs | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/grpc/password_reset.rs b/src/grpc/password_reset.rs
index df8093f36a..2c4a3ba288 100644
--- a/src/grpc/password_reset.rs
+++ b/src/grpc/password_reset.rs
@@ -199,6 +199,10 @@ impl PasswordResetServer {
 
         let mut user = enrollment.fetch_user(&self.pool).await?;
 
+        if !user.is_active {
+            return Err(Status::permission_denied("user disabled"));
+        }
+
         let mut transaction = self.pool.begin().await.map_err(|_| {
             error!("Failed to begin transaction");
             Status::internal("unexpected error")

From 29c238c0104361f011f3b087a144360f639b2628 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Mon, 10 Jun 2024 13:29:55 +0200
Subject: [PATCH 19/34] don't display password reset button if the user is
 disabled

---
 .../UsersOverview/components/UserEditButton/UserEditButton.tsx  | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
index 7b7ab3fe40..9578aa849b 100644
--- a/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
+++ b/web/src/pages/users/UsersOverview/components/UserEditButton/UserEditButton.tsx
@@ -39,7 +39,7 @@ export const UserEditButton = ({ user }: Props) => {
           onClick={() => setChangePasswordModal({ visible: true, user })}
         />
       )}
-      <ResetPasswordButton user={user} />
+      {user.is_active && <ResetPasswordButton user={user} />}
       <EditButtonOption
         key="edit-user"
         text={LL.usersOverview.list.editButton.edit()}

From 315c08993f089c843f32375441df54f94d08d300 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Mon, 10 Jun 2024 14:30:14 +0200
Subject: [PATCH 20/34] add ldap todo

---
 src/handlers/user.rs | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index fac38c41aa..882e164107 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -342,6 +342,7 @@ pub async fn modify_user(
     }
     user.save(&mut *transaction).await?;
 
+    // TODO: Reflect user status (active/disabled) modification in ldap
     let _result = ldap_modify_user(&appstate.pool, &username, &user).await;
     let user_info = UserInfo::from_user(&appstate.pool, &user).await?;
     appstate.trigger_action(AppEvent::UserModified(user_info));

From eb16f45859ea9a4ad8a16e164d6c6c8992e06fc7 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 11:02:51 +0200
Subject: [PATCH 21/34] hide user status from himself

---
 .../ProfileDetails/ProfileDetails.tsx         | 21 +++++++++++--------
 1 file changed, 12 insertions(+), 9 deletions(-)

diff --git a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
index d4b19c7794..96945b9d0e 100644
--- a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
+++ b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetails.tsx
@@ -64,6 +64,7 @@ const ViewMode = () => {
     },
   );
   const user = useUserProfileStore((store) => store.userProfile?.user);
+  const isMe = useUserProfileStore((store) => store.isMe);
 
   const sortedGroups = useMemo(() => {
     if (user?.groups) {
@@ -106,16 +107,18 @@ const ViewMode = () => {
           <p>{user.email}</p>
         </div>
       </div>
-      <div className="row">
-        <div className="info">
-          <Label>{LL.userPage.userDetails.fields.status.label()}</Label>
-          <p>
-            {user.is_active
-              ? LL.userPage.userDetails.fields.status.active()
-              : LL.userPage.userDetails.fields.status.disabled()}
-          </p>
+      {!isMe && (
+        <div className="row">
+          <div className="info">
+            <Label>{LL.userPage.userDetails.fields.status.label()}</Label>
+            <p>
+              {user.is_active
+                ? LL.userPage.userDetails.fields.status.active()
+                : LL.userPage.userDetails.fields.status.disabled()}
+            </p>
+          </div>
         </div>
-      </div>
+      )}
       <div className="row tags">
         <Label>{LL.userPage.userDetails.fields.groups.label()}</Label>
         <div className="tags">

From 3a185862f182c3b99e29b426bae49ff82cd9d2ff Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 11:14:02 +0200
Subject: [PATCH 22/34] prevent admin from disabling himself

---
 src/handlers/user.rs | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index fac38c41aa..d089cf6dbc 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -322,7 +322,16 @@ pub async fn modify_user(
             .await?;
     }
     if session.is_admin {
-        // update VPN gateway config if groups have changed
+        // prevent admin from disabling himself
+        if session.user.username == username {
+            debug!("Admin {username} attempted to disable himself");
+            return Ok(ApiResponse {
+                json: json!({}),
+                status: StatusCode::BAD_REQUEST,
+            });
+        }
+
+        // update VPN gateway config if user status or groups have changed
         if user_info
             .handle_user_groups(&mut transaction, &mut user)
             .await?

From 7cfbf09d4d8c00aefd558e98af1751a94762bd78 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 11:20:54 +0200
Subject: [PATCH 23/34] add more tests

---
 tests/user.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 47 insertions(+)

diff --git a/tests/user.rs b/tests/user.rs
index aa4e31368a..fb2f4b677c 100644
--- a/tests/user.rs
+++ b/tests/user.rs
@@ -707,3 +707,50 @@ async fn test_user_add_device() {
         .content
         .contains("Device type:</span> iPhone, OS: iOS 17.1, Mobile Safari"));
 }
+
+#[tokio::test]
+async fn test_disable() {
+    let client = make_client().await;
+
+    let auth = Auth::new("admin", "pass123");
+    let response = client.post("/api/v1/auth").json(&auth).send().await;
+    assert_eq!(response.status(), StatusCode::OK);
+
+    // get yourself
+    let mut user_details = fetch_user_details(&client, "admin").await;
+    user_details.user.is_active = false;
+
+    // disable yourself
+    let response = client
+        .put("/api/v1/user/admin")
+        .json(&user_details.user)
+        .send()
+        .await;
+
+    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+    // create user
+    let new_user = AddUserData {
+        username: "adumbledore".into(),
+        last_name: "Dumbledore".into(),
+        first_name: "Albus".into(),
+        email: "a.dumbledore@hogwart.edu.uk".into(),
+        phone: Some("1234".into()),
+        password: Some("Password1234543$!".into()),
+    };
+    let response = client.post("/api/v1/user").json(&new_user).send().await;
+    assert_eq!(response.status(), StatusCode::CREATED);
+
+    // get user
+    let mut user_details = fetch_user_details(&client, "adumbledore").await;
+    assert_eq!(user_details.user.first_name, "Albus");
+
+    // disable user
+    user_details.user.is_active = false;
+    let response = client
+        .put("/api/v1/user/adumbledore")
+        .json(&user_details.user)
+        .send()
+        .await;
+    assert_eq!(response.status(), StatusCode::OK);
+}

From 41370ac18839899d05ae9226f8cad50aff231ee2 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 11:37:39 +0200
Subject: [PATCH 24/34] properly check if the admin is going to disable himself

---
 src/handlers/user.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/handlers/user.rs b/src/handlers/user.rs
index 6f86beb712..d52e3796b0 100644
--- a/src/handlers/user.rs
+++ b/src/handlers/user.rs
@@ -323,7 +323,7 @@ pub async fn modify_user(
     }
     if session.is_admin {
         // prevent admin from disabling himself
-        if session.user.username == username {
+        if session.user.username == username && !user_info.is_active {
             debug!("Admin {username} attempted to disable himself");
             return Ok(ApiResponse {
                 json: json!({}),

From 3f5f727a9c3cb99211839a68725098c42617665c Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 14:28:18 +0200
Subject: [PATCH 25/34] fix css

---
 .../users/UsersOverview/components/UsersList/style.scss    | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/web/src/pages/users/UsersOverview/components/UsersList/style.scss b/web/src/pages/users/UsersOverview/components/UsersList/style.scss
index 15fc7d7c67..95cd783e47 100644
--- a/web/src/pages/users/UsersOverview/components/UsersList/style.scss
+++ b/web/src/pages/users/UsersOverview/components/UsersList/style.scss
@@ -98,8 +98,11 @@
         }
       }
 
-      .edit-button {
-        margin: 0 auto;
+      .user-edit-cell {
+        display: flex;
+        flex-flow: row;
+        align-items: center;
+        justify-content: center;
       }
 
       .groups-cell {

From 576aa44f6eb1be200e334cbc383d4195fe1264ce Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Tue, 11 Jun 2024 14:40:39 +0200
Subject: [PATCH 26/34] add new line to migrations

---
 migrations/20240604104038_add_user_active_flag.down.sql | 2 +-
 migrations/20240604104038_add_user_active_flag.up.sql   | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/migrations/20240604104038_add_user_active_flag.down.sql b/migrations/20240604104038_add_user_active_flag.down.sql
index 0151cefaeb..bb48e798c1 100644
--- a/migrations/20240604104038_add_user_active_flag.down.sql
+++ b/migrations/20240604104038_add_user_active_flag.down.sql
@@ -1 +1 @@
-ALTER TABLE "user" DROP COLUMN is_active;
\ No newline at end of file
+ALTER TABLE "user" DROP COLUMN is_active;
diff --git a/migrations/20240604104038_add_user_active_flag.up.sql b/migrations/20240604104038_add_user_active_flag.up.sql
index b426087f7b..4cbe454f87 100644
--- a/migrations/20240604104038_add_user_active_flag.up.sql
+++ b/migrations/20240604104038_add_user_active_flag.up.sql
@@ -1 +1 @@
-ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT true;
\ No newline at end of file
+ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT true;

From 2b5ddbfefb7f01a9820b1d91878205e29c6d6e3b Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 09:18:28 +0200
Subject: [PATCH 27/34] rephrase

Co-authored-by: Adam <aciarcinski@teonite.com>
---
 src/grpc/password_reset.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/grpc/password_reset.rs b/src/grpc/password_reset.rs
index 2c4a3ba288..4a41f15789 100644
--- a/src/grpc/password_reset.rs
+++ b/src/grpc/password_reset.rs
@@ -146,7 +146,7 @@ impl PasswordResetServer {
 
         if !user.has_password() || !user.is_active {
             return Err(Status::permission_denied(
-                "user disabled or not enrolled yet",
+                "user disabled or not yet enrolled",
             ));
         }
 

From 72f5930b7636cdfa7edc0464fbf7903be5899c20 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 09:31:15 +0200
Subject: [PATCH 28/34] don't panic

---
 src/db/models/user.rs | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index e802b8ec6c..2ea623127a 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -803,12 +803,9 @@ impl User {
     where
         E: PgExecutor<'e>,
     {
-        Session::delete_all_for_user(
-            executor,
-            self.id
-                .expect("User ID must be set to log out all sessions"),
-        )
-        .await?;
+        if let Some(id) = self.id {
+            Session::delete_all_for_user(executor, id).await?;
+        }
         Ok(())
     }
 }

From b7e1268931cf76bbc4e10435ef04f6bb3f51f096 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 09:50:52 +0200
Subject: [PATCH 29/34] add more log messages

---
 src/grpc/password_reset.rs | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/grpc/password_reset.rs b/src/grpc/password_reset.rs
index 2c4a3ba288..8783c7d54d 100644
--- a/src/grpc/password_reset.rs
+++ b/src/grpc/password_reset.rs
@@ -145,6 +145,10 @@ impl PasswordResetServer {
         let user = enrollment.fetch_user(&self.pool).await?;
 
         if !user.has_password() || !user.is_active {
+            error!(
+                "Can't start password reset for a disabled or not enrolled user {}.",
+                user.username
+            );
             return Err(Status::permission_denied(
                 "user disabled or not enrolled yet",
             ));
@@ -200,6 +204,10 @@ impl PasswordResetServer {
         let mut user = enrollment.fetch_user(&self.pool).await?;
 
         if !user.is_active {
+            error!(
+                "Can't reset password for a disabled user {}.",
+                user.username
+            );
             return Err(Status::permission_denied("user disabled"));
         }
 

From 1020db06a4ed9304b14731b27a1bda37b63421cd Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 11:13:09 +0200
Subject: [PATCH 30/34] make tests more reliable

---
 e2e/tests/auth.spec.ts       |  3 +++
 e2e/tests/enrollment.spec.ts | 25 ++++++++++++-------------
 2 files changed, 15 insertions(+), 13 deletions(-)

diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts
index de79f47e39..eacbe7e1c7 100644
--- a/e2e/tests/auth.spec.ts
+++ b/e2e/tests/auth.spec.ts
@@ -110,8 +110,11 @@ test.describe('Test user authentication', () => {
     expect(page.url()).toBe(routes.base + routes.me);
     await disableUser(browser, testUser);
     // The user should be logged out when the admin disables him
+    const responsePromise = page.waitForResponse('**/me');
     await page.reload();
     await waitForPromise(2000);
+    const response = await responsePromise;
+    expect(response.status()).toBe(401);
     expect(page.url()).toBe(routes.base + routes.auth.login);
   });
 });
diff --git a/e2e/tests/enrollment.spec.ts b/e2e/tests/enrollment.spec.ts
index eb69d82a46..265ccf53f4 100644
--- a/e2e/tests/enrollment.spec.ts
+++ b/e2e/tests/enrollment.spec.ts
@@ -42,17 +42,20 @@ test.describe('Create user with enrollment enabled', () => {
   });
 
   test('Try to complete enrollment with disabled user', async ({ page, browser }) => {
-    await disableUser(browser, user);
     expect(token).toBeDefined();
     await waitForBase(page);
+    await disableUser(browser, user);
     await page.goto(testsConfig.ENROLLMENT_URL);
     await waitForPromise(2000);
     // Test if we can send the token
     await selectEnrollment(page);
+    const startResponse = page.waitForResponse('**/start');
     await setToken(token, page);
-    await expect(page.getByText('Field is invalid')).toBeVisible();
+    expect((await startResponse).status()).toBe(403);
     // Check if we are still on the token page
     expect(page.url()).toBe(`${testsConfig.ENROLLMENT_URL}/token`);
+
+    // Test other enrollment steps
     await enableUser(browser, user);
     await page.reload();
     await setToken(token, page);
@@ -66,20 +69,16 @@ test.describe('Create user with enrollment enabled', () => {
     await setPassword(page);
     // VPN
     await page.getByTestId('enrollment-next').click();
-    const deviceCreationMessage = page.waitForEvent('console');
+
     // Test if we can create a device configuration, if the admin has disabled us after the token validation
+    const deviceResponse = page.waitForResponse('**/create_device');
     await createDevice(page);
-    // Creating a new device config should fail with a 400 error
-    expect((await deviceCreationMessage).text()).toBe(
-      'Failed to load resource: the server responded with a status of 400 (Bad Request)'
-    );
-    const userActivationMessage = page.waitForEvent('console');
-    // Try to finish the enrollment
-    await page.getByTestId('enrollment-next').click({ timeout: 2000 });
+    expect((await deviceResponse).status()).toBe(400);
+
     // Activating the user should fail with a 400 error
-    expect((await userActivationMessage).text()).toBe(
-      'Failed to load resource: the server responded with a status of 400 (Bad Request)'
-    );
+    const userResponse = page.waitForResponse('**/activate_user');
+    await page.getByTestId('enrollment-next').click({ timeout: 2000 });
+    expect((await userResponse).status()).toBe(400);
   });
 
   test('Complete enrollment with created user', async ({ page }) => {

From 0688a6b9c299b07b9db5eb7938861f022a99793e Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 12:46:15 +0200
Subject: [PATCH 31/34] upddate protobufs

---
 proto | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/proto b/proto
index 29898d9cb5..c71f378472 160000
--- a/proto
+++ b/proto
@@ -1 +1 @@
-Subproject commit 29898d9cb502ef5e7bb1771b63e6a66debf31e7d
+Subproject commit c71f37847279ee23220fcf9e0e45d2c365b3b8ee

From 668214fb5f7626b4c0c2b8ef6b13c053e3955eb1 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 12:50:50 +0200
Subject: [PATCH 32/34] format

---
 src/grpc/enrollment.rs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs
index 112ba3a865..c7db5d0932 100644
--- a/src/grpc/enrollment.rs
+++ b/src/grpc/enrollment.rs
@@ -223,7 +223,10 @@ impl EnrollmentServer {
         }
 
         if !user.is_active {
-            warn!("Can't finalize enrollment for disabled user {}", user.username);
+            warn!(
+                "Can't finalize enrollment for disabled user {}",
+                user.username
+            );
             return Err(Status::invalid_argument("user is disabled"));
         }
 

From 75918ac3f9b25a280023afa03a3e85265a77d69e Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 14:09:00 +0200
Subject: [PATCH 33/34] sqlx prepare

---
 ...72929d6a612846acf4a5489c36a04cb57de59.json | 22 +++++++++++++++++++
 ...ab52c14293fa64d260db812c89ed43a5a7cc8.json | 15 +++++++++++++
 2 files changed, 37 insertions(+)
 create mode 100644 .sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
 create mode 100644 .sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json

diff --git a/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json b/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
new file mode 100644
index 0000000000..ce67e42930
--- /dev/null
+++ b/.sqlx/query-0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59.json
@@ -0,0 +1,22 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT recovery_codes FROM \"user\" WHERE id = $1",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "recovery_codes",
+        "type_info": "TextArray"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Int8"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "0ad599ef120ecc02b030bd1276172929d6a612846acf4a5489c36a04cb57de59"
+}
diff --git a/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json b/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json
new file mode 100644
index 0000000000..8d314a5faa
--- /dev/null
+++ b/.sqlx/query-1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8.json
@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE session SET expires = $1 WHERE id = $2",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Timestamp",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "1bb3c8ecbd6500717d639678e08ab52c14293fa64d260db812c89ed43a5a7cc8"
+}

From 0d09a8e48ff62be4b1b7c17ebe4cda7d1ba21ef6 Mon Sep 17 00:00:00 2001
From: Aleksander <170264518+t-aleksander@users.noreply.github.com>
Date: Wed, 12 Jun 2024 17:52:31 +0200
Subject: [PATCH 34/34] fix test

---
 e2e/tests/auth.spec.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts
index eacbe7e1c7..f985b777e2 100644
--- a/e2e/tests/auth.spec.ts
+++ b/e2e/tests/auth.spec.ts
@@ -110,9 +110,9 @@ test.describe('Test user authentication', () => {
     expect(page.url()).toBe(routes.base + routes.me);
     await disableUser(browser, testUser);
     // The user should be logged out when the admin disables him
-    const responsePromise = page.waitForResponse('**/me');
-    await page.reload();
     await waitForPromise(2000);
+    const responsePromise = page.waitForResponse('**/user/' + testUser.username);
+    await page.locator('a[href="/me"]').click();
     const response = await responsePromise;
     expect(response.status()).toBe(401);
     expect(page.url()).toBe(routes.base + routes.auth.login);
