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-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-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-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json b/.sqlx/query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json new file mode 100644 index 0000000000..1f364cea31 --- /dev/null +++ b/.sqlx/query-59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "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": [ + { + "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": "59ff9c1093cb41a5f902cf42f92afbac37e0c581e96285340196841504ed3cf0" +} 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\" 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\" 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" } diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts index 7477aee07a..f985b777e2 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,37 @@ 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 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); + }); }); test.describe('Test password change', () => { diff --git a/e2e/tests/enrollment.spec.ts b/e2e/tests/enrollment.spec.ts index 2e6725c7c5..265ccf53f4 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,46 @@ test.describe('Create user with enrollment enabled', () => { dockerDown(); }); + test('Try to complete enrollment with disabled user', async ({ page, browser }) => { + 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); + 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); + // 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(); + + // 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); + expect((await deviceResponse).status()).toBe(400); + + // Activating the user should fail with a 400 error + 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 }) => { 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 => { + 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 => { + 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/migrations/20240604104038_add_user_active_flag.down.sql b/migrations/20240604104038_add_user_active_flag.down.sql new file mode 100644 index 0000000000..bb48e798c1 --- /dev/null +++ b/migrations/20240604104038_add_user_active_flag.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..4cbe454f87 --- /dev/null +++ b/migrations/20240604104038_add_user_active_flag.up.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN is_active boolean NOT NULL DEFAULT true; diff --git a/proto b/proto index 29898d9cb5..c71f378472 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 29898d9cb502ef5e7bb1771b63e6a66debf31e7d +Subproject commit c71f37847279ee23220fcf9e0e45d2c365b3b8ee diff --git a/src/db/models/enrollment.rs b/src/db/models/enrollment.rs index 767af4fe9f..40d4f10719 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 for Status { TokenError::DbError(_) | TokenError::AdminNotFound | TokenError::UserNotFound + | TokenError::UserDisabled | TokenError::NotificationError(_) | TokenError::WelcomeMsgNotConfigured | TokenError::WelcomeEmailNotConfigured @@ -380,6 +383,14 @@ impl User { return Err(TokenError::AlreadyActive); } + if !self.is_active { + warn!( + "Can't create enrollment token for disabled user {}", + self.username + ); + return Err(TokenError::UserDisabled); + } + let user_id = self.id.expect("User without ID"); let admin_id = admin.id.expect("Admin user without ID"); @@ -454,6 +465,14 @@ 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 { + warn!( + "Can't create desktop configuration enrollment token for disabled user {}", + self.username + ); + return Err(TokenError::UserDisabled); + } + self.clear_unused_enrollment_tokens(&mut *transaction) .await?; 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..fe7b8d4dd8 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, pub is_active: bool, + pub enrolled: bool, } impl UserInfo { @@ -96,10 +97,32 @@ impl UserInfo { groups, mfa_method: user.mfa_method.clone(), authorized_apps, - is_active: user.has_password(), + 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. + /// 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 { + if self.is_active != 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) + } + } + /// 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/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 65cf433465..2ea623127a 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, @@ -59,6 +60,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)] @@ -71,6 +73,7 @@ pub struct User { pub email: String, pub phone: Option, 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 +119,7 @@ impl User { email_mfa_secret: None, mfa_method: MFAMethod::None, recovery_codes: Vec::new(), + is_active: true, } } @@ -447,7 +451,7 @@ impl User { ) -> Result, 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\", password_hash, is_active \ FROM \"user\"" ) .fetch_all(pool) @@ -460,7 +464,8 @@ 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, + enrolled: u.password_hash.is_some(), }) .collect(); Ok(res) @@ -476,7 +481,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 +572,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 +588,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 ) @@ -793,6 +798,16 @@ impl User { Ok(()) } + + pub async fn logout_all_sessions<'e, E>(&self, executor: E) -> Result<(), SqlxError> + where + E: PgExecutor<'e>, + { + if let Some(id) = self.id { + Session::delete_all_for_user(executor, id).await?; + } + Ok(()) + } } #[cfg(test)] diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs index 72a7b2e58b..5721cb32ee 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 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 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 683677baa0..c7db5d0932 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 { + warn!("Can't start enrollment for disabled user {}", 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") @@ -217,6 +222,14 @@ impl EnrollmentServer { return Err(Status::invalid_argument("user already activated")); } + if !user.is_active { + warn!( + "Can't finalize enrollment for disabled user {}", + 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 +302,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,7 +485,7 @@ impl From for AdminInfo { impl InitialUserInfo { async fn from_user(pool: &DbPool, user: User) -> Result { - let is_active = user.has_password(); + 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 { @@ -474,8 +494,9 @@ impl InitialUserInfo { login: user.username, email: user.email, phone_number: user.phone, - is_active, + 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\" \ 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 c68a15c3c0..5b4613d96a 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.has_password() { + // Do not allow password change if user is disabled or not enrolled + if !user.has_password() || !user.is_active { return Ok(()); } @@ -144,8 +144,14 @@ impl PasswordResetServer { let user = enrollment.fetch_user(&self.pool).await?; - if !user.has_password() { - return Err(Status::permission_denied("user inactive")); + 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 yet enrolled", + )); } let mut transaction = self.pool.begin().await.map_err(|_| { @@ -197,6 +203,14 @@ 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")); + } + let mut transaction = self.pool.begin().await.map_err(|_| { error!("Failed to begin transaction"); Status::internal("unexpected error") 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/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..d52e3796b0 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.has_password() { - 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), @@ -322,10 +322,22 @@ 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 && !user_info.is_active { + 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? + || user_info + .handle_status_change(&mut transaction, &mut user) + .await? { let networks = WireguardNetwork::all(&mut *transaction).await?; for network in networks { @@ -339,6 +351,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)); diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index 9350061aa6..f7c1dfa602 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 { + info!( + "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"); 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); +} 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: 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); +} diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index b17b71b4e5..fac618863b 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: { @@ -536,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 40142d1f62..edfee8010f 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 @@ -1205,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 @@ -4488,6 +4550,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 @@ -5041,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 698fa3f439..8006c3719c 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: 'Dezaktywuj użytkownika', + controls: { + submit: 'Dezaktywuj użytkownika', + }, + message: 'Czy chcesz dezaktywować użytkownika {username}?', + messages: { + success: 'Użytkownik {username} został dezaktywowany.', + }, + }, + enableUser: { + title: 'Aktywuj użytkownika', + controls: { + submit: 'Aktywuj użytkownika', + }, + message: 'Czy chcesz aktywować użytkownika {username}?', + messages: { + success: 'Użytkownik {username} został aktywowany.', + }, + }, deleteProvisioner: { title: 'Usuń provisionera', controls: { @@ -522,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..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,6 +107,18 @@ const ViewMode = () => {

{user.email}

+ {!isMe && ( +
+
+ +

+ {user.is_active + ? LL.userPage.userDetails.fields.status.active() + : LL.userPage.userDetails.fields.status.disabled()} +

+
+
+ )}
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(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 = (values) => { values = trimObjectStrings(values); if (userProfile && userProfile.user) { @@ -246,6 +265,25 @@ export const ProfileDetailsForm = () => { />
+ {isAdmin && !isMe && ( +
+
+ ({ + key: val ? 'active' : 'inactive', + displayValue: val + ? LL.userPage.userDetails.fields.status.active() + : LL.userPage.userDetails.fields.status.disabled(), + })} + /> +
+
+ )}
{ 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); @@ -38,7 +39,7 @@ export const UserEditButton = ({ user }: Props) => { onClick={() => setChangePasswordModal({ visible: true, user })} /> )} - + {user.is_active && } { } /> )} - {user.is_active === true && ( + {user.enrolled && user.is_active && ( { } /> )} - {!user.is_active && ( + {!user.enrolled && user.is_active && ( { } /> )} + {user.username !== currentUser?.username && ( + + setToggleUserModal({ + visible: true, + user, + }) + } + /> + )} {user.username !== currentUser?.username && ( { ); return ( -
+
onSelect(user.id)}>
diff --git a/web/src/pages/users/UsersOverview/components/UsersList/style.scss b/web/src/pages/users/UsersOverview/components/UsersList/style.scss index cd4d2b5e69..95cd783e47 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,13 @@ } } + .user-edit-cell { + display: flex; + flex-flow: row; + align-items: center; + justify-content: center; + } + .groups-cell { display: flex; flex-flow: row nowrap; 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 = () => { <> + ); 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 ( + 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( 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( 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; // DO NOT EXTEND THIS STORE + setToggleUserModal: ModalSetter; + // DO NOT EXTEND THIS STORE setProvisionKeyModal: ModalSetter; // DO NOT EXTEND THIS STORE setChangePasswordModal: ModalSetter;