diff --git a/.sqlx/query-0d2c77d57bab4410b7cf7a79bcadc7e64b747ea250e6e27a5177f89116cb4775.json b/.sqlx/query-0d2c77d57bab4410b7cf7a79bcadc7e64b747ea250e6e27a5177f89116cb4775.json deleted file mode 100644 index 6fde8ac13..000000000 --- a/.sqlx/query-0d2c77d57bab4410b7cf7a79bcadc7e64b747ea250e6e27a5177f89116cb4775.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name FROM openidprovider LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "base_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "client_secret", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "display_name", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "0d2c77d57bab4410b7cf7a79bcadc7e64b747ea250e6e27a5177f89116cb4775" -} diff --git a/.sqlx/query-ba65214847bd66c0b57d1d2eea65d862c062b3224e61598247ec8c927216caf3.json b/.sqlx/query-1964ce84c61adbc9ff3d01998e1e94182dcb8473eee7e5bc02067bd805835123.json similarity index 59% rename from .sqlx/query-ba65214847bd66c0b57d1d2eea65d862c062b3224e61598247ec8c927216caf3.json rename to .sqlx/query-1964ce84c61adbc9ff3d01998e1e94182dcb8473eee7e5bc02067bd805835123.json index 0f206c3b5..49b02d179 100644 --- a/.sqlx/query-ba65214847bd66c0b57d1d2eea65d862c062b3224e61598247ec8c927216caf3.json +++ b/.sqlx/query-1964ce84c61adbc9ff3d01998e1e94182dcb8473eee7e5bc02067bd805835123.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name FROM \"group\" WHERE name = $1", + "query": "SELECT id, name, is_admin FROM \"group\" WHERE name = $1", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "name", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + false, false, false ] }, - "hash": "ba65214847bd66c0b57d1d2eea65d862c062b3224e61598247ec8c927216caf3" + "hash": "1964ce84c61adbc9ff3d01998e1e94182dcb8473eee7e5bc02067bd805835123" } diff --git a/.sqlx/query-24b61173ea347abd382b1839446f2c0315892c7d2c012c7cc4c399410189814b.json b/.sqlx/query-24b61173ea347abd382b1839446f2c0315892c7d2c012c7cc4c399410189814b.json deleted file mode 100644 index ae65d7c0d..000000000 --- a/.sqlx/query-24b61173ea347abd382b1839446f2c0315892c7d2c012c7cc4c399410189814b.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5 WHERE id = $6", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "24b61173ea347abd382b1839446f2c0315892c7d2c012c7cc4c399410189814b" -} diff --git a/.sqlx/query-59bf54c747b02c1611b71028e662634bb056938becc3eb63fc60c661a2afaab0.json b/.sqlx/query-3cc1bcdc1c4fab2e7613846c38f104ee61fc68c26f762c6136bf284ed4cfa2f7.json similarity index 53% rename from .sqlx/query-59bf54c747b02c1611b71028e662634bb056938becc3eb63fc60c661a2afaab0.json rename to .sqlx/query-3cc1bcdc1c4fab2e7613846c38f104ee61fc68c26f762c6136bf284ed4cfa2f7.json index aa10043a8..65778108f 100644 --- a/.sqlx/query-59bf54c747b02c1611b71028e662634bb056938becc3eb63fc60c661a2afaab0.json +++ b/.sqlx/query-3cc1bcdc1c4fab2e7613846c38f104ee61fc68c26f762c6136bf284ed4cfa2f7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, name FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id WHERE group_user.user_id = $1", + "query": "SELECT id, name, is_admin FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id WHERE group_user.user_id = $1", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "name", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + false, false, false ] }, - "hash": "59bf54c747b02c1611b71028e662634bb056938becc3eb63fc60c661a2afaab0" + "hash": "3cc1bcdc1c4fab2e7613846c38f104ee61fc68c26f762c6136bf284ed4cfa2f7" } diff --git a/.sqlx/query-45953e3820712bf534e2696f6e50d798082c152c7c629190b6c6edd3f113c544.json b/.sqlx/query-45953e3820712bf534e2696f6e50d798082c152c7c629190b6c6edd3f113c544.json deleted file mode 100644 index 075d69c6b..000000000 --- a/.sqlx/query-45953e3820712bf534e2696f6e50d798082c152c7c629190b6c6edd3f113c544.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\" FROM \"openidprovider\"", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "base_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "client_secret", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "display_name", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "45953e3820712bf534e2696f6e50d798082c152c7c629190b6c6edd3f113c544" -} diff --git a/.sqlx/query-4ad2544f4b65e4c037f8b574ae50b8a0a34a7376b86242814bda2f8e004d1589.json b/.sqlx/query-4ad2544f4b65e4c037f8b574ae50b8a0a34a7376b86242814bda2f8e004d1589.json deleted file mode 100644 index ae2f3d7e9..000000000 --- a/.sqlx/query-4ad2544f4b65e4c037f8b574ae50b8a0a34a7376b86242814bda2f8e004d1589.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\" FROM \"openidprovider\" WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "base_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "client_secret", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "display_name", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "4ad2544f4b65e4c037f8b574ae50b8a0a34a7376b86242814bda2f8e004d1589" -} diff --git a/.sqlx/query-4afdd77aa5f71ea496685213f70d56258310ea4397a522fd91ece06b910b07f0.json b/.sqlx/query-4afdd77aa5f71ea496685213f70d56258310ea4397a522fd91ece06b910b07f0.json new file mode 100644 index 000000000..c2831a3e6 --- /dev/null +++ b/.sqlx/query-4afdd77aa5f71ea496685213f70d56258310ea4397a522fd91ece06b910b07f0.json @@ -0,0 +1,123 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.id, u.username, u.password_hash, u.last_name, u.first_name, u.email, u.phone, u.mfa_enabled, u.totp_enabled, u.email_mfa_enabled, u.totp_secret, u.email_mfa_secret, u.mfa_method \"mfa_method: _\", u.recovery_codes, u.is_active, u.openid_sub FROM \"user\" u WHERE EXISTS (SELECT 1 FROM group_user gu LEFT JOIN \"group\" g ON gu.group_id = g.id WHERE is_admin = true AND user_id = u.id) AND u.is_active = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "first_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "phone", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "mfa_enabled", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "totp_enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "email_mfa_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "totp_secret", + "type_info": "Bytea" + }, + { + "ordinal": 11, + "name": "email_mfa_secret", + "type_info": "Bytea" + }, + { + "ordinal": 12, + "name": "mfa_method: _", + "type_info": { + "Custom": { + "name": "mfa_method", + "kind": { + "Enum": [ + "none", + "one_time_password", + "webauthn", + "web3", + "email" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "recovery_codes", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 15, + "name": "openid_sub", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "4afdd77aa5f71ea496685213f70d56258310ea4397a522fd91ece06b910b07f0" +} diff --git a/.sqlx/query-62d68324433b5bb49e3764ec4cfaeb531f58e2160dc579fbae7f99621ef7eb31.json b/.sqlx/query-62d68324433b5bb49e3764ec4cfaeb531f58e2160dc579fbae7f99621ef7eb31.json new file mode 100644 index 000000000..f0280ad6e --- /dev/null +++ b/.sqlx/query-62d68324433b5bb49e3764ec4cfaeb531f58e2160dc579fbae7f99621ef7eb31.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM group_user gu LEFT JOIN \"group\" g ON gu.group_id = g.id WHERE is_admin = true AND user_id = $1) \"bool!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "62d68324433b5bb49e3764ec4cfaeb531f58e2160dc579fbae7f99621ef7eb31" +} diff --git a/.sqlx/query-6fde26b448d0a4783060bf3e606e16931c0b71a91c88025eb1316287bd1f9d8c.json b/.sqlx/query-71c5b427aa144e63596f8a29e52b02912381f62a775141820b35fb7fba8fac97.json similarity index 58% rename from .sqlx/query-6fde26b448d0a4783060bf3e606e16931c0b71a91c88025eb1316287bd1f9d8c.json rename to .sqlx/query-71c5b427aa144e63596f8a29e52b02912381f62a775141820b35fb7fba8fac97.json index 5ba207612..3b0478270 100644 --- a/.sqlx/query-6fde26b448d0a4783060bf3e606e16931c0b71a91c88025eb1316287bd1f9d8c.json +++ b/.sqlx/query-71c5b427aa144e63596f8a29e52b02912381f62a775141820b35fb7fba8fac97.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\" FROM \"group\" WHERE id = $1", + "query": "SELECT id, \"name\",\"is_admin\" FROM \"group\" WHERE id = $1", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "name", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + false, false, false ] }, - "hash": "6fde26b448d0a4783060bf3e606e16931c0b71a91c88025eb1316287bd1f9d8c" + "hash": "71c5b427aa144e63596f8a29e52b02912381f62a775141820b35fb7fba8fac97" } diff --git a/.sqlx/query-75045020f615df37d233bde312dede10ece25d0a36dc363040dca0077b2ff4d8.json b/.sqlx/query-75045020f615df37d233bde312dede10ece25d0a36dc363040dca0077b2ff4d8.json index a22e485a9..6a5cade10 100644 --- a/.sqlx/query-75045020f615df37d233bde312dede10ece25d0a36dc363040dca0077b2ff4d8.json +++ b/.sqlx/query-75045020f615df37d233bde312dede10ece25d0a36dc363040dca0077b2ff4d8.json @@ -12,6 +12,11 @@ "ordinal": 1, "name": "name", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { @@ -20,6 +25,7 @@ ] }, "nullable": [ + false, false, false ] diff --git a/.sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json b/.sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json new file mode 100644 index 000000000..1e37c06b1 --- /dev/null +++ b/.sqlx/query-77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\",\"directory_sync_admin_behavior\",\"directory_sync_target\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Int4", + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + ] + }, + "nullable": [ + false + ] + }, + "hash": "77a6abaf34dd64741a1075a700bc510630fa89a072f81ccfe21b6e986f7b2552" +} diff --git a/.sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json b/.sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json new file mode 100644 index 000000000..d7ef4b0fa --- /dev/null +++ b/.sqlx/query-7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc.json @@ -0,0 +1,133 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\" FROM \"openidprovider\" WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "client_secret", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "google_service_account_key", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "google_service_account_email", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "admin_email", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "directory_sync_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "directory_sync_interval", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "directory_sync_user_behavior: _", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "directory_sync_admin_behavior: _", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "directory_sync_target: _", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "7e0df54ab4876f49960b16747ffa05c748bd2b09683251c45728e227a320b4bc" +} diff --git a/.sqlx/query-86135388d6da625f594c74860ca50859589f8735396acffa968ce88cfe307ff5.json b/.sqlx/query-86135388d6da625f594c74860ca50859589f8735396acffa968ce88cfe307ff5.json deleted file mode 100644 index a19212061..000000000 --- a/.sqlx/query-86135388d6da625f594c74860ca50859589f8735396acffa968ce88cfe307ff5.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE \"group\" SET \"name\" = $2 WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "86135388d6da625f594c74860ca50859589f8735396acffa968ce88cfe307ff5" -} diff --git a/.sqlx/query-8e70e158b41d640eba1333c0aea4f06bc8e8fce23ee336d64f8608f0e7eefc8c.json b/.sqlx/query-8e70e158b41d640eba1333c0aea4f06bc8e8fce23ee336d64f8608f0e7eefc8c.json new file mode 100644 index 000000000..d62bfc64a --- /dev/null +++ b/.sqlx/query-8e70e158b41d640eba1333c0aea4f06bc8e8fce23ee336d64f8608f0e7eefc8c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_user (group_id, user_id) VALUES (1, $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8e70e158b41d640eba1333c0aea4f06bc8e8fce23ee336d64f8608f0e7eefc8c" +} diff --git a/.sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json b/.sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json new file mode 100644 index 000000000..e4436fe45 --- /dev/null +++ b/.sqlx/query-93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e.json @@ -0,0 +1,133 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, \n directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\" FROM openidprovider WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "client_secret", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "google_service_account_key", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "google_service_account_email", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "admin_email", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "directory_sync_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "directory_sync_interval", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "directory_sync_user_behavior: DirectorySyncUserBehavior", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "directory_sync_admin_behavior: DirectorySyncUserBehavior", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "directory_sync_target: DirectorySyncTarget", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "93ba83260f46d538c2063ec49bc12dd0f2a64cb2eb7f2dd13b9cba4ae441f70e" +} diff --git a/.sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json b/.sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json new file mode 100644 index 000000000..093e10db2 --- /dev/null +++ b/.sqlx/query-9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697.json @@ -0,0 +1,131 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, \"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\",\"google_service_account_key\",\"google_service_account_email\",\"admin_email\",\"directory_sync_enabled\",\"directory_sync_interval\",\"directory_sync_user_behavior\" \"directory_sync_user_behavior: _\",\"directory_sync_admin_behavior\" \"directory_sync_admin_behavior: _\",\"directory_sync_target\" \"directory_sync_target: _\" FROM \"openidprovider\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "client_secret", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "google_service_account_key", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "google_service_account_email", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "admin_email", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "directory_sync_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "directory_sync_interval", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "directory_sync_user_behavior: _", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "directory_sync_admin_behavior: _", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "directory_sync_target: _", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "9bbdc8988c6ab347d5e0b1808eb547357763cf9a1372f418c57ddc66ffab3697" +} diff --git a/.sqlx/query-a79fb5b30b7366e7145c147458bd00053846b927c80463b3fd836ea0af11cf07.json b/.sqlx/query-a79fb5b30b7366e7145c147458bd00053846b927c80463b3fd836ea0af11cf07.json deleted file mode 100644 index 52b9b0b16..000000000 --- a/.sqlx/query-a79fb5b30b7366e7145c147458bd00053846b927c80463b3fd836ea0af11cf07.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO \"openidprovider\" (\"name\",\"base_url\",\"client_id\",\"client_secret\",\"display_name\") VALUES ($1,$2,$3,$4,$5) RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "a79fb5b30b7366e7145c147458bd00053846b927c80463b3fd836ea0af11cf07" -} diff --git a/.sqlx/query-ab4d3df8aa0e1401824c5ed291a0601bc4b08439d2516802d06dfd5da3692fd3.json b/.sqlx/query-ab4d3df8aa0e1401824c5ed291a0601bc4b08439d2516802d06dfd5da3692fd3.json deleted file mode 100644 index ee841e1f8..000000000 --- a/.sqlx/query-ab4d3df8aa0e1401824c5ed291a0601bc4b08439d2516802d06dfd5da3692fd3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6 WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "ab4d3df8aa0e1401824c5ed291a0601bc4b08439d2516802d06dfd5da3692fd3" -} diff --git a/.sqlx/query-b68f3e802a65fd2541dfb72b7a02622b3ff076a2eb04b93e992e7af4aba3a486.json b/.sqlx/query-b68f3e802a65fd2541dfb72b7a02622b3ff076a2eb04b93e992e7af4aba3a486.json new file mode 100644 index 000000000..5e514d3cd --- /dev/null +++ b/.sqlx/query-b68f3e802a65fd2541dfb72b7a02622b3ff076a2eb04b93e992e7af4aba3a486.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"group\" SET \"name\" = $2,\"is_admin\" = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "b68f3e802a65fd2541dfb72b7a02622b3ff076a2eb04b93e992e7af4aba3a486" +} diff --git a/.sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json b/.sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json new file mode 100644 index 000000000..3dd332649 --- /dev/null +++ b/.sqlx/query-bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025.json @@ -0,0 +1,131 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, base_url, client_id, client_secret, display_name, google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", directory_sync_target \"directory_sync_target: DirectorySyncTarget\" FROM openidprovider LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "base_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "client_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "client_secret", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "google_service_account_key", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "google_service_account_email", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "admin_email", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "directory_sync_enabled", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "directory_sync_interval", + "type_info": "Int4" + }, + { + "ordinal": 11, + "name": "directory_sync_user_behavior: DirectorySyncUserBehavior", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 12, + "name": "directory_sync_admin_behavior: DirectorySyncUserBehavior", + "type_info": { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + } + }, + { + "ordinal": 13, + "name": "directory_sync_target: DirectorySyncTarget", + "type_info": { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "bd1ba52cce3e0529d93f54da70cc77b3a2445b02bba1b6de399fa576acd82025" +} diff --git a/.sqlx/query-e99464b4da5a21bcffda16866783d11c819eb078e392f1492467ae420800242d.json b/.sqlx/query-c56df2c35bd10d7864ad44fbb2ff8625e7b0089644916e2d8023f072806aa948.json similarity index 56% rename from .sqlx/query-e99464b4da5a21bcffda16866783d11c819eb078e392f1492467ae420800242d.json rename to .sqlx/query-c56df2c35bd10d7864ad44fbb2ff8625e7b0089644916e2d8023f072806aa948.json index 424aad372..2973e85c2 100644 --- a/.sqlx/query-e99464b4da5a21bcffda16866783d11c819eb078e392f1492467ae420800242d.json +++ b/.sqlx/query-c56df2c35bd10d7864ad44fbb2ff8625e7b0089644916e2d8023f072806aa948.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"group\" (\"name\") VALUES ($1) RETURNING id", + "query": "INSERT INTO \"group\" (\"name\",\"is_admin\") VALUES ($1,$2) RETURNING id", "describe": { "columns": [ { @@ -11,12 +11,13 @@ ], "parameters": { "Left": [ - "Text" + "Text", + "Bool" ] }, "nullable": [ false ] }, - "hash": "e99464b4da5a21bcffda16866783d11c819eb078e392f1492467ae420800242d" + "hash": "c56df2c35bd10d7864ad44fbb2ff8625e7b0089644916e2d8023f072806aa948" } diff --git a/.sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json b/.sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json new file mode 100644 index 000000000..b42f08b95 --- /dev/null +++ b/.sqlx/query-c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE \"openidprovider\" SET \"name\" = $2,\"base_url\" = $3,\"client_id\" = $4,\"client_secret\" = $5,\"display_name\" = $6,\"google_service_account_key\" = $7,\"google_service_account_email\" = $8,\"admin_email\" = $9,\"directory_sync_enabled\" = $10,\"directory_sync_interval\" = $11,\"directory_sync_user_behavior\" = $12,\"directory_sync_admin_behavior\" = $13,\"directory_sync_target\" = $14 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Int4", + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "c858bafd3e74f99d5720a9627f68677e44dea7faf04115dbe8200041b662040c" +} diff --git a/.sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json b/.sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json new file mode 100644 index 000000000..7b82f6643 --- /dev/null +++ b/.sqlx/query-ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, directory_sync_target = $13 WHERE id = $14", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Int4", + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_user_behavior", + "kind": { + "Enum": [ + "keep", + "disable", + "delete" + ] + } + } + }, + { + "Custom": { + "name": "dirsync_target", + "kind": { + "Enum": [ + "all", + "users", + "groups" + ] + } + } + }, + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ccea8776d7cdc2d6c87ecd54c36113b3e829aad2a0cdbfa00b83a81158b9aa96" +} diff --git a/.sqlx/query-0d04ddf1bf7cf709235a62b56ea6415eaf4f0e5c36dc95ee6b55e451f4715997.json b/.sqlx/query-cf5024dd625b2eae44a6befddd9f1dbee92dd6022c2c71649150f57200d99a23.json similarity index 58% rename from .sqlx/query-0d04ddf1bf7cf709235a62b56ea6415eaf4f0e5c36dc95ee6b55e451f4715997.json rename to .sqlx/query-cf5024dd625b2eae44a6befddd9f1dbee92dd6022c2c71649150f57200d99a23.json index 8662cc635..ffe0aa678 100644 --- a/.sqlx/query-0d04ddf1bf7cf709235a62b56ea6415eaf4f0e5c36dc95ee6b55e451f4715997.json +++ b/.sqlx/query-cf5024dd625b2eae44a6befddd9f1dbee92dd6022c2c71649150f57200d99a23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\" FROM \"group\"", + "query": "SELECT id, \"name\",\"is_admin\" FROM \"group\"", "describe": { "columns": [ { @@ -12,15 +12,21 @@ "ordinal": 1, "name": "name", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false ] }, - "hash": "0d04ddf1bf7cf709235a62b56ea6415eaf4f0e5c36dc95ee6b55e451f4715997" + "hash": "cf5024dd625b2eae44a6befddd9f1dbee92dd6022c2c71649150f57200d99a23" } diff --git a/.sqlx/query-e358d2799f31f9ed8374fa480bf5d431370a39d38eda251ab9462e6bf7eba642.json b/.sqlx/query-d3d49b2ae1c6a54c647af84e5316dea80a8163ef81b33a141e4fd2668b1bfb62.json similarity index 56% rename from .sqlx/query-e358d2799f31f9ed8374fa480bf5d431370a39d38eda251ab9462e6bf7eba642.json rename to .sqlx/query-d3d49b2ae1c6a54c647af84e5316dea80a8163ef81b33a141e4fd2668b1bfb62.json index 820c4c4a5..c2b35f7d9 100644 --- a/.sqlx/query-e358d2799f31f9ed8374fa480bf5d431370a39d38eda251ab9462e6bf7eba642.json +++ b/.sqlx/query-d3d49b2ae1c6a54c647af84e5316dea80a8163ef81b33a141e4fd2668b1bfb62.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT g.name name, COALESCE(ARRAY_AGG(DISTINCT u.username) FILTER (WHERE u.username IS NOT NULL), '{}') \"members!\", COALESCE(ARRAY_AGG(DISTINCT wn.name) FILTER (WHERE wn.name IS NOT NULL), '{}') \"vpn_locations!\" FROM \"group\" g LEFT JOIN \"group_user\" gu ON gu.group_id = g.id LEFT JOIN \"user\" u ON u.id = gu.user_id LEFT JOIN \"wireguard_network_allowed_group\" wnag ON wnag.group_id = g.id LEFT JOIN \"wireguard_network\" wn ON wn.id = wnag.network_id GROUP BY g.name", + "query": "SELECT g.name name, COALESCE(ARRAY_AGG(DISTINCT u.username) FILTER (WHERE u.username IS NOT NULL), '{}') \"members!\", COALESCE(ARRAY_AGG(DISTINCT wn.name) FILTER (WHERE wn.name IS NOT NULL), '{}') \"vpn_locations!\", is_admin FROM \"group\" g LEFT JOIN \"group_user\" gu ON gu.group_id = g.id LEFT JOIN \"user\" u ON u.id = gu.user_id LEFT JOIN \"wireguard_network_allowed_group\" wnag ON wnag.group_id = g.id LEFT JOIN \"wireguard_network\" wn ON wn.id = wnag.network_id GROUP BY g.name, g.id", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "vpn_locations!", "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, null, - null + null, + false ] }, - "hash": "e358d2799f31f9ed8374fa480bf5d431370a39d38eda251ab9462e6bf7eba642" + "hash": "d3d49b2ae1c6a54c647af84e5316dea80a8163ef81b33a141e4fd2668b1bfb62" } diff --git a/.sqlx/query-e0a5e5060afa2628112ae29b8a51a2f9a2abcb8d044ca90a536c6e567e320d69.json b/.sqlx/query-e0a5e5060afa2628112ae29b8a51a2f9a2abcb8d044ca90a536c6e567e320d69.json new file mode 100644 index 000000000..6fff00d5e --- /dev/null +++ b/.sqlx/query-e0a5e5060afa2628112ae29b8a51a2f9a2abcb8d044ca90a536c6e567e320d69.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_user (group_id, user_id) VALUES (1, $1), (1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e0a5e5060afa2628112ae29b8a51a2f9a2abcb8d044ca90a536c6e567e320d69" +} diff --git a/.sqlx/query-fedc6441d5b2371ac365cca09a9b44eb5e7cd0c3bdbb808b7898997328c91a1d.json b/.sqlx/query-fedc6441d5b2371ac365cca09a9b44eb5e7cd0c3bdbb808b7898997328c91a1d.json deleted file mode 100644 index ea086a87e..000000000 --- a/.sqlx/query-fedc6441d5b2371ac365cca09a9b44eb5e7cd0c3bdbb808b7898997328c91a1d.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, base_url, client_id, client_secret, display_name FROM openidprovider WHERE name = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "base_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "client_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "client_secret", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "display_name", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "fedc6441d5b2371ac365cca09a9b44eb5e7cd0c3bdbb808b7898997328c91a1d" -} diff --git a/Cargo.lock b/Cargo.lock index 09aa4e121..73bc710a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1131,6 +1131,7 @@ dependencies = [ "rust-ini", "secp256k1", "secrecy", + "semver", "serde", "serde_cbor_2", "serde_json", @@ -1153,6 +1154,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "trait-variant", "uaparser", "utoipa", "utoipa-swagger-ui", @@ -5648,6 +5650,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 2bd02980b..09bebc254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ secp256k1 = { version = "0.29", features = [ "global-context", ] } secrecy = { version = "0.8", features = ["serde"] } +semver = "1.0" serde = { version = "1.0", features = ["derive"] } # match version from webauthn-rs-core serde_cbor = { version = "0.12.0-dev", package = "serde_cbor_2" } @@ -94,6 +95,7 @@ totp-lite = { version = "2.0" } tower-http = { version = "0.5", features = ["fs", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +trait-variant = "0.1" uaparser = "0.6" # openapi utoipa = { version = "4", features = ["axum_extras"] } diff --git a/migrations/20241129095100_openid_directory_sync.down.sql b/migrations/20241129095100_openid_directory_sync.down.sql new file mode 100644 index 000000000..cf0db27cd --- /dev/null +++ b/migrations/20241129095100_openid_directory_sync.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE openidprovider DROP COLUMN google_service_account_key; +ALTER TABLE openidprovider DROP COLUMN google_service_account_email; +ALTER TABLE openidprovider DROP COLUMN admin_email; +ALTER TABLE openidprovider DROP COLUMN directory_sync_enabled; +ALTER TABLE openidprovider DROP COLUMN directory_sync_interval; +ALTER TABLE openidprovider DROP COLUMN directory_sync_user_behavior; +ALTER TABLE openidprovider DROP COLUMN directory_sync_admin_behavior; +DROP TYPE dirsync_user_behavior; diff --git a/migrations/20241129095100_openid_directory_sync.up.sql b/migrations/20241129095100_openid_directory_sync.up.sql new file mode 100644 index 000000000..044e7eda6 --- /dev/null +++ b/migrations/20241129095100_openid_directory_sync.up.sql @@ -0,0 +1,13 @@ +CREATE TYPE dirsync_user_behavior AS ENUM ( + 'keep', + 'disable', + 'delete' +); + +ALTER TABLE openidprovider ADD COLUMN google_service_account_key TEXT DEFAULT NULL; +ALTER TABLE openidprovider ADD COLUMN google_service_account_email TEXT DEFAULT NULL; +ALTER TABLE openidprovider ADD COLUMN admin_email TEXT DEFAULT NULL; +ALTER TABLE openidprovider ADD COLUMN directory_sync_enabled BOOLEAN DEFAULT FALSE NOT NULL; +ALTER TABLE openidprovider ADD COLUMN directory_sync_interval int4 DEFAULT 600 NOT NULL; +ALTER TABLE openidprovider ADD COLUMN directory_sync_user_behavior dirsync_user_behavior DEFAULT 'keep'::dirsync_user_behavior NOT NULL; +ALTER TABLE openidprovider ADD COLUMN directory_sync_admin_behavior dirsync_user_behavior DEFAULT 'keep'::dirsync_user_behavior NOT NULL; diff --git a/migrations/20241211115639_directory_sync_target.down.sql b/migrations/20241211115639_directory_sync_target.down.sql new file mode 100644 index 000000000..c50b53dfe --- /dev/null +++ b/migrations/20241211115639_directory_sync_target.down.sql @@ -0,0 +1 @@ +ALTER TABLE openidprovider DROP COLUMN directory_sync_target; diff --git a/migrations/20241211115639_directory_sync_target.up.sql b/migrations/20241211115639_directory_sync_target.up.sql new file mode 100644 index 000000000..e1afd0795 --- /dev/null +++ b/migrations/20241211115639_directory_sync_target.up.sql @@ -0,0 +1,7 @@ +CREATE TYPE dirsync_target AS ENUM ( + 'all', + 'users', + 'groups' +); + +ALTER TABLE openidprovider ADD COLUMN directory_sync_target dirsync_target DEFAULT 'all'::dirsync_target NOT NULL; diff --git a/migrations/20241212091902_add_admin_permission.down.sql b/migrations/20241212091902_add_admin_permission.down.sql new file mode 100644 index 000000000..dadb84845 --- /dev/null +++ b/migrations/20241212091902_add_admin_permission.down.sql @@ -0,0 +1 @@ +ALTER TABLE "group" DROP COLUMN is_admin; diff --git a/migrations/20241212091902_add_admin_permission.up.sql b/migrations/20241212091902_add_admin_permission.up.sql new file mode 100644 index 000000000..ec4c7ee9d --- /dev/null +++ b/migrations/20241212091902_add_admin_permission.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE "group" ADD COLUMN is_admin boolean NOT NULL DEFAULT FALSE; +-- First group created by migrations is the admin group, +-- which until now couldn't be deleted, so we can assume that it should +-- have the ID of 1. +UPDATE "group" SET is_admin = true WHERE id = 1; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index cc74f843f..b75470fcd 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -18,10 +18,12 @@ use serde::{Deserialize, Serialize}; use crate::{ appstate::AppState, - db::{Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session, SessionState, User}, + db::{ + models::group::Permission, Group, Id, OAuth2AuthorizedApp, OAuth2Token, Session, + SessionState, User, + }, error::WebError, handlers::SESSION_COOKIE_NAME, - server_config, }; pub static JWT_ISSUER: &str = "DefGuard"; @@ -168,8 +170,10 @@ impl SessionInfo { } } - fn contains_group(&self, group_name: &str) -> bool { - self.groups.iter().any(|group| group.name == group_name) + fn contains_any_group(&self, group_names: &[&str]) -> bool { + self.groups + .iter() + .any(|group| group_names.contains(&group.name.as_str())) } } @@ -193,11 +197,11 @@ where let Ok(groups) = user.member_of(&appstate.pool).await else { return Err(WebError::DbError("cannot fetch groups".into())); }; - let groupname = server_config().admin_groupname.clone(); + let is_admin = user.is_admin(&appstate.pool).await?; Ok(SessionInfo { session, user, - is_admin: groups.iter().any(|group| group.name == groupname), + is_admin, groups, }) } else { @@ -208,7 +212,7 @@ where #[macro_export] macro_rules! role { - ($name:ident, $($config_field:ident)*) => { + ($name:ident, $($permission:path)*) => { pub struct $name; #[async_trait] @@ -224,8 +228,14 @@ macro_rules! role { state: &S, ) -> Result { let session_info = SessionInfo::from_request_parts(parts, state).await?; + let appstate = AppState::from_ref(state); $( - if session_info.contains_group(&server_config().$config_field) { + let groups_with_permission = Group::find_by_permission( + &appstate.pool, + $permission, + ).await?; + let group_names = groups_with_permission.iter().map(|group| group.name.as_str()).collect::>(); + if session_info.contains_any_group(&group_names) { return Ok(Self {}); } )* @@ -235,9 +245,7 @@ macro_rules! role { }; } -role!(AdminRole, admin_groupname); -role!(UserAdminRole, admin_groupname useradmin_groupname); -role!(VpnRole, admin_groupname vpn_groupname); +role!(AdminRole, Permission::IsAdmin); // User authenticated by a valid access token pub struct AccessUserInfo(pub(crate) User); diff --git a/src/bin/defguard.rs b/src/bin/defguard.rs index f33d50d8b..d51996695 100644 --- a/src/bin/defguard.rs +++ b/src/bin/defguard.rs @@ -9,12 +9,13 @@ use defguard::{ db::{init_db, AppEvent, GatewayEvent, Settings, User}, enterprise::{ license::{run_periodic_license_check, set_cached_license, License}, - limits::{run_periodic_count_update, update_counts}, + limits::update_counts, }, grpc::{run_grpc_bidi_stream, run_grpc_server, GatewayMap, WorkerState}, init_dev_env, init_vpn_location, mail::{run_mail_handler, Mail}, run_web_server, + utility_thread::run_utility_thread, wireguard_peer_disconnect::run_periodic_peer_disconnect, wireguard_stats_purge::run_periodic_stats_purge, SERVER_CONFIG, VERSION, @@ -123,9 +124,8 @@ async fn main() -> Result<(), anyhow::Error> { res = run_mail_handler(mail_rx, pool.clone()) => error!("Mail handler returned early: {res:#?}"), res = run_periodic_peer_disconnect(pool.clone(), wireguard_tx) => error!("Periodic peer disconnect task returned early: {res:#?}"), res = run_periodic_stats_purge(pool.clone(), config.stats_purge_frequency.into(), config.stats_purge_threshold.into()), if !config.disable_stats_purge => error!("Periodic stats purge task returned early: {res:#?}"), - res = run_periodic_license_check(pool.clone()) => error!("Periodic license check task returned early: {res:#?}"), - // Temporary. Change to a database trigger when they are implemented. - res = run_periodic_count_update(&pool) => error!("Periodic count update task returned early: {res:#?}"), + res = run_periodic_license_check(&pool) => error!("Periodic license check task returned early: {res:#?}"), + res = run_utility_thread(&pool) => error!("Utility thread returned early: {res:#?}"), } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 6bbe54981..1e9d15a84 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,19 +57,6 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_GRPC_KEY")] pub grpc_key: Option, - #[arg(long, env = "DEFGUARD_ADMIN_GROUPNAME", default_value = "admin")] - pub admin_groupname: String, - - #[arg( - long, - env = "DEFGUARD_USERADMIN_GROUPNAME", - default_value = "useradmin" - )] - pub useradmin_groupname: String, - - #[arg(long, env = "DEFGUARD_VPN_GROUPNAME", default_value = "vpn")] - pub vpn_groupname: String, - #[arg( long, env = "DEFGUARD_DEFAULT_ADMIN_PASSWORD", diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 99ece048e..73b9ee495 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -1,16 +1,29 @@ +use std::fmt; + use model_derive::Model; -use sqlx::{query, query_as, query_scalar, Error as SqlxError, PgConnection, PgExecutor}; +use sqlx::{query, query_as, query_scalar, Error as SqlxError, FromRow, PgConnection, PgExecutor}; use utoipa::ToSchema; -use crate::{ - db::{models::error::ModelError, Id, NoId, User, WireguardNetwork}, - server_config, -}; +use crate::db::{models::error::ModelError, Id, NoId, User, WireguardNetwork}; + +#[derive(Debug)] +pub enum Permission { + IsAdmin, +} + +impl fmt::Display for Permission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IsAdmin => write!(f, "is_admin"), + } + } +} -#[derive(Debug, Model, ToSchema)] +#[derive(Debug, Model, ToSchema, FromRow)] pub struct Group { pub(crate) id: I, pub name: String, + pub is_admin: bool, } impl Group { @@ -19,6 +32,7 @@ impl Group { Self { id: NoId, name: name.into(), + is_admin: false, } } } @@ -28,9 +42,13 @@ impl Group { where E: PgExecutor<'e>, { - query_as!(Self, "SELECT id, name FROM \"group\" WHERE name = $1", name) - .fetch_optional(executor) - .await + query_as!( + Self, + "SELECT id, name, is_admin FROM \"group\" WHERE name = $1", + name + ) + .fetch_optional(executor) + .await } pub async fn member_usernames<'e, E>(&self, executor: E) -> Result, SqlxError> @@ -79,6 +97,53 @@ impl Group { .fetch_all(executor) .await } + + pub(crate) async fn find_by_permission<'e, E>( + executor: E, + permission: Permission, + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + let query = format!( + "SELECT id, name, is_admin FROM \"group\" WHERE {permission} = TRUE ORDER BY id" + ); + query_as(&query).fetch_all(executor).await + } + + pub(crate) async fn has_permission<'e, E>( + &self, + executor: E, + permission: Permission, + ) -> Result + where + E: PgExecutor<'e>, + { + let query_str = format!("SELECT {permission} FROM \"group\" WHERE id = $1"); + let result = query_scalar(&query_str) + .bind(self.id) + .fetch_optional(executor) + .await?; + Ok(result.unwrap_or(false)) + } + + pub(crate) async fn set_permission<'e, E>( + &self, + executor: E, + permission: Permission, + value: bool, + ) -> Result<(), SqlxError> + where + E: PgExecutor<'e>, + { + let query_str = format!("UPDATE \"group\" SET {permission} = $2 WHERE id = $1"); + query(&query_str) + .bind(self.id) + .bind(value) + .execute(executor) + .await?; + Ok(()) + } } impl WireguardNetwork { @@ -110,7 +175,9 @@ impl WireguardNetwork { transaction: &mut PgConnection, ) -> Result>, ModelError> { debug!("Returning a list of allowed groups for network {self}"); - let admin_group_name = &server_config().admin_groupname; + let admin_groups = + Group::find_by_permission(&mut *transaction, Permission::IsAdmin).await?; + // get allowed groups from DB let mut groups = self.fetch_allowed_groups(&mut *transaction).await?; @@ -119,9 +186,10 @@ impl WireguardNetwork { return Ok(None); } - // make sure admin group is included - if !groups.iter().any(|name| name == admin_group_name) { - groups.push(admin_group_name.to_string()); + for group in admin_groups { + if !groups.iter().any(|name| name == &group.name) { + groups.push(group.name); + } } Ok(Some(groups)) @@ -262,4 +330,41 @@ mod test { let members = group.member_usernames(&pool).await.unwrap(); assert!(members.is_empty()); } + + #[sqlx::test] + async fn test_group_permissions(pool: PgPool) { + let group = Group::new("admin2").save(&pool).await.unwrap(); + let user = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + user.add_to_group(&pool, &group).await.unwrap(); + assert!(!user.is_admin(&pool).await.unwrap()); + assert!(!group + .has_permission(&pool, Permission::IsAdmin) + .await + .unwrap()); + group + .set_permission(&pool, Permission::IsAdmin, true) + .await + .unwrap(); + + assert!(group + .has_permission(&pool, Permission::IsAdmin) + .await + .unwrap()); + assert!(user.is_admin(&pool).await.unwrap()); + let groups = Group::find_by_permission(&pool, Permission::IsAdmin) + .await + .unwrap(); + assert_eq!(groups.len(), 2); + assert!(groups.iter().any(|g| g.name == "admin2")); + } } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 77e289c08..093fc3d65 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -78,6 +78,7 @@ pub struct UserInfo { pub authorized_apps: Vec, pub is_active: bool, pub enrolled: bool, + pub is_admin: bool, } impl UserInfo { @@ -100,6 +101,7 @@ impl UserInfo { authorized_apps, is_active: user.is_active, enrolled: user.is_enrolled(), + is_admin: user.is_admin(pool).await?, }) } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 1fb18459d..2c649a35f 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -9,7 +9,7 @@ use argon2::{ }; use axum::http::StatusCode; use model_derive::Model; -use sqlx::{query, query_as, query_scalar, Error as SqlxError, PgExecutor, PgPool, Type}; +use sqlx::{query, query_as, query_scalar, Error as SqlxError, FromRow, PgExecutor, PgPool, Type}; use totp_lite::{totp_custom, Sha1}; use super::{ @@ -20,7 +20,7 @@ use super::{ }; use crate::{ auth::{EMAIL_CODE_DIGITS, TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, - db::{Id, NoId, Session}, + db::{models::group::Permission, Id, NoId, Session}, error::WebError, random::{gen_alphanumeric, gen_totp_secret}, server_config, @@ -66,7 +66,7 @@ pub struct UserDiagnostic { pub enrolled: bool, } -#[derive(Clone, Debug, Model, PartialEq, Serialize)] +#[derive(Clone, Debug, Model, PartialEq, Serialize, FromRow)] pub struct User { pub id: I, pub username: String, @@ -645,6 +645,24 @@ impl User { .await } + pub(crate) async fn find_many_by_emails<'e, E>( + executor: E, + emails: &[&str], + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + query_as( + "SELECT id, 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, is_active, openid_sub \ + FROM \"user\" WHERE email = ANY($1)", + ) + .bind(emails) + .fetch_all(executor) + .await + } + // FIXME: Remove `LIMIT 1` when `openid_sub` is unique. pub(crate) async fn find_by_sub<'e, E>( executor: E, @@ -684,7 +702,7 @@ impl User { { query_as!( Group, - "SELECT id, name FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id \ + "SELECT id, name, is_admin FROM \"group\" JOIN group_user ON \"group\".id = group_user.group_id \ WHERE group_user.user_id = $1", self.id ) @@ -827,29 +845,57 @@ impl User { pool: &PgPool, default_admin_pass: &str, ) -> Result<(), anyhow::Error> { - info!("Initializing admin user"); - let password_hash = hash_password(default_admin_pass)?; - - // create admin user - let result = query_scalar!( - "INSERT INTO \"user\" (username, password_hash, last_name, first_name, email) \ - VALUES ('admin', $1, 'Administrator', 'DefGuard', 'admin@defguard') \ - ON CONFLICT DO NOTHING \ - RETURNING id", - password_hash - ) - .fetch_optional(pool) - .await?; + debug!("Checking if some admin user already exists and creating one if not..."); + let admins = User::find_admins(pool).await?; + if admins.is_empty() { + let admin_groups = Group::find_by_permission(pool, Permission::IsAdmin).await?; + if admin_groups.is_empty() { + return Err(anyhow::anyhow!( + "No admin group and users found, or they are all disabled. \ + You'll need to create and assign the admin group manually, \ + as there must be at least one active admin user." + )); + } - // if new user was created add them to admin group (ID 1) - if let Some(new_user_id) = result { - info!("New admin user has been created, adding to Admin group..."); - query("INSERT INTO group_user (group_id, user_id) VALUES (1, $1)") - .bind(new_user_id) - .execute(pool) - .await?; - } + // create admin user + let password_hash = hash_password(default_admin_pass)?; + let result = query_scalar!( + "INSERT INTO \"user\" (username, password_hash, last_name, first_name, email) \ + VALUES ('admin', $1, 'Administrator', 'DefGuard', 'admin@defguard') \ + ON CONFLICT DO NOTHING \ + RETURNING id", + password_hash + ) + .fetch_optional(pool) + .await?; + // if new user was created add them to admin group, first one you find + // the groups are sorted by ID desceding, so it will often be the 1st one = the default admin group + if let Some(new_user_id) = result { + let admin_group_id = admin_groups + .first() + .ok_or(anyhow::anyhow!( + "No admin group found, can't create admin user" + ))? + .id; + info!("New admin user has been created, adding to Admin group..."); + query("INSERT INTO group_user (group_id, user_id) VALUES ($1, $2)") + .bind(admin_group_id) + .bind(new_user_id) + .execute(pool) + .await?; + info!("Admin user has been created as there was no other admin user"); + } else { + return Err(anyhow::anyhow!( + "A conflict occurred while trying to add a missing admin. \ + There is already a user with username 'admin' but he is not an admin or he is disabled. \ + You will need to assign someone the admin group manually or enable this admin user, \ + as there must be at least one active admin." + )); + } + } else { + debug!("Admin users already exists, skipping creation of the default admin user"); + } Ok(()) } @@ -858,7 +904,6 @@ impl User { E: PgExecutor<'e>, { Session::delete_all_for_user(executor, self.id).await?; - Ok(()) } @@ -882,6 +927,55 @@ impl User { .fetch_optional(executor) .await } + + /// Find users which emails are NOT in `user_emails`. + pub(crate) async fn exclude<'e, E>( + executor: E, + user_emails: &[&str], + ) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + // This can't be a macro since sqlx can't handle an array of slices in a macro. + query_as( + "SELECT id, 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, is_active, openid_sub \ + FROM \"user\" WHERE email NOT IN (SELECT * FROM UNNEST($1::TEXT[]))", + ) + .bind(user_emails) + .fetch_all(executor) + .await + } + + pub(crate) async fn is_admin<'e, E>(&self, executor: E) -> Result + where + E: PgExecutor<'e>, + { + query_scalar!("SELECT EXISTS (SELECT 1 FROM group_user gu LEFT JOIN \"group\" g ON gu.group_id = g.id \ + WHERE is_admin = true AND user_id = $1) \"bool!\"", self.id) + .fetch_one(executor) + .await + } + + /// Find all users that are admins and are active. + pub(crate) async fn find_admins<'e, E>(executor: E) -> Result, SqlxError> + where + E: PgExecutor<'e>, + { + query_as!( + Self, + " + SELECT u.id, u.username, u.password_hash, u.last_name, u.first_name, u.email, \ + u.phone, u.mfa_enabled, u.totp_enabled, u.email_mfa_enabled, \ + u.totp_secret, u.email_mfa_secret, u.mfa_method \"mfa_method: _\", u.recovery_codes, u.is_active, u.openid_sub \ + FROM \"user\" u \ + WHERE EXISTS (SELECT 1 FROM group_user gu LEFT JOIN \"group\" g ON gu.group_id = g.id \ + WHERE is_admin = true AND user_id = u.id) AND u.is_active = true" + ) + .fetch_all(executor) + .await + } } #[cfg(test)] @@ -1035,4 +1129,181 @@ mod test { ); assert!(henry.save(&pool).await.is_err()); } + + #[sqlx::test] + async fn test_is_admin(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + + let user = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let is_admin = user.is_admin(&pool).await.unwrap(); + + assert!(!is_admin); + + query!( + "INSERT INTO group_user (group_id, user_id) VALUES (1, $1)", + user.id + ) + .execute(&pool) + .await + .unwrap(); + + let is_admin = user.is_admin(&pool).await.unwrap(); + + assert!(is_admin); + } + + #[sqlx::test] + async fn test_find_admins(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + + let user = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let user2 = User::new( + "hpotter2", + Some("pass123"), + "Potter", + "Harry", + "h.potter2@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let user3 = User::new( + "hpotter3", + Some("pass123"), + "Potter", + "Harry", + "h.potter3@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + query!( + "INSERT INTO group_user (group_id, user_id) VALUES (1, $1), (1, $2)", + user.id, + user2.id, + ) + .execute(&pool) + .await + .unwrap(); + + let admins = User::find_admins(&pool).await.unwrap(); + assert_eq!(admins.len(), 2); + assert!(admins.iter().any(|u| u.id == user.id)); + assert!(admins.iter().any(|u| u.id == user2.id)); + } + + #[sqlx::test] + async fn test_get_missing(pool: PgPool) { + let user1 = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + let user2 = User::new( + "hpotter2", + Some("pass1234"), + "Potter2", + "Harry2", + "h.potter2@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + let albus = User::new( + "adumbledore", + Some("magic!"), + "Dumbledore", + "Albus", + "a.dumbledore@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let user_emails = vec![user1.email.as_str(), albus.email.as_str()]; + let users = User::exclude(&pool, &user_emails).await.unwrap(); + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, user2.id); + } + + #[sqlx::test] + async fn test_find_many_by_emails(pool: PgPool) { + let user1 = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + User::new( + "hpotter2", + Some("pass1234"), + "Potter2", + "Harry2", + "h.potter2@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + let albus = User::new( + "adumbledore", + Some("magic!"), + "Dumbledore", + "Albus", + "a.dumbledore@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let user_emails = vec![user1.email.as_str(), albus.email.as_str()]; + let users = User::find_many_by_emails(&pool, &user_emails) + .await + .unwrap(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, user1.id); + assert_eq!(users[1].id, albus.id); + } } diff --git a/src/enterprise/db/models/openid_provider.rs b/src/enterprise/db/models/openid_provider.rs index 5861e7698..ed6d09c20 100644 --- a/src/enterprise/db/models/openid_provider.rs +++ b/src/enterprise/db/models/openid_provider.rs @@ -1,8 +1,90 @@ +use std::fmt; + use model_derive::Model; -use sqlx::{query, query_as, Error as SqlxError, PgPool}; +use sqlx::{query, query_as, Error as SqlxError, PgPool, Type}; use crate::db::{Id, NoId}; +// The behavior when a user is deleted from the directory +// Keep: Keep the user, despite being deleted from the external provider's directory +// Disable: Disable the user +// Delete: Delete the user +#[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] +#[sqlx(type_name = "dirsync_user_behavior", rename_all = "snake_case")] +pub enum DirectorySyncUserBehavior { + Keep, + Disable, + Delete, +} + +impl fmt::Display for DirectorySyncUserBehavior { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + DirectorySyncUserBehavior::Keep => "keep", + DirectorySyncUserBehavior::Disable => "disable", + DirectorySyncUserBehavior::Delete => "delete", + } + ) + } +} + +impl From for DirectorySyncUserBehavior { + fn from(s: String) -> Self { + match s.to_lowercase().as_str() { + "keep" => DirectorySyncUserBehavior::Keep, + "disable" => DirectorySyncUserBehavior::Disable, + "delete" => DirectorySyncUserBehavior::Delete, + _ => { + warn!("Unknown directory sync user behavior passed: {}", s); + DirectorySyncUserBehavior::Keep + } + } + } +} + +// What to sync from the directory +// All: Sync both users and groups +// Users: Sync only users and their state +// Groups: Sync only groups (members without their state) +#[derive(Clone, Deserialize, Serialize, PartialEq, Type, Debug)] +#[sqlx(type_name = "dirsync_target", rename_all = "snake_case")] +pub enum DirectorySyncTarget { + All, + Users, + Groups, +} + +impl fmt::Display for DirectorySyncTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + DirectorySyncTarget::All => "all", + DirectorySyncTarget::Users => "users", + DirectorySyncTarget::Groups => "groups", + } + ) + } +} + +impl From for DirectorySyncTarget { + fn from(s: String) -> Self { + match s.to_lowercase().as_str() { + "all" => DirectorySyncTarget::All, + "users" => DirectorySyncTarget::Users, + "groups" => DirectorySyncTarget::Groups, + _ => { + warn!("Unknown directory sync target passed: {}", s); + DirectorySyncTarget::All + } + } + } +} + #[derive(Deserialize, Model, Serialize)] pub struct OpenIdProvider { pub id: I, @@ -11,6 +93,19 @@ pub struct OpenIdProvider { pub client_id: String, pub client_secret: String, pub display_name: Option, + // Specific stuff for Google + pub google_service_account_key: Option, + pub google_service_account_email: Option, + pub admin_email: Option, + pub directory_sync_enabled: bool, + // How often to sync the directory in seconds + pub directory_sync_interval: i32, + #[model(enum)] + pub directory_sync_user_behavior: DirectorySyncUserBehavior, + #[model(enum)] + pub directory_sync_admin_behavior: DirectorySyncUserBehavior, + #[model(enum)] + pub directory_sync_target: DirectorySyncTarget, } impl OpenIdProvider { @@ -21,6 +116,14 @@ impl OpenIdProvider { client_id: S, client_secret: S, display_name: Option, + google_service_account_key: Option, + google_service_account_email: Option, + admin_email: Option, + directory_sync_enabled: bool, + directory_sync_interval: i32, + directory_sync_user_behavior: DirectorySyncUserBehavior, + directory_sync_admin_behavior: DirectorySyncUserBehavior, + directory_sync_target: DirectorySyncTarget, ) -> Self { Self { id: NoId, @@ -29,18 +132,39 @@ impl OpenIdProvider { client_id: client_id.into(), client_secret: client_secret.into(), display_name, + google_service_account_key, + google_service_account_email, + admin_email, + directory_sync_enabled, + directory_sync_interval, + directory_sync_user_behavior, + directory_sync_admin_behavior, + directory_sync_target, } } pub async fn upsert(self, pool: &PgPool) -> Result, SqlxError> { if let Some(provider) = OpenIdProvider::::get_current(pool).await? { query!( - "UPDATE openidprovider SET name = $1, base_url = $2, client_id = $3, client_secret = $4, display_name = $5 WHERE id = $6", + "UPDATE openidprovider SET name = $1, \ + base_url = $2, client_id = $3, client_secret = $4, \ + display_name = $5, google_service_account_key = $6, google_service_account_email = $7, admin_email = $8, \ + directory_sync_enabled = $9, directory_sync_interval = $10, directory_sync_user_behavior = $11, directory_sync_admin_behavior = $12, \ + directory_sync_target = $13 \ + WHERE id = $14", self.name, self.base_url, self.client_id, self.client_secret, self.display_name, + self.google_service_account_key, + self.google_service_account_email, + self.admin_email, + self.directory_sync_enabled, + self.directory_sync_interval, + self.directory_sync_user_behavior as DirectorySyncUserBehavior, + self.directory_sync_admin_behavior as DirectorySyncUserBehavior, + self.directory_sync_target as DirectorySyncTarget, provider.id, ) .execute(pool) @@ -57,7 +181,12 @@ impl OpenIdProvider { pub async fn find_by_name(pool: &PgPool, name: &str) -> Result, SqlxError> { query_as!( OpenIdProvider, - "SELECT id, name, base_url, client_id, client_secret, display_name FROM openidprovider WHERE name = $1", + "SELECT id, name, base_url, client_id, client_secret, display_name, \ + google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, + directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ + directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ + directory_sync_target \"directory_sync_target: DirectorySyncTarget\" \ + FROM openidprovider WHERE name = $1", name ) .fetch_optional(pool) @@ -67,7 +196,12 @@ impl OpenIdProvider { pub async fn get_current(pool: &PgPool) -> Result, SqlxError> { query_as!( OpenIdProvider, - "SELECT id, name, base_url, client_id, client_secret, display_name FROM openidprovider LIMIT 1" + "SELECT id, name, base_url, client_id, client_secret, display_name, \ + google_service_account_key, google_service_account_email, admin_email, directory_sync_enabled, \ + directory_sync_interval, directory_sync_user_behavior \"directory_sync_user_behavior: DirectorySyncUserBehavior\", \ + directory_sync_admin_behavior \"directory_sync_admin_behavior: DirectorySyncUserBehavior\", \ + directory_sync_target \"directory_sync_target: DirectorySyncTarget\" \ + FROM openidprovider LIMIT 1" ) .fetch_optional(pool) .await diff --git a/src/enterprise/directory_sync/google.rs b/src/enterprise/directory_sync/google.rs new file mode 100644 index 000000000..58cba5f39 --- /dev/null +++ b/src/enterprise/directory_sync/google.rs @@ -0,0 +1,590 @@ +use std::{str::FromStr, time::Duration}; + +use super::{DirectoryGroup, DirectorySync, DirectorySyncError, DirectoryUser}; +use chrono::Utc; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use reqwest::Url; + +const SCOPES: &str = "openid email profile https://www.googleapis.com/auth/admin.directory.customer.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.user.readonly"; +const ACCESS_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const GROUPS_URL: &str = "https://admin.googleapis.com/admin/directory/v1/groups"; +const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const AUD: &str = "https://oauth2.googleapis.com/token"; +const ALL_USERS_URL: &str = "https://admin.googleapis.com/admin/directory/v1/users"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + iss: String, + scope: String, + aud: String, + sub: String, + exp: i64, + iat: i64, +} + +impl Claims { + fn new(iss: &str, sub: &str) -> Self { + let now = chrono::Utc::now(); + let now_timestamp = now.timestamp(); + let exp = now_timestamp + 3600; + Self { + iss: iss.into(), + scope: SCOPES.into(), + aud: AUD.to_string(), + sub: sub.into(), + exp, + iat: now_timestamp, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServiceAccountConfig { + private_key: String, + client_email: String, +} + +#[derive(Debug)] +pub struct GoogleDirectorySync { + service_account_config: ServiceAccountConfig, + access_token: Option, + token_expiry: Option>, + admin_email: String, +} + +/// +/// Google Directory API responses +/// +/// + +#[derive(Debug, Serialize, Deserialize)] +struct AccessTokenResponse { + #[serde(rename = "access_token")] + token: String, + expires_in: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GroupMember { + email: String, + status: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GroupMembersResponse { + members: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct User { + #[serde(rename = "primaryEmail")] + primary_email: String, + suspended: bool, +} + +impl From for DirectoryUser { + fn from(val: User) -> Self { + DirectoryUser { + email: val.primary_email, + active: !val.suspended, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct UsersResponse { + users: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct GroupsResponse { + groups: Vec, +} + +/// Parse a reqwest response and return the JSON body if the response is OK, otherwise map an error to a DirectorySyncError::RequestError +/// The context_message is used to provide more context to the error message. +async fn parse_response( + response: reqwest::Response, + context_message: &str, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + let status = &response.status(); + match status { + &reqwest::StatusCode::OK => Ok(response.json().await?), + _ => Err(DirectorySyncError::RequestError(format!( + "{context_message} Code returned: {status}. Details: {}", + response.text().await? + ))), + } +} + +impl GoogleDirectorySync { + pub fn new(private_key: &str, client_email: &str, admin_email: &str) -> Self { + Self { + service_account_config: ServiceAccountConfig { + private_key: private_key.into(), + client_email: client_email.into(), + }, + access_token: None, + token_expiry: None, + admin_email: admin_email.into(), + } + } + + pub async fn refresh_access_token(&mut self) -> Result<(), DirectorySyncError> { + let token_response = self.query_access_token().await?; + let expires_in = chrono::Duration::seconds(token_response.expires_in); + self.access_token = Some(token_response.token); + self.token_expiry = Some(Utc::now() + expires_in); + Ok(()) + } + + pub fn is_token_expired(&self) -> bool { + debug!("Checking if Google directory sync token is expired"); + self.token_expiry + .map(|expiry| expiry < Utc::now()) + // No token = expired token + .unwrap_or(true) + } + + #[cfg(not(test))] + async fn query_user_groups(&self, user_id: &str) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + + let access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(GROUPS_URL).unwrap(); + + url.query_pairs_mut() + .append_pair("userKey", user_id) + .append_pair("maxResults", "500"); + + let client = reqwest::Client::new(); + let response = client + .get(url) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", access_token), + ) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + parse_response(response, "Failed to query user groups from Google API.").await + } + + #[cfg(not(test))] + async fn query_groups(&self) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + + let access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(GROUPS_URL).unwrap(); + + url.query_pairs_mut() + .append_pair("customer", "my_customer") + .append_pair("maxResults", "500"); + + let client = reqwest::Client::builder().build()?; + let response = client + .get(url) + .header("Authorization", format!("Bearer {}", access_token)) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + parse_response(response, "Failed to query groups from Google API.").await + } + + #[cfg(not(test))] + async fn query_group_members( + &self, + group: &DirectoryGroup, + ) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + let access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + + let url_str = format!( + "https://admin.googleapis.com/admin/directory/v1/groups/{}/members", + group.id + ); + let mut url = Url::from_str(&url_str).unwrap(); + url.query_pairs_mut() + .append_pair("includeDerivedMembership", "true") + .append_pair("maxResults", "500"); + let client = reqwest::Client::builder().build()?; + let response = client + .get(url) + .header("Authorization", format!("Bearer {}", access_token)) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + parse_response(response, "Failed to query group members from Google API.").await + } + + fn build_token(&self) -> Result { + let claims = Claims::new(&self.service_account_config.client_email, &self.admin_email); + let key = EncodingKey::from_rsa_pem(self.service_account_config.private_key.as_bytes())?; + let token = encode(&Header::new(Algorithm::RS256), &claims, &key)?; + Ok(token) + } + + #[cfg(not(test))] + async fn query_access_token(&self) -> Result { + let token = self.build_token()?; + let mut url = Url::parse(ACCESS_TOKEN_URL).unwrap(); + url.query_pairs_mut() + .append_pair("grant_type", GRANT_TYPE) + .append_pair("assertion", &token); + let client = reqwest::Client::builder().build()?; + let response = client + .post(url) + .header(reqwest::header::CONTENT_LENGTH, 0) + .send() + .await?; + parse_response(response, "Failed to get access token from Google API.").await + } + + #[cfg(not(test))] + async fn query_all_users(&self) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + let access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(ALL_USERS_URL).unwrap(); + url.query_pairs_mut() + .append_pair("customer", "my_customer") + .append_pair("maxResults", "500") + .append_pair("showDeleted", "false"); + let client = reqwest::Client::builder().build()?; + let response = client + .get(url) + .header("Authorization", format!("Bearer {}", access_token)) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + parse_response(response, "Failed to query all users in the Google API.").await + } + + async fn query_test_connection(&self) -> Result<(), DirectorySyncError> { + let access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(ALL_USERS_URL).unwrap(); + url.query_pairs_mut() + .append_pair("customer", "my_customer") + .append_pair("maxResults", "1") + .append_pair("showDeleted", "false"); + let client = reqwest::Client::builder().build()?; + let result = client + .get(url) + .header("Authorization", format!("Bearer {}", access_token)) + .timeout(REQUEST_TIMEOUT) + .send() + .await?; + let _result: UsersResponse = + parse_response(result, "Failed to test connection to Google API.").await?; + Ok(()) + } +} + +impl DirectorySync for GoogleDirectorySync { + async fn get_groups(&self) -> Result, DirectorySyncError> { + debug!("Getting all groups"); + let response = self.query_groups().await?; + debug!("Got all groups response"); + Ok(response.groups) + } + + async fn get_user_groups( + &self, + user_id: &str, + ) -> Result, DirectorySyncError> { + debug!("Getting groups of user {user_id}"); + let response = self.query_user_groups(user_id).await?; + debug!("Got groups response for user {user_id}"); + Ok(response.groups) + } + + async fn get_group_members( + &self, + group: &DirectoryGroup, + ) -> Result, DirectorySyncError> { + debug!("Getting group members of group {}", group.name); + let response = self.query_group_members(group).await?; + debug!("Got group members response for group {}", group.name); + Ok(response + .members + .unwrap_or_default() + .into_iter() + // There may be arbitrary members in the group, we want only one that are also directory members + // Members without a status field don't belong to the directory + .filter(|m| m.status.is_some()) + .map(|m| m.email) + .collect()) + } + + async fn prepare(&mut self) -> Result<(), DirectorySyncError> { + debug!("Preparing Google directory sync..."); + if self.is_token_expired() { + debug!("Access token is expired, refreshing."); + self.refresh_access_token().await?; + debug!("Access token refreshed."); + } else { + debug!("Access token is still valid, skipping refresh."); + } + debug!("Google directory sync prepared."); + Ok(()) + } + + async fn get_all_users(&self) -> Result, DirectorySyncError> { + debug!("Getting all users"); + let response = self.query_all_users().await?; + debug!("Got all users response"); + Ok(response.users.into_iter().map(|u| u.into()).collect()) + } + + async fn test_connection(&self) -> Result<(), DirectorySyncError> { + self.query_test_connection().await?; + Ok(()) + } +} + +#[cfg(test)] +impl GoogleDirectorySync { + async fn query_user_groups(&self, user_id: &str) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + + let _access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(GROUPS_URL).expect("Invalid USER_GROUPS_URL has been set."); + + url.query_pairs_mut() + .append_pair("userKey", user_id) + .append_pair("max_results", "999"); + + Ok(GroupsResponse { + groups: vec![DirectoryGroup { + id: "1".into(), + name: "group1".into(), + }], + }) + } + + async fn query_groups(&self) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + + let _access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str(GROUPS_URL).expect("Invalid USER_GROUPS_URL has been set."); + + url.query_pairs_mut() + .append_pair("customer", "my_customer") + .append_pair("max_results", "999"); + + Ok(GroupsResponse { + groups: vec![ + DirectoryGroup { + id: "1".into(), + name: "group1".into(), + }, + DirectoryGroup { + id: "2".into(), + name: "group2".into(), + }, + DirectoryGroup { + id: "3".into(), + name: "group3".into(), + }, + ], + }) + } + + async fn query_group_members( + &self, + group: &DirectoryGroup, + ) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + + let _access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + + let url_str = format!( + "https://admin.googleapis.com/admin/directory/v1/groups/{}/members", + group.id + ); + let mut url = Url::from_str(&url_str).expect("Invalid GROUP_MEMBERS_URL has been set."); + url.query_pairs_mut() + .append_pair("includeDerivedMembership", "true"); + + Ok(GroupMembersResponse { + members: Some(vec![ + GroupMember { + email: "testuser@email.com".into(), + status: Some("ACTIVE".into()), + }, + GroupMember { + email: "testuserdisabled@email.com".into(), + status: Some("SUSPENDED".into()), + }, + GroupMember { + email: "testuser2@email.com".into(), + status: Some("ACTIVE".into()), + }, + ]), + }) + } + + async fn query_access_token(&self) -> Result { + let mut url: Url = ACCESS_TOKEN_URL + .parse() + .expect("Invalid ACCESS_TOKEN_URL has been set."); + url.query_pairs_mut() + .append_pair("grant_type", GRANT_TYPE) + .append_pair("assertion", "test_assertion"); + Ok(AccessTokenResponse { + token: "test_token_refreshed".into(), + expires_in: 3600, + }) + } + + async fn query_all_users(&self) -> Result { + if self.is_token_expired() { + return Err(DirectorySyncError::AccessTokenExpired); + } + let _access_token = self + .access_token + .as_ref() + .ok_or(DirectorySyncError::AccessTokenExpired)?; + let mut url = Url::from_str("https://admin.googleapis.com/admin/directory/v1/users") + .expect("Invalid USERS_URL has been set."); + url.query_pairs_mut().append_pair("customer", "my_customer"); + + Ok(UsersResponse { + users: vec![ + User { + primary_email: "testuser@email.com".into(), + suspended: false, + }, + User { + primary_email: "testuserdisabled@email.com".into(), + suspended: true, + }, + User { + primary_email: "testuser2@email.com".into(), + suspended: false, + }, + ], + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_token() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + + // no token + assert!(dirsync.is_token_expired()); + + // expired token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(chrono::Utc::now() - chrono::Duration::seconds(10000)); + assert!(dirsync.is_token_expired()); + + // valid token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(chrono::Utc::now() + chrono::Duration::seconds(10000)); + assert!(!dirsync.is_token_expired()); + + // no token + dirsync.access_token = Some("test_token".into()); + dirsync.token_expiry = Some(chrono::Utc::now() - chrono::Duration::seconds(10000)); + dirsync.refresh_access_token().await.unwrap(); + assert!(!dirsync.is_token_expired()); + assert_eq!(dirsync.access_token, Some("test_token_refreshed".into())); + } + + #[tokio::test] + async fn test_all_users() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + dirsync.refresh_access_token().await.unwrap(); + + let users = dirsync.get_all_users().await.unwrap(); + + assert_eq!(users.len(), 3); + assert_eq!(users[1].email, "testuserdisabled@email.com"); + assert!(!users[1].active); + } + + #[tokio::test] + async fn test_groups() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + dirsync.refresh_access_token().await.unwrap(); + + let groups = dirsync.get_groups().await.unwrap(); + + assert_eq!(groups.len(), 3); + + for i in 0..3 { + assert_eq!(groups[i].id, (i + 1).to_string()); + assert_eq!(groups[i].name, format!("group{}", i + 1)); + } + } + + #[tokio::test] + async fn test_user_groups() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + dirsync.refresh_access_token().await.unwrap(); + + let groups = dirsync.get_user_groups("testuser").await.unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].id, "1"); + assert_eq!(groups[0].name, "group1"); + } + + #[tokio::test] + async fn test_group_members() { + let mut dirsync = GoogleDirectorySync::new("private_key", "client_email", "admin_email"); + dirsync.refresh_access_token().await.unwrap(); + + let groups = dirsync.get_groups().await.unwrap(); + let members = dirsync.get_group_members(&groups[0]).await.unwrap(); + + assert_eq!(members.len(), 3); + assert_eq!(members[0], "testuser@email.com"); + } +} diff --git a/src/enterprise/directory_sync/mod.rs b/src/enterprise/directory_sync/mod.rs new file mode 100644 index 000000000..c55d5c6ba --- /dev/null +++ b/src/enterprise/directory_sync/mod.rs @@ -0,0 +1,1124 @@ +use std::collections::{HashMap, HashSet}; + +use sqlx::PgPool; + +use crate::{ + db::{Group, Id, User}, + enterprise::db::models::openid_provider::DirectorySyncUserBehavior, +}; +use sqlx::error::Error as SqlxError; +use thiserror::Error; + +use super::{ + db::models::openid_provider::{DirectorySyncTarget, OpenIdProvider}, + is_enterprise_enabled, +}; + +#[derive(Debug, Error)] +pub enum DirectorySyncError { + #[error("Database error: {0}")] + DbError(#[from] SqlxError), + #[error("Access token has expired or is not present. An issue may have occured while trying to obtain a new one.")] + AccessTokenExpired, + #[error("Processing a request to the provider's API failed: {0}")] + RequestError(String), + #[error( + "Failed to build a JWT token, required for communicating with the provider's API: {0}" + )] + JWTError(#[from] jsonwebtoken::errors::Error), + #[error("The selected provider {0} is not supported for directory sync")] + UnsupportedProvider(String), + #[error("Directory sync is not configured")] + NotConfigured, + #[error("Couldn't map provider's group to a Defguard group as it doesn't exist. There may be an issue with automatic group creation. Error details: {0}")] + DefGuardGroupNotFound(String), +} + +impl From for DirectorySyncError { + fn from(err: reqwest::Error) -> Self { + if err.is_decode() { + DirectorySyncError::RequestError(format!("There was an error while trying to decode provider's response, it may be malformed: {err}")) + } else if err.is_timeout() { + DirectorySyncError::RequestError(format!( + "The request to the provider's API timed out: {err}" + )) + } else { + DirectorySyncError::RequestError(err.to_string()) + } + } +} + +pub mod google; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DirectoryGroup { + pub id: String, + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DirectoryUser { + pub email: String, + // Users may be disabled/suspended in the directory + pub active: bool, +} + +#[trait_variant::make(Send)] +trait DirectorySync { + /// Get all groups in a directory + async fn get_groups(&self) -> Result, DirectorySyncError>; + + /// Get all groups a user is a member of + async fn get_user_groups( + &self, + user_id: &str, + ) -> Result, DirectorySyncError>; + + /// Get all members of a group + async fn get_group_members( + &self, + group: &DirectoryGroup, + ) -> Result, DirectorySyncError>; + + /// Prepare the directory sync client, e.g. get an access token + async fn prepare(&mut self) -> Result<(), DirectorySyncError>; + + /// Get all users in the directory + async fn get_all_users(&self) -> Result, DirectorySyncError>; + + /// Tests the connection to the directory + async fn test_connection(&self) -> Result<(), DirectorySyncError>; +} + +async fn sync_user_groups( + directory_sync: &T, + user: &User, + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + info!("Syncing groups of user {} with the directory", user.email); + let directory_groups = directory_sync.get_user_groups(&user.email).await?; + let directory_group_names: Vec<&str> = + directory_groups.iter().map(|g| g.name.as_str()).collect(); + + debug!( + "User {} is a member of {} groups in the directory: {:?}", + user.email, + directory_groups.len(), + directory_group_names + ); + + let mut transaction = pool.begin().await?; + + let current_groups = user.member_of(&mut *transaction).await?; + let current_group_names: Vec<&str> = current_groups.iter().map(|g| g.name.as_str()).collect(); + + debug!( + "User {} is a member of {} groups in Defguard: {:?}", + user.email, + current_groups.len(), + current_group_names + ); + + for group in &directory_group_names { + if !current_group_names.contains(group) { + create_and_add_to_group(user, group, pool).await?; + } + } + + for current_group in current_groups.iter() { + if !directory_group_names.contains(¤t_group.name.as_str()) { + debug!( + "Removing user {} from group {} as they are not a member of it in the directory", + user.email, current_group.name + ); + user.remove_from_group(&mut *transaction, current_group) + .await?; + } + } + + transaction.commit().await?; + + Ok(()) +} + +pub(crate) async fn test_directory_sync_connection( + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + #[cfg(not(test))] + if !is_enterprise_enabled() { + debug!("Enterprise is not enabled, skipping testing directory sync connection"); + return Ok(()); + } + + match get_directory_sync_client(pool).await { + Ok(mut dir_sync) => { + dir_sync.prepare().await?; + dir_sync.test_connection().await?; + } + Err(err) => { + error!("Failed to build directory sync client: {err}"); + } + } + + Ok(()) +} + +/// Sync user groups with the directory if directory sync is enabled and configured, skip otherwise +pub(crate) async fn sync_user_groups_if_configured( + user: &User, + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + #[cfg(not(test))] + if !is_enterprise_enabled() { + debug!("Enterprise is not enabled, skipping syncing user groups"); + return Ok(()); + } + + let provider = OpenIdProvider::get_current(pool).await?; + if !is_directory_sync_enabled(provider.as_ref()).await? { + debug!("Directory sync is disabled, skipping syncing user groups"); + return Ok(()); + } + + match get_directory_sync_client(pool).await { + Ok(mut dir_sync) => { + dir_sync.prepare().await?; + sync_user_groups(&dir_sync, user, pool).await?; + } + Err(err) => { + error!("Failed to build directory sync client: {err}"); + } + } + + Ok(()) +} + +async fn create_and_add_to_group( + user: &User, + group_name: &str, + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + debug!( + "Creating group {} if it doesn't exist and adding user {group_name} to it if they are not already a member", + user.email + ); + let group = if let Some(group) = Group::find_by_name(pool, group_name).await? { + debug!("Group {group_name} already exists, skipping creation"); + group + } else { + debug!("Group {group_name} didn't exist, creating it now"); + let new_group = Group::new(group_name).save(pool).await?; + debug!("Group {group_name} created"); + new_group + }; + + debug!( + "Adding user {} to group {group_name} if they are not already a member", + user.email + ); + user.add_to_group(pool, &group).await?; + debug!( + "User {} was added to group {group_name} if they weren't already a member", + user.email + ); + Ok(()) +} + +/// Sync all users' groups with the directory +async fn sync_all_users_groups( + directory_sync: &T, + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + info!("Syncing all users' groups with the directory, this may take a while..."); + let directory_groups = directory_sync.get_groups().await?; + debug!("Found {} groups to sync", directory_groups.len()); + + // Create a map of user: group to apply later + // It will be used to decide what groups should be removed from the user and what should be added + let mut user_group_map: HashMap> = HashMap::new(); + debug!( + "Beggining a construction of user-group mapping which will be applied later to Defguard" + ); + for group in &directory_groups { + match directory_sync.get_group_members(group).await { + Ok(members) => { + debug!( + "Group {} has {} members in the directory, adding them to the user-group mapping", + group.name, + members.len() + ); + for member in members { + // insert every user for now, we will filter non-existing users later + user_group_map + .entry(member) + .or_default() + .insert(&group.name); + } + } + Err(err) => { + error!( + "Failed to get group members for group {}. Error details: {}", + group.name, err + ); + } + } + } + + let mut transaction = pool.begin().await?; + debug!("User-group mapping construction done, starting to apply the changes to the database"); + let mut admin_count = User::find_admins(&mut *transaction).await?.len(); + for (user, groups) in user_group_map.into_iter() { + debug!("Syncing groups for user {user}"); + let Some(user) = User::find_by_email(pool, &user).await? else { + debug!("User {user} not found in the database, skipping group sync"); + continue; + }; + + let current_groups = user.member_of(&mut *transaction).await?; + debug!( + "User {} is a member of {} groups in Defguard: {:?}", + user.email, + current_groups.len(), + current_groups + ); + for current_group in ¤t_groups { + debug!( + "Checking if user {} is still a member of group {} in the directory", + user.email, current_group.name + ); + if !groups.contains(current_group.name.as_str()) { + if current_group.is_admin { + if admin_count == 1 { + error!( + "User {} is the last admin in the system, can't remove them from an admin group {}", + user.email, current_group.name + ); + continue; + } else { + debug!( + "Removing user {} from group {} as they are not a member of it in the directory", + user.email, current_group.name + ); + user.remove_from_group(&mut *transaction, current_group) + .await?; + admin_count -= 1; + } + } else { + debug!("Removing user {} from group {} as they are not a member of it in the directory", user.email, current_group.name); + user.remove_from_group(&mut *transaction, current_group) + .await?; + } + } + } + + for group in groups { + create_and_add_to_group(&user, group, pool).await?; + } + } + transaction.commit().await?; + + info!("Syncing all users' groups done."); + Ok(()) +} + +async fn get_directory_sync_client( + pool: &PgPool, +) -> Result { + debug!("Getting directory sync client"); + let provider_settings = OpenIdProvider::get_current(pool) + .await? + .ok_or(DirectorySyncError::NotConfigured)?; + + match provider_settings.name.as_str() { + "Google" => { + debug!("Google directory sync provider selected"); + match ( + provider_settings.google_service_account_key.as_ref(), + provider_settings.google_service_account_email.as_ref(), + provider_settings.admin_email.as_ref(), + ) { + (Some(key), Some(email), Some(admin_email)) => { + debug!("Google directory has all the configuration needed, proceeding with creating the sync client"); + let client = google::GoogleDirectorySync::new(key, email, admin_email); + debug!("Google directory sync client created"); + Ok(client) + } + _ => Err(DirectorySyncError::NotConfigured), + } + } + _ => Err(DirectorySyncError::UnsupportedProvider( + provider_settings.name.clone(), + )), + } +} + +async fn is_directory_sync_enabled( + provider: Option<&OpenIdProvider>, +) -> Result { + debug!("Checking if directory sync is enabled"); + if let Some(provider_settings) = provider { + debug!( + "Directory sync enabled state: {}", + provider_settings.directory_sync_enabled + ); + Ok(provider_settings.directory_sync_enabled) + } else { + debug!("No openid provider found, directory sync is disabled"); + Ok(false) + } +} + +async fn sync_all_users_state( + directory_sync: &T, + pool: &PgPool, +) -> Result<(), DirectorySyncError> { + info!("Syncing all users' state with the directory, this may take a while..."); + let mut transaction = pool.begin().await?; + let all_users = directory_sync.get_all_users().await?; + let settings = OpenIdProvider::get_current(pool) + .await? + .ok_or(DirectorySyncError::NotConfigured)?; + + let user_behavior = settings.directory_sync_user_behavior; + let admin_behavior = settings.directory_sync_admin_behavior; + + let emails = all_users + .iter() + // We want to filter out the main admin user, as he shouldn't be deleted + .map(|u| u.email.as_str()) + .collect::>(); + let missing_users = User::exclude(&mut *transaction, &emails) + .await? + .into_iter() + .collect::>>(); + + let disabled_users_emails = all_users + .iter() + .filter(|u| !u.active) + .map(|u| u.email.as_str()) + .collect::>(); + let users_to_disable = + User::find_many_by_emails(&mut *transaction, &disabled_users_emails).await?; + + let enabled_users_emails = all_users + .iter() + .filter(|u| u.active) + .map(|u| u.email.as_str()) + .collect::>(); + let users_to_enable = + User::find_many_by_emails(&mut *transaction, &enabled_users_emails).await?; + + debug!( + "There are {} disabled users in the directory, disabling them in Defguard...", + users_to_disable.len() + ); + + for mut user in users_to_disable { + if user.is_active { + debug!( + "Disabling user {} because they are disabled in the directory", + user.email + ); + user.is_active = false; + user.save(&mut *transaction).await?; + } else { + debug!("User {} is already disabled, skipping", user.email); + } + } + debug!("Done processing disabled users"); + + debug!( + "There are {} users missing from the directory but present in Defguard, \ + deciding what to do next based on the following settings: user action: {}, admin action: {}", + missing_users.len(), + user_behavior, + admin_behavior + ); + let mut admin_count = User::find_admins(&mut *transaction).await?.len(); + for mut user in missing_users { + match user.is_admin(&mut *transaction).await? { + true => match admin_behavior { + DirectorySyncUserBehavior::Keep => { + debug!( + "Keeping admin {} despite not being present in the directory", + user.email + ); + } + DirectorySyncUserBehavior::Disable => { + if user.is_active { + if admin_count == 1 { + error!( + "Admin {} is the last admin in the system, can't disable them", + user.email + ); + continue; + } else { + info!( + "Disabling admin {} because they are not present in the directory and the admin behavior setting is set to disable", + user.email + ); + user.is_active = false; + user.save(&mut *transaction).await?; + admin_count -= 1; + } + } else { + debug!( + "Admin {} is already disabled in Defguard, skipping", + user.email + ); + } + } + DirectorySyncUserBehavior::Delete => { + if admin_count == 1 { + error!( + "Admin {} is the last admin in the system, can't delete them", + user.email + ); + continue; + } else { + info!( + "Deleting admin {} because they are not present in the directory", + user.email + ); + user.delete(&mut *transaction).await?; + admin_count -= 1; + } + } + }, + false => match user_behavior { + DirectorySyncUserBehavior::Keep => { + debug!( + "Keeping user {} despite not being present in the directory", + user.email + ); + } + DirectorySyncUserBehavior::Disable => { + if user.is_active { + info!( + "Disabling user {} because they are not present in the directory and the user behavior setting is set to disable", + user.email + ); + user.is_active = false; + user.save(&mut *transaction).await?; + } else { + debug!( + "User {} is already disabled in Defguard, skipping", + user.email + ); + } + } + DirectorySyncUserBehavior::Delete => { + info!( + "Deleting user {} because they are not present in the directory", + user.email + ); + user.delete(&mut *transaction).await?; + } + }, + } + } + debug!("Done processing missing users"); + + debug!( + "There are {} enabled users in the directory, enabling them in Defguard if they were previously disabled", + users_to_enable.len() + ); + for mut user in users_to_enable { + if user.is_active { + debug!("User {} is already enabled, skipping", user.email); + continue; + } else { + debug!( + "Enabling user {} because they are enabled in the directory and disabled in Defguard", + user.email + ); + user.is_active = true; + user.save(&mut *transaction).await?; + } + } + debug!("Done processing enabled users"); + transaction.commit().await?; + info!("Syncing all users' state with the directory done"); + Ok(()) +} + +// The default inverval for the directory sync job +const DIRECTORY_SYNC_INTERVAL: u64 = 60 * 10; + +pub(crate) async fn get_directory_sync_interval(pool: &PgPool) -> u64 { + if let Ok(Some(provider_settings)) = OpenIdProvider::get_current(pool).await { + provider_settings + .directory_sync_interval + .try_into() + .unwrap_or(DIRECTORY_SYNC_INTERVAL) + } else { + DIRECTORY_SYNC_INTERVAL + } +} + +pub(crate) async fn do_directory_sync(pool: &PgPool) -> Result<(), DirectorySyncError> { + #[cfg(not(test))] + if !is_enterprise_enabled() { + debug!("Enterprise is not enabled, skipping performing directory sync"); + return Ok(()); + } + + // TODO: The settings are retrieved many times + let provider = OpenIdProvider::get_current(pool).await?; + + if !is_directory_sync_enabled(provider.as_ref()).await? { + debug!("Directory sync is disabled, skipping performing directory sync"); + return Ok(()); + } + + let sync_target = provider + .ok_or(DirectorySyncError::NotConfigured)? + .directory_sync_target; + + match get_directory_sync_client(pool).await { + Ok(mut dir_sync) => { + // TODO: Directory sync's access token is dropped every time, find a way to preserve it + // Same goes for Etags, those could be used to reduce the amount of data transferred. Some way + // of preserving them should be implemented. + dir_sync.prepare().await?; + if matches!( + sync_target, + DirectorySyncTarget::All | DirectorySyncTarget::Users + ) { + sync_all_users_state(&dir_sync, pool).await?; + } + if matches!( + sync_target, + DirectorySyncTarget::All | DirectorySyncTarget::Groups + ) { + sync_all_users_groups(&dir_sync, pool).await?; + } + } + Err(err) => { + error!("Failed to build directory sync client: {err}"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{ + config::DefGuardConfig, enterprise::db::models::openid_provider::DirectorySyncTarget, + SERVER_CONFIG, + }; + use secrecy::ExposeSecret; + + async fn make_test_provider( + pool: &PgPool, + user_behavior: DirectorySyncUserBehavior, + admin_behavior: DirectorySyncUserBehavior, + target: DirectorySyncTarget, + ) -> OpenIdProvider { + let current = OpenIdProvider::get_current(pool).await.unwrap(); + + if let Some(provider) = current { + provider.delete(pool).await.unwrap(); + } + + OpenIdProvider::new( + "Google".to_string(), + "base_url".to_string(), + "client_id".to_string(), + "client_secret".to_string(), + Some("display_name".to_string()), + Some("google_service_account_key".to_string()), + Some("google_service_account_email".to_string()), + Some("admin_email".to_string()), + true, + 60, + user_behavior, + admin_behavior, + target, + ) + .save(pool) + .await + .unwrap() + } + + async fn make_test_user(name: &str, pool: &PgPool) -> User { + User::new( + name, + None, + "lastname", + "firstname", + format!("{name}@email.com").as_str(), + None, + ) + .save(pool) + .await + .unwrap() + } + + async fn get_test_user(pool: &PgPool, name: &str) -> Option> { + User::find_by_username(pool, name).await.unwrap() + } + + async fn make_admin(pool: &PgPool, user: &User) { + let admin_group = Group::find_by_name(pool, "admin").await.unwrap().unwrap(); + user.add_to_group(pool, &admin_group).await.unwrap(); + } + + // Keep both users and admins + #[sqlx::test] + async fn test_users_state_keep_both(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + make_test_user("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + sync_all_users_state(&client, &pool).await.unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + } + + // Delete users, keep admins + #[sqlx::test] + async fn test_users_state_delete_users(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + make_test_user("testuser", &pool).await; + make_admin(&pool, &user1).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + + sync_all_users_state(&client, &pool).await.unwrap(); + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + } + + // Delete admins, keep users + #[sqlx::test] + async fn test_users_state_delete_admins(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + let _ = make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + let user3 = make_test_user("user3", &pool).await; + make_test_user("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + sync_all_users_state(&client, &pool).await.unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + } + + #[sqlx::test] + async fn test_users_state_delete_both(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + User::init_admin_user(&pool, config.default_admin_password.expose_secret()) + .await + .unwrap(); + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + let user3 = make_test_user("user3", &pool).await; + make_test_user("testuser", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + assert!(get_test_user(&pool, "user1").await.is_some()); + assert!(get_test_user(&pool, "user2").await.is_some()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + sync_all_users_state(&client, &pool).await.unwrap(); + + assert!( + get_test_user(&pool, "user1").await.is_none() + || get_test_user(&pool, "user3").await.is_none() + ); + assert!( + get_test_user(&pool, "user1").await.is_some() + || get_test_user(&pool, "user3").await.is_some() + ); + assert!(get_test_user(&pool, "user2").await.is_none()); + assert!(get_test_user(&pool, "testuser").await.is_some()); + } + + #[sqlx::test] + async fn test_users_state_disable_users(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Disable, + DirectorySyncUserBehavior::Keep, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + make_test_user("testuser", &pool).await; + make_test_user("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + sync_all_users_state(&client, &pool).await.unwrap(); + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(user1.is_active); + assert!(!user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + + #[sqlx::test] + async fn test_users_state_disable_admins(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Keep, + DirectorySyncUserBehavior::Disable, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + let user1 = make_test_user("user1", &pool).await; + make_test_user("user2", &pool).await; + let user3 = make_test_user("user3", &pool).await; + make_test_user("testuser", &pool).await; + make_test_user("testuserdisabled", &pool).await; + make_admin(&pool, &user1).await; + make_admin(&pool, &user3).await; + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(user1.is_active); + assert!(user2.is_active); + assert!(user3.is_active); + assert!(testuser.is_active); + assert!(testuserdisabled.is_active); + + sync_all_users_state(&client, &pool).await.unwrap(); + + let user1 = get_test_user(&pool, "user1").await.unwrap(); + let user2 = get_test_user(&pool, "user2").await.unwrap(); + let user3 = get_test_user(&pool, "user3").await.unwrap(); + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + assert!(!user1.is_active || !user3.is_active); + assert!(user1.is_active || user3.is_active); + assert!(user2.is_active); + assert!(testuser.is_active); + assert!(!testuserdisabled.is_active); + } + + #[sqlx::test] + async fn test_users_groups(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + make_test_user("testuser", &pool).await; + make_test_user("testuser2", &pool).await; + make_test_user("testuserdisabled", &pool).await; + sync_all_users_groups(&client, &pool).await.unwrap(); + + let mut groups = Group::all(&pool).await.unwrap(); + + let testuser = get_test_user(&pool, "testuser").await.unwrap(); + let testuser2 = get_test_user(&pool, "testuser2").await.unwrap(); + let testuserdisabled = get_test_user(&pool, "testuserdisabled").await.unwrap(); + + let testuser_groups = testuser.member_of(&pool).await.unwrap(); + let testuser2_groups = testuser2.member_of(&pool).await.unwrap(); + let testuserdisabled_groups = testuserdisabled.member_of(&pool).await.unwrap(); + + assert_eq!(testuser_groups.len(), 3); + assert_eq!(testuser2_groups.len(), 3); + assert_eq!(testuserdisabled_groups.len(), 3); + groups.sort_by(|a, b| a.name.cmp(&b.name)); + + let group_present = + |groups: &Vec>, name: &str| groups.iter().any(|g| g.name == name); + + assert!(group_present(&testuser_groups, "group1")); + assert!(group_present(&testuser_groups, "group2")); + assert!(group_present(&testuser_groups, "group3")); + + assert!(group_present(&testuser2_groups, "group1")); + assert!(group_present(&testuser2_groups, "group2")); + assert!(group_present(&testuser2_groups, "group3")); + + assert!(group_present(&testuserdisabled_groups, "group1")); + assert!(group_present(&testuserdisabled_groups, "group2")); + assert!(group_present(&testuserdisabled_groups, "group3")); + } + + #[sqlx::test] + async fn test_sync_user_groups(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + sync_user_groups_if_configured(&user, &pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + let group = Group::find_by_name(&pool, "group1").await.unwrap().unwrap(); + assert_eq!(user_groups[0].id, group.id); + } + + #[sqlx::test] + async fn test_sync_target_users(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Users, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + } + + #[sqlx::test] + async fn test_sync_target_all(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + make_test_user("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_none()); + } + + #[sqlx::test] + async fn test_sync_target_groups(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::Groups, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + let user = make_test_user("testuser", &pool).await; + make_test_user("user2", &pool).await; + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 0); + do_directory_sync(&pool).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 3); + let user2 = get_test_user(&pool, "user2").await; + assert!(user2.is_some()); + } + + #[sqlx::test] + async fn test_sync_unassign_last_admin_group(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // Make one admin and check if he's deleted + let user = make_test_user("testuser", &pool).await; + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + user.add_to_group(&pool, &admin_grp).await.unwrap(); + let user_groups = user.member_of(&pool).await.unwrap(); + assert_eq!(user_groups.len(), 1); + assert!(user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool).await.unwrap(); + + // He should still be an admin as it's the last one + assert!(user.is_admin(&pool).await.unwrap()); + + // Make another admin and check if one of them is deleted + let user2 = make_test_user("testuser2", &pool).await; + user2.add_to_group(&pool, &admin_grp).await.unwrap(); + + do_directory_sync(&pool).await.unwrap(); + + let admins = User::find_admins(&pool).await.unwrap(); + // There should be only one admin left + assert_eq!(admins.len(), 1); + + let defguard_user = make_test_user("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + + do_directory_sync(&pool).await.unwrap(); + } + + #[sqlx::test] + async fn test_sync_delete_last_admin_user(pool: PgPool) { + let config = DefGuardConfig::new_test_config(); + let _ = SERVER_CONFIG.set(config.clone()); + make_test_provider( + &pool, + DirectorySyncUserBehavior::Delete, + DirectorySyncUserBehavior::Delete, + DirectorySyncTarget::All, + ) + .await; + let mut client = get_directory_sync_client(&pool).await.unwrap(); + client.prepare().await.unwrap(); + + // a user that's not in the directory + let defguard_user = make_test_user("defguard", &pool).await; + make_admin(&pool, &defguard_user).await; + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + do_directory_sync(&pool).await.unwrap(); + + // The user should still be an admin + assert!(defguard_user.is_admin(&pool).await.unwrap()); + + // remove his admin status + let admin_grp = Group::find_by_name(&pool, "admin").await.unwrap().unwrap(); + defguard_user + .remove_from_group(&pool, &admin_grp) + .await + .unwrap(); + + do_directory_sync(&pool).await.unwrap(); + let user = User::find_by_username(&pool, "defguard").await.unwrap(); + assert!(user.is_none()); + } +} diff --git a/src/enterprise/handlers/enterprise_settings.rs b/src/enterprise/handlers/enterprise_settings.rs index 07442b9b3..29cee9e85 100644 --- a/src/enterprise/handlers/enterprise_settings.rs +++ b/src/enterprise/handlers/enterprise_settings.rs @@ -19,7 +19,7 @@ pub async fn get_enterprise_settings( session.user.username ); let settings = EnterpriseSettings::get(&appstate.pool).await?; - info!( + debug!( "User {} retrieved enterprise settings", session.user.username ); diff --git a/src/enterprise/handlers/mod.rs b/src/enterprise/handlers/mod.rs index 5816a2d75..3628b274a 100644 --- a/src/enterprise/handlers/mod.rs +++ b/src/enterprise/handlers/mod.rs @@ -1,5 +1,5 @@ use crate::{ - auth::{SessionInfo, UserAdminRole}, + auth::{AdminRole, SessionInfo}, handlers::{ApiResponse, ApiResult}, }; @@ -57,7 +57,7 @@ pub async fn check_enterprise_status() -> ApiResult { } /// Gets full information about enterprise status. -pub async fn check_enterprise_info(_admin: UserAdminRole, _session: SessionInfo) -> ApiResult { +pub async fn check_enterprise_info(_admin: AdminRole, _session: SessionInfo) -> ApiResult { let enterprise_enabled = is_enterprise_enabled(); let needs_license = needs_enterprise_license(); let license = get_cached_license(); diff --git a/src/enterprise/handlers/openid_login.rs b/src/enterprise/handlers/openid_login.rs index e68234478..f4db9e8b5 100644 --- a/src/enterprise/handlers/openid_login.rs +++ b/src/enterprise/handlers/openid_login.rs @@ -27,7 +27,10 @@ use super::LicenseInfo; use crate::{ appstate::AppState, db::{Id, Settings, User}, - enterprise::{db::models::openid_provider::OpenIdProvider, limits::update_counts}, + enterprise::{ + db::models::openid_provider::OpenIdProvider, + directory_sync::sync_user_groups_if_configured, limits::update_counts, + }, error::WebError, handlers::{ auth::create_session, @@ -485,6 +488,12 @@ pub(crate) async fn auth_callback( } if let Some(user_info) = user_info { + if let Err(err) = sync_user_groups_if_configured(&user, &appstate.pool).await { + error!( + "Failed to sync user groups for user {} with the directory while the user was logging in through an external provider: {err:?}", + user.username + ); + } let url = if let Some(openid_cookie) = private_cookies.get(SIGN_IN_COOKIE_NAME) { debug!("Found OpenID session cookie, returning the redirect URL stored in it."); let url = openid_cookie.value().to_string(); diff --git a/src/enterprise/handlers/openid_providers.rs b/src/enterprise/handlers/openid_providers.rs index a477683b2..2e380f238 100644 --- a/src/enterprise/handlers/openid_providers.rs +++ b/src/enterprise/handlers/openid_providers.rs @@ -3,23 +3,34 @@ use axum::{ http::StatusCode, Json, }; +use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey}; use serde_json::json; use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::db::models::openid_provider::OpenIdProvider, + enterprise::{ + db::models::openid_provider::OpenIdProvider, directory_sync::test_directory_sync_connection, + }, handlers::{ApiResponse, ApiResult}, }; #[derive(Debug, Deserialize, Serialize)] pub struct AddProviderData { - name: String, - base_url: String, - client_id: String, - client_secret: String, - display_name: Option, + pub name: String, + pub base_url: String, + pub client_id: String, + pub client_secret: String, + pub display_name: Option, + pub admin_email: Option, + pub google_service_account_email: Option, + pub google_service_account_key: Option, + pub directory_sync_enabled: bool, + pub directory_sync_interval: i32, + pub directory_sync_user_behavior: String, + pub directory_sync_admin_behavior: String, + pub directory_sync_target: String, } #[derive(Debug, Deserialize, Serialize)] @@ -27,25 +38,6 @@ pub struct DeleteProviderData { name: String, } -impl AddProviderData { - #[must_use] - pub fn new( - name: &str, - base_url: &str, - client_id: &str, - client_secret: &str, - display_name: Option<&str>, - ) -> Self { - Self { - name: name.to_string(), - base_url: base_url.to_string(), - client_id: client_id.to_string(), - client_secret: client_secret.to_string(), - display_name: display_name.map(|s| s.to_string()), - } - } -} - pub async fn add_openid_provider( _license: LicenseInfo, _admin: AdminRole, @@ -53,6 +45,36 @@ pub async fn add_openid_provider( State(appstate): State, Json(provider_data): Json, ) -> ApiResult { + let current_provider = OpenIdProvider::get_current(&appstate.pool).await?; + + // The key is sent from the frontend only when user explicitly changes it, as we never send it back. + // Check if the thing received from the frontend is a valid RSA private key (signaling user intent to change key) + // or is it just some empty string or other junk. + let private_key = match &provider_data.google_service_account_key { + Some(key) => { + if RsaPrivateKey::from_pkcs8_pem(key).is_ok() { + debug!( + "User {} provided a valid RSA private key for provider's directory sync, using it", + session.user.username + ); + provider_data.google_service_account_key.clone() + } else if let Some(provider) = ¤t_provider { + debug!( + "User {} did not provide a valid RSA private key for provider's directory sync or the key did not change, using the existing key", + session.user.username + ); + provider.google_service_account_key.clone() + } else { + warn!( + "User {} did not provide a valid RSA private key for provider's directory sync", + session.user.username + ); + None + } + } + None => None, + }; + // Currently, we only support one OpenID provider at a time let new_provider = OpenIdProvider::new( provider_data.name, @@ -60,6 +82,14 @@ pub async fn add_openid_provider( provider_data.client_id, provider_data.client_secret, provider_data.display_name, + private_key, + provider_data.google_service_account_email, + provider_data.admin_email, + provider_data.directory_sync_enabled, + provider_data.directory_sync_interval, + provider_data.directory_sync_user_behavior.into(), + provider_data.directory_sync_admin_behavior.into(), + provider_data.directory_sync_target.into(), ) .upsert(&appstate.pool) .await?; @@ -84,10 +114,14 @@ pub async fn get_current_openid_provider( State(appstate): State, ) -> ApiResult { match OpenIdProvider::get_current(&appstate.pool).await? { - Some(provider) => Ok(ApiResponse { - json: json!(provider), - status: StatusCode::OK, - }), + Some(mut provider) => { + // Get rid of it, it should stay on the backend only. + provider.google_service_account_key = None; + Ok(ApiResponse { + json: json!(provider), + status: StatusCode::OK, + }) + } None => Ok(ApiResponse { json: json!({}), status: StatusCode::NOT_FOUND, @@ -177,3 +211,34 @@ pub async fn list_openid_providers( status: StatusCode::OK, }) } + +pub async fn test_dirsync_connection( + _license: LicenseInfo, + _admin: AdminRole, + session: SessionInfo, + State(appstate): State, +) -> ApiResult { + debug!( + "User {} testing directory sync connection", + session.user.username + ); + + if let Err(err) = test_directory_sync_connection(&appstate.pool).await { + error!( + "User {} tested directory sync connection, the connection failed: {}", + session.user.username, err + ); + return Ok(ApiResponse { + json: json!({ "message": err.to_string(), "success": false }), + status: StatusCode::OK, + }); + } + debug!( + "User {} tested directory sync connection, the connection was successful", + session.user.username + ); + Ok(ApiResponse { + json: json!({ "message": "Connection successful", "success": true }), + status: StatusCode::OK, + }) +} diff --git a/src/enterprise/license.rs b/src/enterprise/license.rs index cd22a869a..08c65d978 100644 --- a/src/enterprise/license.rs +++ b/src/enterprise/license.rs @@ -536,7 +536,7 @@ pub fn update_cached_license(key: Option<&str>) -> Result<(), LicenseError> { const RENEWAL_TIME: TimeDelta = TimeDelta::hours(24); const MAX_OVERDUE_TIME: TimeDelta = TimeDelta::days(14); -pub async fn run_periodic_license_check(pool: PgPool) -> Result<(), LicenseError> { +pub async fn run_periodic_license_check(pool: &PgPool) -> Result<(), LicenseError> { let config = server_config(); let mut check_period: Duration = *config.check_period; info!( @@ -598,8 +598,8 @@ pub async fn run_periodic_license_check(pool: PgPool) -> Result<(), LicenseError info!("License requires renewal, renewing license..."); check_period = *config.check_period_renewal_window; debug!("Changing check period to {}", format_duration(check_period)); - match renew_license(&pool).await { - Ok(new_license_key) => match save_license_key(&pool, &new_license_key).await { + match renew_license(pool).await { + Ok(new_license_key) => match save_license_key(pool, &new_license_key).await { Ok(()) => { update_cached_license(Some(&new_license_key))?; check_period = *config.check_period; diff --git a/src/enterprise/limits.rs b/src/enterprise/limits.rs index bb44ff55c..056ee7a8b 100644 --- a/src/enterprise/limits.rs +++ b/src/enterprise/limits.rs @@ -1,9 +1,5 @@ use sqlx::{error::Error as SqlxError, query_as, PgPool}; -use std::{ - sync::{RwLock, RwLockReadGuard}, - time::Duration, -}; -use tokio::time::sleep; +use std::sync::{RwLock, RwLockReadGuard}; #[derive(Debug)] pub(crate) struct Counts { @@ -54,13 +50,9 @@ pub async fn update_counts(pool: &PgPool) -> Result<(), SqlxError> { Ok(()) } -// Just to make sure we don't miss any user/device/network count changes -pub async fn run_periodic_count_update(pool: &PgPool) -> Result<(), SqlxError> { - let delay = Duration::from_secs(60 * 60); - loop { - update_counts(pool).await?; - sleep(delay).await; - } +pub async fn do_count_update(pool: &PgPool) -> Result<(), SqlxError> { + update_counts(pool).await?; + Ok(()) } impl Counts { diff --git a/src/enterprise/mod.rs b/src/enterprise/mod.rs index 3c4c5adfa..1a546395b 100644 --- a/src/enterprise/mod.rs +++ b/src/enterprise/mod.rs @@ -1,4 +1,5 @@ pub mod db; +pub mod directory_sync; pub mod grpc; pub mod handlers; pub mod license; diff --git a/src/grpc/mod.rs b/src/grpc/mod.rs index c899431e0..1cc66eb91 100644 --- a/src/grpc/mod.rs +++ b/src/grpc/mod.rs @@ -52,6 +52,7 @@ use crate::{ }, enterprise::{ db::models::{enterprise_settings::EnterpriseSettings, openid_provider::OpenIdProvider}, + directory_sync::sync_user_groups_if_configured, grpc::polling::PollingServer, handlers::openid_login::{make_oidc_client, user_from_claims}, is_enterprise_enabled, @@ -602,6 +603,14 @@ pub async fn run_grpc_bidi_stream( { Ok(user) => { user.clear_unused_enrollment_tokens(&pool).await?; + if let Err(err) = + sync_user_groups_if_configured(&user, &pool).await + { + error!( + "Failed to sync user groups for user {} with the directory while the user was logging in through an external provider: {err:?}", + user.username, + ); + } debug!("Cleared unused tokens for {}.", user.username); debug!( "Creating a new desktop activation token for user {} as a result of proxy OpenID auth callback.", diff --git a/src/handlers/group.rs b/src/handlers/group.rs index 97f45dd46..b0ffbbc26 100644 --- a/src/handlers/group.rs +++ b/src/handlers/group.rs @@ -9,11 +9,9 @@ use utoipa::ToSchema; use super::{ApiResponse, EditGroupInfo, GroupInfo, Username}; use crate::{ appstate::AppState, - auth::{SessionInfo, UserAdminRole}, - db::{Group, User, WireguardNetwork}, + auth::{AdminRole, SessionInfo}, + db::{models::group::Permission, Group, User, WireguardNetwork}, error::WebError, - server_config, - // ldap::utils::{ldap_add_user_to_group, ldap_modify_group, ldap_remove_user_from_group}, }; #[derive(Serialize, ToSchema)] @@ -54,7 +52,7 @@ pub(crate) struct BulkAssignToGroupsRequest { ) )] pub(crate) async fn bulk_assign_to_groups( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Json(data): Json, ) -> Result { @@ -132,7 +130,7 @@ pub(crate) async fn bulk_assign_to_groups( ) )] pub(crate) async fn list_groups_info( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, ) -> Result { debug!("Listing groups info"); @@ -140,13 +138,14 @@ pub(crate) async fn list_groups_info( GroupInfo, "SELECT g.name name, \ COALESCE(ARRAY_AGG(DISTINCT u.username) FILTER (WHERE u.username IS NOT NULL), '{}') \"members!\", \ - COALESCE(ARRAY_AGG(DISTINCT wn.name) FILTER (WHERE wn.name IS NOT NULL), '{}') \"vpn_locations!\" \ + COALESCE(ARRAY_AGG(DISTINCT wn.name) FILTER (WHERE wn.name IS NOT NULL), '{}') \"vpn_locations!\", \ + is_admin \ FROM \"group\" g \ LEFT JOIN \"group_user\" gu ON gu.group_id = g.id \ LEFT JOIN \"user\" u ON u.id = gu.user_id \ LEFT JOIN \"wireguard_network_allowed_group\" wnag ON wnag.group_id = g.id \ LEFT JOIN \"wireguard_network\" wn ON wn.id = wnag.network_id \ - GROUP BY g.name" + GROUP BY g.name, g.id" ) .fetch_all(&appstate.pool) .await?; @@ -201,7 +200,8 @@ pub(crate) async fn list_groups( { "name": "name", "members": ["user"], - "vpn_locations": ["location"] + "vpn_locations": ["location"], + "is_admin": false } )), (status = 401, description = "Unauthorized to retrive a group.", body = ApiResponse, example = json!({"msg": "Session is required"})), @@ -218,9 +218,12 @@ pub(crate) async fn get_group( if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { let members = group.member_usernames(&appstate.pool).await?; let vpn_locations = group.allowed_vpn_locations(&appstate.pool).await?; + let is_admin = group + .has_permission(&appstate.pool, Permission::IsAdmin) + .await?; info!("Retrieved group {name}"); Ok(ApiResponse { - json: json!(GroupInfo::new(name, members, vpn_locations)), + json: json!(GroupInfo::new(name, members, vpn_locations, is_admin)), status: StatusCode::OK, }) } else { @@ -254,7 +257,7 @@ pub(crate) async fn get_group( ) )] pub(crate) async fn create_group( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Json(group_info): Json, ) -> Result { @@ -266,7 +269,9 @@ pub(crate) async fn create_group( // FIXME: conflicts must not return internal server error (500). let group = Group::new(&group_info.name).save(&appstate.pool).await?; // TODO: create group in LDAP - + group + .set_permission(&mut *transaction, Permission::IsAdmin, group_info.is_admin) + .await?; for username in &group_info.members { let Some(user) = User::find_by_username(&mut *transaction, username).await? else { let msg = format!("Failed to find user {username}"); @@ -308,7 +313,7 @@ pub(crate) async fn create_group( ) )] pub(crate) async fn modify_group( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Path(name): Path, Json(group_info): Json, @@ -330,6 +335,27 @@ pub(crate) async fn modify_group( // TODO: update LDAP } + if group.is_admin != group_info.is_admin && !group_info.is_admin { + // prevent removing admin permissions from the last admin group + let admin_groups_count = Group::find_by_permission(&appstate.pool, Permission::IsAdmin) + .await? + .len(); + if admin_groups_count == 1 { + error!( + "Can't remove admin permissions from the last admin group: {}", + name + ); + return Ok(ApiResponse { + json: json!({}), + status: StatusCode::BAD_REQUEST, + }); + } + } + + group + .set_permission(&mut *transaction, Permission::IsAdmin, group_info.is_admin) + .await?; + // Modify group members. let mut current_members = group.members(&mut *transaction).await?; for username in &group_info.members { @@ -389,16 +415,20 @@ pub(crate) async fn delete_group( Path(name): Path, ) -> Result { debug!("Deleting group {name}"); - // Administrative group must not be removed. - // Note: Group names are unique, so this condition should be sufficient. - if name == server_config().admin_groupname { - return Ok(ApiResponse { - json: json!({}), - status: StatusCode::BAD_REQUEST, - }); - } - if let Some(group) = Group::find_by_name(&appstate.pool, &name).await? { + // Prevent removing the last admin group + if group.is_admin { + let admin_group_count = Group::find_by_permission(&appstate.pool, Permission::IsAdmin) + .await? + .len(); + if admin_group_count == 1 { + error!("Cannot delete the last admin group: {name}"); + return Ok(ApiResponse { + json: json!({}), + status: StatusCode::BAD_REQUEST, + }); + } + } group.delete(&appstate.pool).await?; // TODO: delete group from LDAP @@ -436,7 +466,7 @@ pub(crate) async fn delete_group( ) )] pub(crate) async fn add_group_member( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Path(name): Path, Json(data): Json, @@ -485,7 +515,7 @@ pub(crate) async fn add_group_member( ) )] pub(crate) async fn remove_group_member( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Path((name, username)): Path<(String, String)>, ) -> Result { diff --git a/src/handlers/mail.rs b/src/handlers/mail.rs index f2cc35f90..05fac8727 100644 --- a/src/handlers/mail.rs +++ b/src/handlers/mail.rs @@ -216,7 +216,7 @@ pub async fn send_gateway_disconnected_email( pool: &PgPool, ) -> Result<(), WebError> { debug!("Sending gateway disconnected mail to all admin users"); - let admin_users = User::find_by_group_name(pool, &server_config().admin_groupname).await?; + let admin_users = User::find_admins(pool).await?; let gateway_name = gateway_name.unwrap_or_default(); for user in admin_users { let mail = Mail { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 945cb07c8..d32874547 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -30,6 +30,7 @@ pub mod openid_flow; pub(crate) mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod support; +pub(crate) mod updates; pub(crate) mod user; pub(crate) mod webhooks; #[cfg(feature = "wireguard")] @@ -207,15 +208,22 @@ pub struct GroupInfo { pub name: String, pub members: Vec, pub vpn_locations: Vec, + pub is_admin: bool, } impl GroupInfo { #[must_use] - pub fn new>(name: S, members: Vec, vpn_locations: Vec) -> Self { + pub fn new>( + name: S, + members: Vec, + vpn_locations: Vec, + is_admin: bool, + ) -> Self { Self { name: name.into(), members, vpn_locations, + is_admin, } } } @@ -225,6 +233,7 @@ impl GroupInfo { pub struct EditGroupInfo { pub name: String, pub members: Vec, + pub is_admin: bool, } #[derive(Deserialize, Serialize, ToSchema)] diff --git a/src/handlers/updates.rs b/src/handlers/updates.rs new file mode 100644 index 000000000..10e9584ae --- /dev/null +++ b/src/handlers/updates.rs @@ -0,0 +1,29 @@ +use axum::http::StatusCode; +use serde_json::json; + +use super::{ApiResponse, ApiResult}; +use crate::{ + auth::{AdminRole, SessionInfo}, + updates::get_update, +}; + +pub async fn check_new_version(_admin: AdminRole, session: SessionInfo) -> ApiResult { + debug!( + "User {} is checking if there is a new version available", + session.user.username + ); + let update = get_update(); + if let Some(update) = update.as_ref() { + debug!("A new version is available, returning the update information"); + Ok(ApiResponse { + json: json!(update), + status: StatusCode::OK, + }) + } else { + debug!("No new version available"); + Ok(ApiResponse { + json: serde_json::json!({ "message": "No updates available" }), + status: StatusCode::NO_CONTENT, + }) + } +} diff --git a/src/handlers/user.rs b/src/handlers/user.rs index b2e4e9b5e..947abaeb5 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -11,7 +11,7 @@ use super::{ }; use crate::{ appstate::AppState, - auth::{SessionInfo, UserAdminRole}, + auth::{AdminRole, SessionInfo}, db::{ models::{ device::DeviceInfo, @@ -156,7 +156,7 @@ pub(crate) fn check_password_strength(password: &str) -> Result<(), WebError> { (status = 500, description = "Unable return list of users.", body = ApiResponse, example = json!({"msg": "Internal error"})) ) )] -pub async fn list_users(_role: UserAdminRole, State(appstate): State) -> ApiResult { +pub async fn list_users(_role: AdminRole, State(appstate): State) -> ApiResult { let all_users = User::all(&appstate.pool).await?; let mut users: Vec = Vec::with_capacity(all_users.len()); for user in all_users { @@ -282,7 +282,7 @@ pub async fn get_user( ) )] pub async fn add_user( - _role: UserAdminRole, + _role: AdminRole, session: SessionInfo, State(appstate): State, Json(user_data): Json, @@ -378,7 +378,7 @@ pub async fn add_user( ) )] pub async fn start_enrollment( - _role: UserAdminRole, + _role: AdminRole, session: SessionInfo, State(appstate): State, Path(username): Path, @@ -560,7 +560,7 @@ pub async fn start_remote_desktop_configuration( ) )] pub async fn username_available( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Json(data): Json, ) -> ApiResult { @@ -707,7 +707,7 @@ pub async fn modify_user( ) )] pub async fn delete_user( - _role: UserAdminRole, + _role: AdminRole, State(appstate): State, Path(username): Path, session: SessionInfo, @@ -830,7 +830,7 @@ pub async fn change_self_password( ) )] pub async fn change_password( - _role: UserAdminRole, + _role: AdminRole, session: SessionInfo, State(appstate): State, Path(username): Path, @@ -908,7 +908,7 @@ pub async fn change_password( ) )] pub async fn reset_password( - _role: UserAdminRole, + _role: AdminRole, session: SessionInfo, State(appstate): State, Path(username): Path, diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index 13fad84dd..544438cae 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use super::{device_for_admin_or_self, user_for_admin_or_self, ApiResponse, ApiResult, WebError}; use crate::{ appstate::AppState, - auth::{Claims, ClaimsType, SessionInfo, VpnRole}, + auth::{AdminRole, Claims, ClaimsType, SessionInfo}, db::{ models::{ device::{ @@ -93,7 +93,7 @@ pub struct ImportedNetworkData { // ) // )] pub async fn create_network( - _role: VpnRole, + _role: AdminRole, State(appstate): State, session: SessionInfo, Json(data): Json, @@ -150,7 +150,7 @@ async fn find_network(id: Id, pool: &PgPool) -> Result, Web } pub async fn modify_network( - _role: VpnRole, + _role: AdminRole, Path(network_id): Path, State(appstate): State, session: SessionInfo, @@ -202,7 +202,7 @@ pub async fn modify_network( } pub async fn delete_network( - _role: VpnRole, + _role: AdminRole, Path(network_id): Path, State(appstate): State, session: SessionInfo, @@ -225,7 +225,7 @@ pub async fn delete_network( } pub async fn list_networks( - _role: VpnRole, + _role: AdminRole, State(appstate): State, Extension(gateway_state): Extension>>, ) -> ApiResult { @@ -258,7 +258,7 @@ pub async fn list_networks( pub async fn network_details( Path(network_id): Path, - _role: VpnRole, + _role: AdminRole, State(appstate): State, Extension(gateway_state): Extension>>, ) -> ApiResult { @@ -293,7 +293,7 @@ pub async fn network_details( pub async fn gateway_status( Path(network_id): Path, - _role: VpnRole, + _role: AdminRole, Extension(gateway_state): Extension>>, ) -> ApiResult { debug!("Displaying gateway status for network {network_id}"); @@ -310,7 +310,7 @@ pub async fn gateway_status( pub async fn remove_gateway( Path((network_id, gateway_id)): Path<(i64, String)>, - _role: VpnRole, + _role: AdminRole, Extension(gateway_state): Extension>>, ) -> ApiResult { debug!("Removing gateway {gateway_id} in network {network_id}"); @@ -333,7 +333,7 @@ pub async fn remove_gateway( } pub async fn import_network( - _role: VpnRole, + _role: AdminRole, State(appstate): State, Json(data): Json, ) -> ApiResult { @@ -386,7 +386,7 @@ pub async fn import_network( // This is used exclusively for the wizard to map imported devices to users. pub async fn add_user_devices( - _role: VpnRole, + _role: AdminRole, session: SessionInfo, State(appstate): State, Path(network_id): Path, @@ -794,7 +794,7 @@ pub async fn delete_device( (status = 403, description = "You don't have permission to list all devices.", body = ApiResponse, example = json!({"msg": "requires privileged access"})), ) )] -pub async fn list_devices(_role: VpnRole, State(appstate): State) -> ApiResult { +pub async fn list_devices(_role: AdminRole, State(appstate): State) -> ApiResult { debug!("Listing devices"); let devices = Device::all(&appstate.pool).await?; info!("Listed {} devices", devices.len()); @@ -880,7 +880,7 @@ pub async fn download_config( } pub async fn create_network_token( - _role: VpnRole, + _role: AdminRole, State(appstate): State, Path(network_id): Path, ) -> ApiResult { @@ -936,7 +936,7 @@ impl QueryFrom { } pub async fn user_stats( - _role: VpnRole, + _role: AdminRole, State(appstate): State, Path(network_id): Path, Query(query_from): Query, @@ -961,7 +961,7 @@ pub async fn user_stats( } pub async fn network_stats( - _role: VpnRole, + _role: AdminRole, State(appstate): State, Path(network_id): Path, Query(query_from): Query, diff --git a/src/lib.rs b/src/lib.rs index 329b4ffd3..f985233cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,7 +15,10 @@ use enterprise::handlers::{ check_enterprise_info, check_enterprise_status, enterprise_settings::{get_enterprise_settings, patch_enterprise_settings}, openid_login::{auth_callback, get_auth_info}, - openid_providers::{add_openid_provider, delete_openid_provider, get_current_openid_provider}, + openid_providers::{ + add_openid_provider, delete_openid_provider, get_current_openid_provider, + test_dirsync_connection, + }, }; use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, @@ -23,6 +26,7 @@ use handlers::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, rename_authentication_key, }, + updates::check_new_version, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -130,6 +134,8 @@ pub(crate) mod random; pub mod secret; pub mod support; pub mod templates; +pub mod updates; +pub mod utility_thread; pub mod wg_config; pub mod wireguard_peer_disconnect; pub mod wireguard_stats_purge; @@ -298,6 +304,7 @@ pub fn build_webapp( .route("/info", get(get_app_info)) .route("/ssh_authorized_keys", get(get_authorized_keys)) .route("/api-docs", get(openapi)) + .route("/updates", get(check_new_version)) // /auth .route("/auth", post(authenticate)) .route("/auth/logout", post(logout)) @@ -414,9 +421,13 @@ pub fn build_webapp( .route("/callback", post(auth_callback)) .route("/auth_info", get(get_auth_info)), ); - let webapp = webapp - .route("/api/v1/enterprise_status", get(check_enterprise_status)) - .route("/api/v1/enterprise_info", get(check_enterprise_info)); + let webapp = webapp.nest( + "/api/v1", + Router::new() + .route("/enterprise_status", get(check_enterprise_status)) + .route("/enterprise_info", get(check_enterprise_info)) + .route("/test_directory_sync", get(test_dirsync_connection)), + ); #[cfg(feature = "openid")] let webapp = webapp diff --git a/src/updates.rs b/src/updates.rs new file mode 100644 index 000000000..911739ef8 --- /dev/null +++ b/src/updates.rs @@ -0,0 +1,71 @@ +use std::{ + env, + sync::{RwLock, RwLockReadGuard}, +}; + +use chrono::NaiveDate; +use semver::Version; + +const PRODUCT_NAME: &str = "Defguard"; +const UPDATES_URL: &str = "https://update-service-dev.defguard.net/api/update/check"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Deserialize, Debug, Serialize)] +pub struct Update { + version: String, + release_date: NaiveDate, + release_notes_url: String, + update_url: String, + critical: bool, + notes: String, +} + +static NEW_UPDATE: RwLock> = RwLock::new(None); + +fn set_update(update: Update) { + *NEW_UPDATE + .write() + .expect("Failed to acquire lock on the update.") = Some(update); +} + +pub fn get_update() -> RwLockReadGuard<'static, Option> { + NEW_UPDATE + .read() + .expect("Failed to acquire lock on the update.") +} + +async fn fetch_update() -> Result { + let body = serde_json::json!({ + "product": PRODUCT_NAME, + "client_version": VERSION, + "operating_system": env::consts::OS, + }); + let response = reqwest::Client::new() + .post(UPDATES_URL) + .json(&body) + .send() + .await?; + Ok(response.json::().await?) +} + +pub(crate) async fn do_new_version_check() -> Result<(), anyhow::Error> { + debug!("Checking for new version of Defguard ..."); + let update = fetch_update().await?; + let current_version = Version::parse(VERSION)?; + let new_version = Version::parse(&update.version)?; + if new_version > current_version { + if update.critical { + warn!("There is a new critical Defguard update available: {} (Released on {}). It's recommended to update as soon as possible.", + update.version, update.release_date); + } else { + info!( + "There is a new Defguard version available: {} (Released on {})", + update.version, update.release_date + ); + } + set_update(update); + } else { + debug!("New version check done. You are using the latest version of Defguard."); + } + Ok(()) +} diff --git a/src/utility_thread.rs b/src/utility_thread.rs new file mode 100644 index 000000000..c136c376f --- /dev/null +++ b/src/utility_thread.rs @@ -0,0 +1,66 @@ +use std::time::Duration; + +use sqlx::PgPool; +use tokio::time::{sleep, Instant}; + +use crate::{ + enterprise::{ + directory_sync::{do_directory_sync, get_directory_sync_interval}, + limits::do_count_update, + }, + updates::do_new_version_check, +}; + +const UTILITY_THREAD_MAIN_SLEEP_TIME: u64 = 5; +const COUNT_UPDATE_INTERVAL: u64 = 60 * 60; +const UPDATES_CHECK_INTERVAL: u64 = 60 * 60 * 6; + +pub async fn run_utility_thread(pool: &PgPool) -> Result<(), anyhow::Error> { + let mut last_count_update = Instant::now(); + let mut last_directory_sync = Instant::now(); + let mut last_updates_check = Instant::now(); + + let directory_sync_task = || async { + if let Err(e) = do_directory_sync(pool).await { + error!("There was an error while performing directory sync job: {e:?}",); + } + }; + + let count_update_task = || async { + if let Err(e) = do_count_update(pool).await { + error!("There was an error while performing count update job: {e:?}"); + } + }; + + let updates_check_task = || async { + if let Err(e) = do_new_version_check().await { + error!("There was an error while checking for new Defguard version: {e:?}"); + } + }; + + directory_sync_task().await; + count_update_task().await; + updates_check_task().await; + + loop { + sleep(Duration::from_secs(UTILITY_THREAD_MAIN_SLEEP_TIME)).await; + + // Count update job for updating device/user/network counts + if last_count_update.elapsed().as_secs() >= COUNT_UPDATE_INTERVAL { + count_update_task().await; + last_count_update = Instant::now(); + } + + // Directory sync job for syncing with the directory service + if last_directory_sync.elapsed().as_secs() >= get_directory_sync_interval(pool).await { + directory_sync_task().await; + last_directory_sync = Instant::now(); + } + + // Check for new Defguard version + if last_updates_check.elapsed().as_secs() >= UPDATES_CHECK_INTERVAL { + updates_check_task().await; + last_updates_check = Instant::now(); + } + } +} diff --git a/tests/group.rs b/tests/group.rs index 31040e0a9..9c9d9d51e 100644 --- a/tests/group.rs +++ b/tests/group.rs @@ -16,7 +16,7 @@ async fn test_create_group() { assert_eq!(response.status(), StatusCode::OK); // Create new group. - let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new()); + let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new(), false); let response = client.post("/api/v1/group").json(&data).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -43,12 +43,12 @@ async fn test_modify_group() { assert_eq!(response.status(), StatusCode::OK); // Create new group. - let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new()); + let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new(), false); let response = client.post("/api/v1/group").json(&data).send().await; assert_eq!(response.status(), StatusCode::CREATED); // Rename group. - let data = GroupInfo::new("gryffindor", Vec::new(), Vec::new()); + let data = GroupInfo::new("gryffindor", Vec::new(), Vec::new(), false); let response = client .put("/api/v1/group/hogwards") .json(&data) @@ -77,7 +77,7 @@ async fn test_modify_group_members() { assert_eq!(response.status(), StatusCode::OK); // Create new group. - let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new()); + let data = GroupInfo::new("hogwards", vec!["hpotter".into()], Vec::new(), false); let response = client.post("/api/v1/group").json(&data).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -88,7 +88,7 @@ async fn test_modify_group_members() { assert_eq!(group_info.members, vec!["hpotter".to_string()]); // Change group members. - let data = GroupInfo::new("hogwards", Vec::new(), Vec::new()); + let data = GroupInfo::new("hogwards", Vec::new(), Vec::new(), false); let response = client .put("/api/v1/group/hogwards") .json(&data) @@ -118,7 +118,8 @@ async fn test_modify_group_no_locations_in_request() { "members": [ "hpotter", "admin" - ] + ], + "is_admin": false }); let response = client.post("/api/v1/group").json(&data).send().await; assert_eq!(response.status(), StatusCode::CREATED); @@ -128,7 +129,8 @@ async fn test_modify_group_no_locations_in_request() { "name": "gryffindor", "members": [ "hpotter", - ] + ], + "is_admin": false }); let response = client .put("/api/v1/group/hogwards") @@ -148,3 +150,48 @@ async fn test_modify_group_no_locations_in_request() { assert_eq!(group_info.name, "gryffindor"); assert_eq!(group_info.members, vec!["hpotter"]); } + +#[tokio::test] +async fn test_remove_last_admin_group() { + let (client, _) = make_test_client().await; + + // Authorize as an administrator. + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // Get group info. + let response = client.get("/api/v1/group/admin").send().await; + assert_eq!(response.status(), StatusCode::OK); + let group_info: GroupInfo = response.json().await; + assert_eq!(group_info.members, vec!["admin".to_string()]); + + let response = client.delete("/api/v1/group/admin").send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_modify_last_admin_group() { + let (client, _) = make_test_client().await; + + // Authorize as an administrator. + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // Get group info. + let response = client.get("/api/v1/group/admin").send().await; + assert_eq!(response.status(), StatusCode::OK); + let group_info: GroupInfo = response.json().await; + assert_eq!(group_info.members, vec!["admin".to_string()]); + // try to remove admin status from the last group + let data = json!({ + "name": "admin", + "members": [ + "admin", + ], + "is_admin": false + }); + let response = client.put("/api/v1/group/admin").json(&data).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/tests/openid_login.rs b/tests/openid_login.rs index 4fc2a51a3..07a321ef4 100644 --- a/tests/openid_login.rs +++ b/tests/openid_login.rs @@ -1,5 +1,9 @@ use chrono::{Duration, Utc}; use common::{exceed_enterprise_limits, make_test_client}; +use defguard::enterprise::db::models::openid_provider::DirectorySyncTarget; +use defguard::enterprise::{ + db::models::openid_provider::DirectorySyncUserBehavior, license::get_cached_license, +}; use defguard::{ enterprise::{ handlers::openid_providers::AddProviderData, @@ -34,13 +38,21 @@ async fn test_openid_providers() { exceed_enterprise_limits(&client).await; - let provider_data = AddProviderData::new( - "test", - "https://accounts.google.com", - "client_id", - "client_secret", - Some("display_name"), - ); + let provider_data = AddProviderData { + name: "test".to_string(), + base_url: "https://accounts.google.com".to_string(), + client_id: "client_id".to_string(), + client_secret: "client_secret".to_string(), + display_name: Some("display_name".to_string()), + admin_email: None, + google_service_account_email: None, + google_service_account_key: None, + directory_sync_enabled: false, + directory_sync_interval: 100, + directory_sync_user_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_admin_behavior: DirectorySyncUserBehavior::Keep.to_string(), + directory_sync_target: DirectorySyncTarget::All.to_string(), + }; let response = client .post("/api/v1/openid/provider") diff --git a/web/.env b/web/.env new file mode 100644 index 000000000..3afe802fb --- /dev/null +++ b/web/.env @@ -0,0 +1 @@ +PROXY_TARGET=https://defguard-dev.teonite.net diff --git a/web/package.json b/web/package.json index 544013ef8..8b284ff41 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "serve": "vite preview", "generate-translation-types": "typesafe-i18n --no-watch", "lint": "eslint -c ./.eslintrc.cjs --ignore-path .eslintignore --ext .ts --ext .tsx src/ && prettier --check 'src/**/*.{ts,tsx,scss}' && tsc", - "fix": "prettier -w 'src/**/*.{ts,tsx,scss}' && eslint -c ./.eslintrc.cjs --ignore-path .eslintignore --fix 'src/**/*.{ts,tsx}'", + "fix": "prettier -w 'src/**/*.{ts,tsx,scss}' && eslint -c ./.eslintrc.cjs --ignore-path .eslintignore --fix --ext .ts --ext .tsx src/", "parse-core-svgs": "svgr --no-index --jsx-runtime 'automatic' --svgo-config ./svgo.config.json --prettier-config ./.prettierrc --out-dir ./src/shared/components/svg/ --typescript ./src/shared/images/svg/", "parse-ui-svgs": "svgr --no-index --jsx-runtime 'automatic' --svgo-config ./svgo.config.json --prettier-config ./.prettierrc --out-dir ./src/shared/defguard-ui/components/svg/ --typescript ./src/shared/defguard-ui/images/svg/", "parse-svgs": "pnpm parse-ui-svgs && pnpm parse-core-svgs", @@ -49,8 +49,8 @@ "@react-rxjs/core": "^0.10.7", "@stablelib/base64": "^1.0.1", "@stablelib/x25519": "^1.0.3", - "@tanstack/query-core": "^4.32.6", - "@tanstack/react-query": "^4.32.6", + "@tanstack/query-core": "^4.36.1", + "@tanstack/react-query": "^4.36.1", "@tanstack/react-virtual": "3.0.0-beta.9", "@tanstack/virtual-core": "3.0.0-beta.9", "@tauri-apps/api": "^1.5.3", @@ -91,7 +91,6 @@ "react-virtualized-auto-sizer": "^1.0.21", "react-window": "^1.8.10", "recharts": "^2.10.4", - "rollup": "^4.9.6", "rxjs": "^7.8.1", "terser": "^5.27.0", "typesafe-i18n": "^5.26.2", @@ -104,8 +103,9 @@ "@csstools/css-parser-algorithms": "^2.5.0", "@csstools/css-tokenizer": "^2.2.3", "@hookform/devtools": "^4.3.1", + "@rollup/wasm-node": "^4.28.1", "@svgr/cli": "^8.1.0", - "@tanstack/react-query-devtools": "^4.32.6", + "@tanstack/react-query-devtools": "^4.36.1", "@types/byte-size": "^8.1.2", "@types/file-saver": "^2.0.7", "@types/lodash-es": "^4.17.12", @@ -118,6 +118,7 @@ "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.17", "concurrently": "^8.2.2", + "dotenv": "^16.4.7", "esbuild": "^0.19.12", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -131,6 +132,7 @@ "postcss": "^8.4.33", "prettier": "^3.2.4", "prop-types": "^15.8.1", + "rollup": "npm:@rollup/wasm-node@^4.28.1", "rollup-plugin-preserve-directives": "^0.3.1", "sass": "^1.70.0", "standard-version": "^9.5.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 468706420..ed8b52def 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -36,10 +36,10 @@ importers: specifier: ^1.0.3 version: 1.0.3 '@tanstack/query-core': - specifier: ^4.32.6 + specifier: ^4.36.1 version: 4.36.1 '@tanstack/react-query': - specifier: ^4.32.6 + specifier: ^4.36.1 version: 4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@tanstack/react-virtual': specifier: 3.0.0-beta.9 @@ -161,9 +161,6 @@ importers: recharts: specifier: ^2.10.4 version: 2.10.4(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - rollup: - specifier: npm:@rollup/wasm-node - version: '@rollup/wasm-node@4.24.0' rxjs: specifier: ^7.8.1 version: 7.8.1 @@ -195,11 +192,14 @@ importers: '@hookform/devtools': specifier: ^4.3.1 version: 4.3.1(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@rollup/wasm-node': + specifier: ^4.28.1 + version: 4.28.1 '@svgr/cli': specifier: ^8.1.0 version: 8.1.0(typescript@5.3.3) '@tanstack/react-query-devtools': - specifier: ^4.32.6 + specifier: ^4.36.1 version: 4.36.1(@tanstack/react-query@4.36.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@types/byte-size': specifier: ^8.1.2 @@ -237,6 +237,9 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 + dotenv: + specifier: ^16.4.7 + version: 16.4.7 esbuild: specifier: ^0.19.12 version: 0.19.12 @@ -276,9 +279,12 @@ importers: prop-types: specifier: ^15.8.1 version: 15.8.1 + rollup: + specifier: npm:@rollup/wasm-node + version: '@rollup/wasm-node@4.28.1' rollup-plugin-preserve-directives: specifier: ^0.3.1 - version: 0.3.1(@rollup/wasm-node@4.24.0) + version: 0.3.1(@rollup/wasm-node@4.28.1) sass: specifier: ^1.70.0 version: 1.70.0 @@ -942,19 +948,29 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^2.2.3 + '@csstools/css-parser-algorithms@2.7.1': + resolution: {integrity: sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + '@csstools/css-tokenizer': ^2.4.1 + '@csstools/css-tokenizer@2.2.3': resolution: {integrity: sha512-pp//EvZ9dUmGuGtG1p+n17gTHEOqu9jO+FiCUjNN3BDmyhdA2Jq9QsVeR7K8/2QCK17HSsioPlTW9ZkzoWb3Lg==} engines: {node: ^14 || ^16 || >=18} - '@csstools/media-query-list-parser@2.1.7': - resolution: {integrity: sha512-lHPKJDkPUECsyAvD60joYfDmp8UERYxHGkFfyLJFTVK/ERJe0sVlIFLXU5XFxdjNDTerp5L4KeaKG+Z5S94qxQ==} + '@csstools/css-tokenizer@2.4.1': + resolution: {integrity: sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==} + engines: {node: ^14 || ^16 || >=18} + + '@csstools/media-query-list-parser@2.1.13': + resolution: {integrity: sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==} engines: {node: ^14 || ^16 || >=18} peerDependencies: - '@csstools/css-parser-algorithms': ^2.5.0 - '@csstools/css-tokenizer': ^2.2.3 + '@csstools/css-parser-algorithms': ^2.7.1 + '@csstools/css-tokenizer': ^2.4.1 - '@csstools/selector-specificity@3.0.1': - resolution: {integrity: sha512-NPljRHkq4a14YzZ3YD406uaxh7s0g6eAq3L9aLOWywoqe8PkYamAvtsh7KNX6c++ihDrJ0RiU+/z7rGnhlZ5ww==} + '@csstools/selector-specificity@3.1.1': + resolution: {integrity: sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==} engines: {node: ^14 || ^16 || >=18} peerDependencies: postcss-selector-parser: ^6.0.13 @@ -1228,10 +1244,6 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@jridgewell/gen-mapping@0.3.3': resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} engines: {node: '>=6.0.0'} @@ -1316,10 +1328,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@pkgr/core@0.1.1': resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1341,8 +1349,8 @@ packages: resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} - '@rollup/wasm-node@4.24.0': - resolution: {integrity: sha512-LL6oALR6fKG6GihtH0K0uWLAl19Q/QJst+oKJT1VWwFo4sPLA0/7JeZaSqrpFWq8OPloiKx/NDG4BWppFSX2vQ==} + '@rollup/wasm-node@4.28.1': + resolution: {integrity: sha512-t4ckEC09V3wbe0r6T4fGjq85lEbvGcGxn7QYYgjHyKNzZaQU5kFqr4FsavXYHRiVNYq8m+dRhdGjpfcC9UzzPg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1836,8 +1844,8 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -2073,8 +2081,8 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} - caniuse-lite@1.0.30001580: - resolution: {integrity: sha512-mtj5ur2FFPZcCEpXFy8ADXbDACuNFXg6mxVDqp7tqooX6l3zwm+d8EPoeOSIFRDvHs8qu7/SLFOGniULkcH2iA==} + caniuse-lite@1.0.30001687: + resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2338,8 +2346,8 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} - css-functions-list@3.2.1: - resolution: {integrity: sha512-Nj5YcaGgBtuUmn1D7oHqPW0c9iui7xsTsj5lIX8ZgevdfhmjFfKB3r8moHJtNJnctnYXJyYX5I1pp90HM4TPgQ==} + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} engines: {node: '>=12 || >=16'} css-select@5.1.0: @@ -2454,6 +2462,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize-keys@1.1.1: resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} engines: {node: '>=0.10.0'} @@ -2584,6 +2601,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + dotgitignore@2.1.0: resolution: {integrity: sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==} engines: {node: '>=6'} @@ -2852,6 +2873,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -2905,13 +2929,16 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} - flat-cache@4.0.0: - resolution: {integrity: sha512-EryKbCE/wxpxKniQlyas6PY1I9vwtF3uCBweX+N8KYTCn3Y12RTGtQAJ/bd5pl7kxUAc8v/R3Ake/N17OZiFqA==} + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} flatted@3.2.9: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + follow-redirects@1.15.5: resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} engines: {node: '>=4.0'} @@ -2924,10 +2951,6 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} - foreground-child@3.1.1: - resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} - engines: {node: '>=14'} - form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -3024,11 +3047,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.3.10: - resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} - engines: {node: '>=16 || 14 >=14.17'} - hasBin: true - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -3228,6 +3246,10 @@ packages: resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==} engines: {node: '>= 4'} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + immutable@4.3.4: resolution: {integrity: sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==} @@ -3472,10 +3494,6 @@ packages: itertools@2.2.3: resolution: {integrity: sha512-TV4TDJ2FrLxhRJDX/AgdyI76i6cHi2Z1hml/d+HLcGVHxmgfxsLpoQBN2ZE9OizPt10+VW+LamLfCDASlnxvNg==} - jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} - engines: {node: '>=14'} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3632,10 +3650,6 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - lru-cache@10.1.0: - resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==} - engines: {node: 14 || >=16.14} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3734,8 +3748,8 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - meow@13.1.0: - resolution: {integrity: sha512-o5R/R3Tzxq0PJ3v3qcQJtSvSE9nKOLSAaDuuoMzDVuGTwHdccMWcYomh9Xolng2tjT6O/Y83d+0coVGof6tqmA==} + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} meow@8.1.2: @@ -3858,6 +3872,10 @@ packages: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3896,10 +3914,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass@7.0.4: - resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} - engines: {node: '>=16 || 14 >=14.17'} - modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -3928,6 +3942,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.8: + resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4127,10 +4146,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.10.1: - resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} - engines: {node: '>=16 || 14 >=14.17'} - path-to-regexp@6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} @@ -4152,6 +4167,9 @@ packages: picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -4174,8 +4192,11 @@ packages: postcss-resolve-nested-selector@0.1.1: resolution: {integrity: sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==} - postcss-safe-parser@7.0.0: - resolution: {integrity: sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==} + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} peerDependencies: postcss: ^8.4.31 @@ -4190,6 +4211,10 @@ packages: resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} engines: {node: '>=4'} + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -4197,6 +4222,10 @@ packages: resolution: {integrity: sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.4.49: + resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4520,11 +4549,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@5.0.5: - resolution: {integrity: sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==} - engines: {node: '>=14'} - hasBin: true - rollup-plugin-preserve-directives@0.3.1: resolution: {integrity: sha512-Jn1gWU7G55A1sU6eFpXmwknfBasF0XbBzRqsE6nqrb/gun+mGV7nx++CwOSGPJQpFzFqvKm5U4XNKo3LTLi4Hg==} peerDependencies: @@ -4639,6 +4663,10 @@ packages: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -4838,8 +4866,8 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-hyperlinks@3.0.0: - resolution: {integrity: sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==} + supports-hyperlinks@3.1.0: + resolution: {integrity: sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==} engines: {node: '>=14.18'} supports-preserve-symlinks-flag@1.0.0: @@ -4867,8 +4895,8 @@ packages: tabbable@6.2.0: resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} - table@6.8.1: - resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} terser@5.27.0: @@ -6134,16 +6162,22 @@ snapshots: dependencies: '@csstools/css-tokenizer': 2.2.3 + '@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1)': + dependencies: + '@csstools/css-tokenizer': 2.4.1 + '@csstools/css-tokenizer@2.2.3': {} - '@csstools/media-query-list-parser@2.1.7(@csstools/css-parser-algorithms@2.5.0(@csstools/css-tokenizer@2.2.3))(@csstools/css-tokenizer@2.2.3)': + '@csstools/css-tokenizer@2.4.1': {} + + '@csstools/media-query-list-parser@2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1)': dependencies: - '@csstools/css-parser-algorithms': 2.5.0(@csstools/css-tokenizer@2.2.3) - '@csstools/css-tokenizer': 2.2.3 + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 - '@csstools/selector-specificity@3.0.1(postcss-selector-parser@6.0.15)': + '@csstools/selector-specificity@3.1.1(postcss-selector-parser@6.1.2)': dependencies: - postcss-selector-parser: 6.0.15 + postcss-selector-parser: 6.1.2 '@emotion/babel-plugin@11.11.0': dependencies: @@ -6382,15 +6416,6 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/gen-mapping@0.3.3': dependencies: '@jridgewell/set-array': 1.1.2 @@ -6548,9 +6573,6 @@ snapshots: '@open-draft/until@2.1.0': {} - '@pkgjs/parseargs@0.11.0': - optional: true - '@pkgr/core@0.1.1': {} '@reach/observe-rect@1.2.0': {} @@ -6569,7 +6591,7 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.1 - '@rollup/wasm-node@4.24.0': + '@rollup/wasm-node@4.28.1': dependencies: '@types/estree': 1.0.6 optionalDependencies: @@ -6797,7 +6819,7 @@ snapshots: '@types/acorn@4.0.6': dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 '@types/babel__core@7.20.5': dependencies: @@ -7095,12 +7117,12 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.12.0: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 ansi-align@3.0.1: dependencies: @@ -7211,7 +7233,7 @@ snapshots: autoprefixer@10.4.17(postcss@8.4.33): dependencies: browserslist: 4.22.2 - caniuse-lite: 1.0.30001580 + caniuse-lite: 1.0.30001687 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.0 @@ -7320,7 +7342,7 @@ snapshots: browserslist@4.22.2: dependencies: - caniuse-lite: 1.0.30001580 + caniuse-lite: 1.0.30001687 electron-to-chromium: 1.4.645 node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.2) @@ -7363,7 +7385,7 @@ snapshots: camelcase@7.0.1: {} - caniuse-lite@1.0.30001580: {} + caniuse-lite@1.0.30001687: {} ccount@2.0.1: {} @@ -7667,7 +7689,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-functions-list@3.2.1: {} + css-functions-list@3.2.3: {} css-select@5.1.0: dependencies: @@ -7759,6 +7781,10 @@ snapshots: dependencies: ms: 2.1.2 + debug@4.4.0: + dependencies: + ms: 2.1.3 + decamelize-keys@1.1.1: dependencies: decamelize: 1.2.0 @@ -7879,6 +7905,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv@16.4.7: {} + dotgitignore@2.1.0: dependencies: find-up: 3.0.0 @@ -8302,6 +8330,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.0.3: {} + fastest-levenshtein@1.0.16: {} fastq@1.16.0: @@ -8318,7 +8348,7 @@ snapshots: file-entry-cache@8.0.0: dependencies: - flat-cache: 4.0.0 + flat-cache: 4.0.1 file-saver@2.0.5: {} @@ -8354,25 +8384,21 @@ snapshots: keyv: 4.5.4 rimraf: 3.0.2 - flat-cache@4.0.0: + flat-cache@4.0.1: dependencies: - flatted: 3.2.9 + flatted: 3.3.2 keyv: 4.5.4 - rimraf: 5.0.5 flatted@3.2.9: {} + flatted@3.3.2: {} + follow-redirects@1.15.5: {} for-each@0.3.3: dependencies: is-callable: 1.2.7 - foreground-child@3.1.1: - dependencies: - cross-spawn: 7.0.3 - signal-exit: 4.1.0 - form-data@4.0.0: dependencies: asynckit: 0.4.0 @@ -8466,14 +8492,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.3.10: - dependencies: - foreground-child: 3.1.1 - jackspeak: 2.3.6 - minimatch: 9.0.3 - minipass: 7.0.4 - path-scurry: 1.10.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -8771,6 +8789,8 @@ snapshots: ignore@5.3.0: {} + ignore@5.3.2: {} + immutable@4.3.4: {} import-fresh@3.3.0: @@ -8989,12 +9009,6 @@ snapshots: itertools@2.2.3: {} - jackspeak@2.3.6: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -9154,8 +9168,6 @@ snapshots: dependencies: tslib: 2.6.2 - lru-cache@10.1.0: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -9353,7 +9365,7 @@ snapshots: memoize-one@5.2.1: {} - meow@13.1.0: {} + meow@13.2.0: {} meow@8.1.2: dependencies: @@ -9452,7 +9464,7 @@ snapshots: micromark-extension-mdx-expression@3.0.0: dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 devlop: 1.1.0 micromark-factory-mdx-expression: 2.0.1 micromark-factory-space: 2.0.0 @@ -9464,7 +9476,7 @@ snapshots: micromark-extension-mdx-jsx@3.0.0: dependencies: '@types/acorn': 4.0.6 - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 micromark-factory-mdx-expression: 2.0.1 @@ -9480,7 +9492,7 @@ snapshots: micromark-extension-mdxjs-esm@3.0.0: dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 micromark-util-character: 2.0.1 @@ -9516,7 +9528,7 @@ snapshots: micromark-factory-mdx-expression@2.0.1: dependencies: - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 devlop: 1.1.0 micromark-util-character: 2.0.1 micromark-util-events-to-acorn: 2.0.2 @@ -9580,7 +9592,7 @@ snapshots: micromark-util-events-to-acorn@2.0.2: dependencies: '@types/acorn': 4.0.6 - '@types/estree': 1.0.5 + '@types/estree': 1.0.6 '@types/unist': 3.0.2 devlop: 1.1.0 estree-util-visit: 2.0.0 @@ -9642,6 +9654,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.52.0: {} mime-types@2.1.35: @@ -9674,8 +9691,6 @@ snapshots: minimist@1.2.8: {} - minipass@7.0.4: {} - modify-values@1.0.1: {} ms@2.1.2: {} @@ -9709,6 +9724,8 @@ snapshots: nanoid@3.3.7: {} + nanoid@3.3.8: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -9928,11 +9945,6 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.10.1: - dependencies: - lru-cache: 10.1.0 - minipass: 7.0.4 - path-to-regexp@6.2.1: {} path-type@3.0.0: @@ -9951,6 +9963,8 @@ snapshots: picocolors@1.0.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} pify@2.3.0: {} @@ -9963,9 +9977,11 @@ snapshots: postcss-resolve-nested-selector@0.1.1: {} - postcss-safe-parser@7.0.0(postcss@8.4.33): + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.4.49): dependencies: - postcss: 8.4.33 + postcss: 8.4.49 postcss-scss@4.0.9(postcss@8.4.33): dependencies: @@ -9976,6 +9992,11 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-value-parser@4.2.0: {} postcss@8.4.33: @@ -9984,6 +10005,12 @@ snapshots: picocolors: 1.0.0 source-map-js: 1.0.2 + postcss@8.4.49: + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -10348,14 +10375,10 @@ snapshots: dependencies: glob: 7.2.3 - rimraf@5.0.5: - dependencies: - glob: 10.3.10 - - rollup-plugin-preserve-directives@0.3.1(@rollup/wasm-node@4.24.0): + rollup-plugin-preserve-directives@0.3.1(@rollup/wasm-node@4.28.1): dependencies: magic-string: 0.30.5 - rollup: '@rollup/wasm-node@4.24.0' + rollup: '@rollup/wasm-node@4.28.1' run-applescript@5.0.0: dependencies: @@ -10468,6 +10491,8 @@ snapshots: source-map-js@1.0.2: {} + source-map-js@1.2.1: {} + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -10660,16 +10685,16 @@ snapshots: stylelint@16.2.0(typescript@5.3.3): dependencies: - '@csstools/css-parser-algorithms': 2.5.0(@csstools/css-tokenizer@2.2.3) - '@csstools/css-tokenizer': 2.2.3 - '@csstools/media-query-list-parser': 2.1.7(@csstools/css-parser-algorithms@2.5.0(@csstools/css-tokenizer@2.2.3))(@csstools/css-tokenizer@2.2.3) - '@csstools/selector-specificity': 3.0.1(postcss-selector-parser@6.0.15) + '@csstools/css-parser-algorithms': 2.7.1(@csstools/css-tokenizer@2.4.1) + '@csstools/css-tokenizer': 2.4.1 + '@csstools/media-query-list-parser': 2.1.13(@csstools/css-parser-algorithms@2.7.1(@csstools/css-tokenizer@2.4.1))(@csstools/css-tokenizer@2.4.1) + '@csstools/selector-specificity': 3.1.1(postcss-selector-parser@6.1.2) balanced-match: 2.0.0 colord: 2.9.3 cosmiconfig: 9.0.0(typescript@5.3.3) - css-functions-list: 3.2.1 + css-functions-list: 3.2.3 css-tree: 2.3.1 - debug: 4.3.4 + debug: 4.4.0 fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 8.0.0 @@ -10677,26 +10702,26 @@ snapshots: globby: 11.1.0 globjoin: 0.1.4 html-tags: 3.3.1 - ignore: 5.3.0 + ignore: 5.3.2 imurmurhash: 0.1.4 is-plain-object: 5.0.0 known-css-properties: 0.29.0 mathml-tag-names: 2.1.3 - meow: 13.1.0 - micromatch: 4.0.5 + meow: 13.2.0 + micromatch: 4.0.8 normalize-path: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.33 - postcss-resolve-nested-selector: 0.1.1 - postcss-safe-parser: 7.0.0(postcss@8.4.33) - postcss-selector-parser: 6.0.15 + picocolors: 1.1.1 + postcss: 8.4.49 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.4.49) + postcss-selector-parser: 6.1.2 postcss-value-parser: 4.2.0 resolve-from: 5.0.0 string-width: 4.2.3 strip-ansi: 7.1.0 - supports-hyperlinks: 3.0.0 + supports-hyperlinks: 3.1.0 svg-tags: 1.0.0 - table: 6.8.1 + table: 6.9.0 write-file-atomic: 5.0.1 transitivePeerDependencies: - supports-color @@ -10720,7 +10745,7 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-hyperlinks@3.0.0: + supports-hyperlinks@3.1.0: dependencies: has-flag: 4.0.0 supports-color: 7.2.0 @@ -10750,9 +10775,9 @@ snapshots: tabbable@6.2.0: {} - table@6.8.1: + table@6.9.0: dependencies: - ajv: 8.12.0 + ajv: 8.17.1 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -11066,7 +11091,7 @@ snapshots: '@rollup/pluginutils': 4.2.1 '@types/eslint': 8.56.2 eslint: 8.56.0 - rollup: '@rollup/wasm-node@4.24.0' + rollup: '@rollup/wasm-node@4.28.1' vite: 5.0.12(@types/node@20.11.7)(sass@1.70.0)(terser@5.27.0) vite-plugin-package-version@1.1.0(vite@5.0.12(@types/node@20.11.7)(sass@1.70.0)(terser@5.27.0)): @@ -11088,7 +11113,7 @@ snapshots: dependencies: esbuild: 0.19.12 postcss: 8.4.33 - rollup: '@rollup/wasm-node@4.24.0' + rollup: '@rollup/wasm-node@4.28.1' optionalDependencies: '@types/node': 20.11.7 fsevents: 2.3.3 diff --git a/web/src/components/App/App.tsx b/web/src/components/App/App.tsx index 6e9414341..b5160d21f 100644 --- a/web/src/components/App/App.tsx +++ b/web/src/components/App/App.tsx @@ -20,6 +20,7 @@ import { UsersSharedModals } from '../../pages/users/UsersSharedModals'; import { WebhooksListPage } from '../../pages/webhooks/WebhooksListPage'; import { WizardPage } from '../../pages/wizard/WizardPage'; import { PageContainer } from '../../shared/components/Layout/PageContainer/PageContainer'; +import { UpdateNotificationModal } from '../../shared/components/modals/UpdateNotificationModal/UpdateNotificationModal'; import { ProtectedRoute } from '../../shared/components/Router/Guards/ProtectedRoute/ProtectedRoute'; import { ToastManager } from '../../shared/defguard-ui/components/Layout/ToastManager/ToastManager'; import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; @@ -27,7 +28,7 @@ import { Navigation } from '../Navigation/Navigation'; const App = () => { const currentUser = useAuthStore((state) => state.user); - const isAdmin = useAuthStore((state) => state.isAdmin); + const isAdmin = useAuthStore((state) => state.user?.is_admin); return ( <>
@@ -180,6 +181,7 @@ const App = () => { /> +
diff --git a/web/src/components/AppLoader.tsx b/web/src/components/AppLoader.tsx index eabf80489..e16e5a6ed 100644 --- a/web/src/components/AppLoader.tsx +++ b/web/src/components/AppLoader.tsx @@ -9,9 +9,9 @@ import { useI18nContext } from '../i18n/i18n-react'; import { baseLocale, detectLocale, locales } from '../i18n/i18n-util'; import { loadLocaleAsync } from '../i18n/i18n-util.async'; import { LoaderPage } from '../pages/loader/LoaderPage'; -import { isUserAdmin } from '../shared/helpers/isUserAdmin'; import { useAppStore } from '../shared/hooks/store/useAppStore'; import { useAuthStore } from '../shared/hooks/store/useAuthStore'; +import { useUpdatesStore } from '../shared/hooks/store/useUpdatesStore'; import useApi from '../shared/hooks/useApi'; import { useToaster } from '../shared/hooks/useToaster'; import { QueryKeys } from '../shared/queries'; @@ -28,6 +28,7 @@ export const AppLoader = () => { const appSettings = useAppStore((state) => state.settings); const { getAppInfo, + getNewVersion, user: { getMe }, getEnterpriseStatus, settings: { getEssentialSettings, getEnterpriseSettings }, @@ -37,11 +38,12 @@ export const AppLoader = () => { const activeLanguage = useAppStore((state) => state.language); const setAppStore = useAppStore((state) => state.setState); const { LL } = useI18nContext(); + const setUpdateStore = useUpdatesStore((s) => s.setUpdate); + const clearUpdate = useUpdatesStore((s) => s.clearUpdate); useQuery([QueryKeys.FETCH_ME], getMe, { onSuccess: async (user) => { - const isAdmin = isUserAdmin(user); - setAuthState({ isAdmin, user }); + setAuthState({ user }); setUserLoading(false); }, onError: () => { @@ -134,6 +136,23 @@ export const AppLoader = () => { } }, [essentialSettings, setAppStore]); + useQuery([QueryKeys.FETCH_NEW_VERSION], getNewVersion, { + onSuccess: (data) => { + if (!data) { + clearUpdate(); + } else { + setUpdateStore(data); + } + }, + onError: (err) => { + console.error(err); + }, + refetchOnWindowFocus: false, + retry: false, + staleTime: Infinity, + enabled: !isUndefined(currentUser) && currentUser.is_admin, + }); + if (userLoading || (settingsLoading && isUndefined(appSettings))) { return ; } diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 2e7d7ebf3..3cdacf4ac 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -24,6 +24,7 @@ const en: BaseTranslation = { rename: 'Rename', copy: 'Copy', edit: 'Edit', + dismiss: 'Dismiss', }, key: 'Key', name: 'Name', @@ -40,12 +41,30 @@ const en: BaseTranslation = { }, }, modals: { + updatesNotificationToaster: { + title: 'New version available {version: string}', + controls: { + more: "See what's new", + }, + }, + updatesNotification: { + header: { + title: 'Update Available', + newVersion: 'new version {version: string}', + criticalBadge: 'critical update', + }, + controls: { + visitRelease: 'Visit release page', + }, + }, addGroup: { title: 'Add group', selectAll: 'Select all users', groupName: 'Group name', searchPlaceholder: 'Filter/Search', submit: 'Create group', + groupSettings: 'Group settings', + adminGroup: 'Admin group', }, editGroup: { title: 'Edit group', @@ -53,6 +72,8 @@ const en: BaseTranslation = { groupName: 'Group name', searchPlaceholder: 'Filter/Search', submit: 'Update group', + groupSettings: 'Group settings', + adminGroup: 'Admin group', }, deleteGroup: { title: 'Delete group {name:string}', @@ -947,7 +968,7 @@ const en: BaseTranslation = { }, openIdSettings: { general: { - title: 'External OpenID Settings', + title: 'External OpenID general settings', helper: 'Here you can change general OpenID behavior in your Defguard instance.', createAccount: { label: @@ -963,6 +984,28 @@ const en: BaseTranslation = { custom: 'Custom', documentation: 'Documentation', delete: 'Delete provider', + directory_sync_settings: { + title: 'Directory Sync Settings', + helper: + "Directory synchronization allows you to automatically synchronize users' status and groups from an external provider.", + notSupported: 'Directory sync is not supported for this provider.', + connectionTest: { + success: 'Connection successful', + error: 'Connection failed with error:', + }, + }, + selects: { + synchronize: { + all: 'All', + users: 'Users', + groups: 'Groups', + }, + behavior: { + keep: 'Keep', + disable: 'Disable', + delete: 'Delete', + }, + }, labels: { provider: { label: 'Provider', @@ -987,6 +1030,45 @@ const en: BaseTranslation = { helper: "Name of the OpenID provider to display on the login's page button. If not provided, the button will display generic 'Login with OIDC' text.", }, + enable_directory_sync: { + label: 'Enable directory sync', + }, + sync_target: { + label: 'Synchronize', + helper: + "What to synchronize from the external provider. You can choose between synchronizing both users' state and group memberships, or narrow it down to just one of these.", + }, + sync_interval: { + label: 'Synchronization interval', + helper: 'Interval in seconds between directory synchronizations.', + }, + user_behavior: { + label: 'User behavior', + helper: + 'Choose how to handle users that are not present in the external provider anymore. You can select between keeping, disabling, or deleting them.', + }, + admin_behavior: { + label: 'Admin behavior', + helper: + 'Choose how to handle Defguard admins that are not present in the external provider anymore. You can select between keeping them, disabling them or completely deleting them.', + }, + admin_email: { + label: 'Admin email', + helper: + 'Email address of the account on which behalf the synchronization checks will be performed, e.g. the person who setup the Google service account. See our documentation for more details.', + }, + service_account_used: { + label: 'Service account in use', + helper: + 'The service account currently being used for synchronization. You can change it by uploading a new service account key file.', + }, + service_account_key_file: { + label: 'Service Account Key file', + helper: + "Upload a new service account key file to set the service account used for synchronization. NOTE: The uploaded file won't be visible after saving the settings and reloading the page as it's contents are sensitive and are never sent back to the dashboard.", + uploaded: 'File uploaded', + uploadPrompt: 'Upload a service account key file', + }, }, }, }, diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 7f2efec6e..e1c0dbc08 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -91,6 +91,10 @@ type RootTranslation = { * E​d​i​t */ edit: string + /** + * D​i​s​m​i​s​s + */ + dismiss: string } /** * K​e​y @@ -134,6 +138,42 @@ type RootTranslation = { } } modals: { + updatesNotificationToaster: { + /** + * N​e​w​ ​v​e​r​s​i​o​n​ ​a​v​a​i​l​a​b​l​e​ ​{​v​e​r​s​i​o​n​} + * @param {string} version + */ + title: RequiredParams<'version'> + controls: { + /** + * S​e​e​ ​w​h​a​t​'​s​ ​n​e​w + */ + more: string + } + } + updatesNotification: { + header: { + /** + * U​p​d​a​t​e​ ​A​v​a​i​l​a​b​l​e + */ + title: string + /** + * n​e​w​ ​v​e​r​s​i​o​n​ ​{​v​e​r​s​i​o​n​} + * @param {string} version + */ + newVersion: RequiredParams<'version'> + /** + * c​r​i​t​i​c​a​l​ ​u​p​d​a​t​e + */ + criticalBadge: string + } + controls: { + /** + * V​i​s​i​t​ ​r​e​l​e​a​s​e​ ​p​a​g​e + */ + visitRelease: string + } + } addGroup: { /** * A​d​d​ ​g​r​o​u​p @@ -155,6 +195,14 @@ type RootTranslation = { * C​r​e​a​t​e​ ​g​r​o​u​p */ submit: string + /** + * G​r​o​u​p​ ​s​e​t​t​i​n​g​s + */ + groupSettings: string + /** + * A​d​m​i​n​ ​g​r​o​u​p + */ + adminGroup: string } editGroup: { /** @@ -177,6 +225,14 @@ type RootTranslation = { * U​p​d​a​t​e​ ​g​r​o​u​p */ submit: string + /** + * G​r​o​u​p​ ​s​e​t​t​i​n​g​s + */ + groupSettings: string + /** + * A​d​m​i​n​ ​g​r​o​u​p + */ + adminGroup: string } deleteGroup: { /** @@ -2351,7 +2407,7 @@ type RootTranslation = { openIdSettings: { general: { /** - * E​x​t​e​r​n​a​l​ ​O​p​e​n​I​D​ ​S​e​t​t​i​n​g​s + * E​x​t​e​r​n​a​l​ ​O​p​e​n​I​D​ ​g​e​n​e​r​a​l​ ​s​e​t​t​i​n​g​s */ title: string /** @@ -2390,6 +2446,60 @@ type RootTranslation = { * D​e​l​e​t​e​ ​p​r​o​v​i​d​e​r */ 'delete': string + directory_sync_settings: { + /** + * D​i​r​e​c​t​o​r​y​ ​S​y​n​c​ ​S​e​t​t​i​n​g​s + */ + title: string + /** + * D​i​r​e​c​t​o​r​y​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​a​l​l​o​w​s​ ​y​o​u​ ​t​o​ ​a​u​t​o​m​a​t​i​c​a​l​l​y​ ​s​y​n​c​h​r​o​n​i​z​e​ ​u​s​e​r​s​'​ ​s​t​a​t​u​s​ ​a​n​d​ ​g​r​o​u​p​s​ ​f​r​o​m​ ​a​n​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​. + */ + helper: string + /** + * D​i​r​e​c​t​o​r​y​ ​s​y​n​c​ ​i​s​ ​n​o​t​ ​s​u​p​p​o​r​t​e​d​ ​f​o​r​ ​t​h​i​s​ ​p​r​o​v​i​d​e​r​. + */ + notSupported: string + connectionTest: { + /** + * C​o​n​n​e​c​t​i​o​n​ ​s​u​c​c​e​s​s​f​u​l + */ + success: string + /** + * C​o​n​n​e​c​t​i​o​n​ ​f​a​i​l​e​d​ ​w​i​t​h​ ​e​r​r​o​r​: + */ + error: string + } + } + selects: { + synchronize: { + /** + * A​l​l + */ + all: string + /** + * U​s​e​r​s + */ + users: string + /** + * G​r​o​u​p​s + */ + groups: string + } + behavior: { + /** + * K​e​e​p + */ + keep: string + /** + * D​i​s​a​b​l​e + */ + disable: string + /** + * D​e​l​e​t​e + */ + 'delete': string + } + } labels: { provider: { /** @@ -2441,6 +2551,90 @@ type RootTranslation = { */ helper: string } + enable_directory_sync: { + /** + * E​n​a​b​l​e​ ​d​i​r​e​c​t​o​r​y​ ​s​y​n​c + */ + label: string + } + sync_target: { + /** + * S​y​n​c​h​r​o​n​i​z​e + */ + label: string + /** + * W​h​a​t​ ​t​o​ ​s​y​n​c​h​r​o​n​i​z​e​ ​f​r​o​m​ ​t​h​e​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​.​ ​Y​o​u​ ​c​a​n​ ​c​h​o​o​s​e​ ​b​e​t​w​e​e​n​ ​s​y​n​c​h​r​o​n​i​z​i​n​g​ ​b​o​t​h​ ​u​s​e​r​s​'​ ​s​t​a​t​e​ ​a​n​d​ ​g​r​o​u​p​ ​m​e​m​b​e​r​s​h​i​p​s​,​ ​o​r​ ​n​a​r​r​o​w​ ​i​t​ ​d​o​w​n​ ​t​o​ ​j​u​s​t​ ​o​n​e​ ​o​f​ ​t​h​e​s​e​. + */ + helper: string + } + sync_interval: { + /** + * S​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​i​n​t​e​r​v​a​l + */ + label: string + /** + * I​n​t​e​r​v​a​l​ ​i​n​ ​s​e​c​o​n​d​s​ ​b​e​t​w​e​e​n​ ​d​i​r​e​c​t​o​r​y​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​s​. + */ + helper: string + } + user_behavior: { + /** + * U​s​e​r​ ​b​e​h​a​v​i​o​r + */ + label: string + /** + * C​h​o​o​s​e​ ​h​o​w​ ​t​o​ ​h​a​n​d​l​e​ ​u​s​e​r​s​ ​t​h​a​t​ ​a​r​e​ ​n​o​t​ ​p​r​e​s​e​n​t​ ​i​n​ ​t​h​e​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​ ​a​n​y​m​o​r​e​.​ ​Y​o​u​ ​c​a​n​ ​s​e​l​e​c​t​ ​b​e​t​w​e​e​n​ ​k​e​e​p​i​n​g​,​ ​d​i​s​a​b​l​i​n​g​,​ ​o​r​ ​d​e​l​e​t​i​n​g​ ​t​h​e​m​. + */ + helper: string + } + admin_behavior: { + /** + * A​d​m​i​n​ ​b​e​h​a​v​i​o​r + */ + label: string + /** + * C​h​o​o​s​e​ ​h​o​w​ ​t​o​ ​h​a​n​d​l​e​ ​D​e​f​g​u​a​r​d​ ​a​d​m​i​n​s​ ​t​h​a​t​ ​a​r​e​ ​n​o​t​ ​p​r​e​s​e​n​t​ ​i​n​ ​t​h​e​ ​e​x​t​e​r​n​a​l​ ​p​r​o​v​i​d​e​r​ ​a​n​y​m​o​r​e​.​ ​Y​o​u​ ​c​a​n​ ​s​e​l​e​c​t​ ​b​e​t​w​e​e​n​ ​k​e​e​p​i​n​g​ ​t​h​e​m​,​ ​d​i​s​a​b​l​i​n​g​ ​t​h​e​m​ ​o​r​ ​c​o​m​p​l​e​t​e​l​y​ ​d​e​l​e​t​i​n​g​ ​t​h​e​m​. + */ + helper: string + } + admin_email: { + /** + * A​d​m​i​n​ ​e​m​a​i​l + */ + label: string + /** + * E​m​a​i​l​ ​a​d​d​r​e​s​s​ ​o​f​ ​t​h​e​ ​a​c​c​o​u​n​t​ ​o​n​ ​w​h​i​c​h​ ​b​e​h​a​l​f​ ​t​h​e​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​ ​c​h​e​c​k​s​ ​w​i​l​l​ ​b​e​ ​p​e​r​f​o​r​m​e​d​,​ ​e​.​g​.​ ​t​h​e​ ​p​e​r​s​o​n​ ​w​h​o​ ​s​e​t​u​p​ ​t​h​e​ ​G​o​o​g​l​e​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​.​ ​S​e​e​ ​o​u​r​ ​d​o​c​u​m​e​n​t​a​t​i​o​n​ ​f​o​r​ ​m​o​r​e​ ​d​e​t​a​i​l​s​. + */ + helper: string + } + service_account_used: { + /** + * S​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​i​n​ ​u​s​e + */ + label: string + /** + * T​h​e​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​c​u​r​r​e​n​t​l​y​ ​b​e​i​n​g​ ​u​s​e​d​ ​f​o​r​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​.​ ​Y​o​u​ ​c​a​n​ ​c​h​a​n​g​e​ ​i​t​ ​b​y​ ​u​p​l​o​a​d​i​n​g​ ​a​ ​n​e​w​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​k​e​y​ ​f​i​l​e​. + */ + helper: string + } + service_account_key_file: { + /** + * S​e​r​v​i​c​e​ ​A​c​c​o​u​n​t​ ​K​e​y​ ​f​i​l​e + */ + label: string + /** + * U​p​l​o​a​d​ ​a​ ​n​e​w​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​k​e​y​ ​f​i​l​e​ ​t​o​ ​s​e​t​ ​t​h​e​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​u​s​e​d​ ​f​o​r​ ​s​y​n​c​h​r​o​n​i​z​a​t​i​o​n​.​ ​N​O​T​E​:​ ​T​h​e​ ​u​p​l​o​a​d​e​d​ ​f​i​l​e​ ​w​o​n​'​t​ ​b​e​ ​v​i​s​i​b​l​e​ ​a​f​t​e​r​ ​s​a​v​i​n​g​ ​t​h​e​ ​s​e​t​t​i​n​g​s​ ​a​n​d​ ​r​e​l​o​a​d​i​n​g​ ​t​h​e​ ​p​a​g​e​ ​a​s​ ​i​t​'​s​ ​c​o​n​t​e​n​t​s​ ​a​r​e​ ​s​e​n​s​i​t​i​v​e​ ​a​n​d​ ​a​r​e​ ​n​e​v​e​r​ ​s​e​n​t​ ​b​a​c​k​ ​t​o​ ​t​h​e​ ​d​a​s​h​b​o​a​r​d​. + */ + helper: string + /** + * F​i​l​e​ ​u​p​l​o​a​d​e​d + */ + uploaded: string + /** + * U​p​l​o​a​d​ ​a​ ​s​e​r​v​i​c​e​ ​a​c​c​o​u​n​t​ ​k​e​y​ ​f​i​l​e + */ + uploadPrompt: string + } } } } @@ -4386,6 +4580,10 @@ export type TranslationFunctions = { * Edit */ edit: () => LocalizedString + /** + * Dismiss + */ + dismiss: () => LocalizedString } /** * Key @@ -4429,6 +4627,40 @@ export type TranslationFunctions = { } } modals: { + updatesNotificationToaster: { + /** + * New version available {version} + */ + title: (arg: { version: string }) => LocalizedString + controls: { + /** + * See what's new + */ + more: () => LocalizedString + } + } + updatesNotification: { + header: { + /** + * Update Available + */ + title: () => LocalizedString + /** + * new version {version} + */ + newVersion: (arg: { version: string }) => LocalizedString + /** + * critical update + */ + criticalBadge: () => LocalizedString + } + controls: { + /** + * Visit release page + */ + visitRelease: () => LocalizedString + } + } addGroup: { /** * Add group @@ -4450,6 +4682,14 @@ export type TranslationFunctions = { * Create group */ submit: () => LocalizedString + /** + * Group settings + */ + groupSettings: () => LocalizedString + /** + * Admin group + */ + adminGroup: () => LocalizedString } editGroup: { /** @@ -4472,6 +4712,14 @@ export type TranslationFunctions = { * Update group */ submit: () => LocalizedString + /** + * Group settings + */ + groupSettings: () => LocalizedString + /** + * Admin group + */ + adminGroup: () => LocalizedString } deleteGroup: { /** @@ -6626,7 +6874,7 @@ export type TranslationFunctions = { openIdSettings: { general: { /** - * External OpenID Settings + * External OpenID general settings */ title: () => LocalizedString /** @@ -6665,6 +6913,60 @@ export type TranslationFunctions = { * Delete provider */ 'delete': () => LocalizedString + directory_sync_settings: { + /** + * Directory Sync Settings + */ + title: () => LocalizedString + /** + * Directory synchronization allows you to automatically synchronize users' status and groups from an external provider. + */ + helper: () => LocalizedString + /** + * Directory sync is not supported for this provider. + */ + notSupported: () => LocalizedString + connectionTest: { + /** + * Connection successful + */ + success: () => LocalizedString + /** + * Connection failed with error: + */ + error: () => LocalizedString + } + } + selects: { + synchronize: { + /** + * All + */ + all: () => LocalizedString + /** + * Users + */ + users: () => LocalizedString + /** + * Groups + */ + groups: () => LocalizedString + } + behavior: { + /** + * Keep + */ + keep: () => LocalizedString + /** + * Disable + */ + disable: () => LocalizedString + /** + * Delete + */ + 'delete': () => LocalizedString + } + } labels: { provider: { /** @@ -6716,6 +7018,90 @@ export type TranslationFunctions = { */ helper: () => LocalizedString } + enable_directory_sync: { + /** + * Enable directory sync + */ + label: () => LocalizedString + } + sync_target: { + /** + * Synchronize + */ + label: () => LocalizedString + /** + * What to synchronize from the external provider. You can choose between synchronizing both users' state and group memberships, or narrow it down to just one of these. + */ + helper: () => LocalizedString + } + sync_interval: { + /** + * Synchronization interval + */ + label: () => LocalizedString + /** + * Interval in seconds between directory synchronizations. + */ + helper: () => LocalizedString + } + user_behavior: { + /** + * User behavior + */ + label: () => LocalizedString + /** + * Choose how to handle users that are not present in the external provider anymore. You can select between keeping, disabling, or deleting them. + */ + helper: () => LocalizedString + } + admin_behavior: { + /** + * Admin behavior + */ + label: () => LocalizedString + /** + * Choose how to handle Defguard admins that are not present in the external provider anymore. You can select between keeping them, disabling them or completely deleting them. + */ + helper: () => LocalizedString + } + admin_email: { + /** + * Admin email + */ + label: () => LocalizedString + /** + * Email address of the account on which behalf the synchronization checks will be performed, e.g. the person who setup the Google service account. See our documentation for more details. + */ + helper: () => LocalizedString + } + service_account_used: { + /** + * Service account in use + */ + label: () => LocalizedString + /** + * The service account currently being used for synchronization. You can change it by uploading a new service account key file. + */ + helper: () => LocalizedString + } + service_account_key_file: { + /** + * Service Account Key file + */ + label: () => LocalizedString + /** + * Upload a new service account key file to set the service account used for synchronization. NOTE: The uploaded file won't be visible after saving the settings and reloading the page as it's contents are sensitive and are never sent back to the dashboard. + */ + helper: () => LocalizedString + /** + * File uploaded + */ + uploaded: () => LocalizedString + /** + * Upload a service account key file + */ + uploadPrompt: () => LocalizedString + } } } } diff --git a/web/src/i18n/ko/index.ts b/web/src/i18n/ko/index.ts index 802a99cd3..6c41aacf9 100644 --- a/web/src/i18n/ko/index.ts +++ b/web/src/i18n/ko/index.ts @@ -1,7 +1,8 @@ /* eslint-disable max-len */ -import type { BaseTranslation } from '../i18n-types'; +import en from '../en'; +import { extendDictionary } from '../i18n-util'; -const ko: BaseTranslation = { +const ko = extendDictionary(en, { common: { conditions: { or: '또는', @@ -1807,6 +1808,6 @@ GitHub에 문의하거나 문제를 제출하기 전에 [docs.defguard.net](http `, }, }, -}; +}); export default ko; diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index f903b08b9..8ac796cf3 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -19,6 +19,7 @@ const pl: Translation = { copy: 'Skopiuj', rename: 'Zmień nazwę', edit: 'Edytuj', + dismiss: 'Odrzuć', }, conditions: { and: 'I', @@ -40,12 +41,30 @@ const pl: Translation = { insecureContext: 'Kontekst nie jest bezpieczny', }, modals: { + updatesNotification: { + header: { + criticalBadge: 'Aktualizacja krytyczna', + newVersion: 'Nowa wersja {version}', + title: 'Aktualizacja dostępna', + }, + controls: { + visitRelease: 'Zobacz stronę aktualizacji', + }, + }, + updatesNotificationToaster: { + title: 'Nowa wersja dostępna {version}', + controls: { + more: 'Zobacz co nowego', + }, + }, addGroup: { groupName: 'Nazwa grupy', searchPlaceholder: 'Szukaj', selectAll: 'Zaznacz wszystkich', submit: 'Stwórz grupę', title: 'Dodaj grupę', + groupSettings: 'Ustawienia grupy', + adminGroup: 'Grupa administratorska', }, editGroup: { groupName: 'Nazwa grupy', @@ -53,6 +72,8 @@ const pl: Translation = { selectAll: 'Zaznacz wszystkich', submit: 'Zmień grupę', title: 'Edytuj grupę', + groupSettings: 'Ustawienia grupy', + adminGroup: 'Grupa administratorska', }, deleteGroup: { title: 'Usuń grupę {name}', @@ -935,7 +956,7 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe }, openIdSettings: { general: { - title: 'Ustawienia zewnętrznego OpenID', + title: 'Ogólne ustawienia zewnętrznego OpenID', helper: 'Możesz tu zmienić ogólną mechanikę działania zewnętrznego OpenID w twojej instancji Defguarda.', createAccount: { @@ -952,6 +973,29 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe custom: 'Niestandardowy', documentation: 'Dokumentacja', delete: 'Usuń dostawcę', + + directory_sync_settings: { + title: 'Ustawienia synchronizacji katalogu', + helper: + 'Synchronizacja katalogu pozwala na automatyczną synchronizację grup użytkowników i ich statusu na podstawie zewnętrznego dostawcy.', + notSupported: 'Synchronizacja katalogu nie jest obsługiwana dla tego dostawcy.', + connectionTest: { + success: 'Połączenie zakończone sukcesem.', + error: 'Wystąpił błąd podczas próby połączenia:', + }, + }, + selects: { + synchronize: { + all: 'Wszystko', + users: 'Użytkownicy', + groups: 'Grupy', + }, + behavior: { + keep: 'Zachowaj', + disable: 'Dezaktywuj', + delete: 'Usuń', + }, + }, labels: { provider: { label: 'Dostawca', @@ -976,6 +1020,45 @@ Uwaga, podane tutaj konfiguracje nie posiadają klucza prywatnego. Musisz uzupe helper: 'Nazwa dostawcy OpenID, która będzie wyświetlana na przycisku logowania. Jeśli zostawisz to pole puste, przycisk będzie miał tekst "Zaloguj przez OIDC".', }, + enable_directory_sync: { + label: 'Włącz synchronizację katalogu', + }, + sync_target: { + label: 'Synchronizuj', + helper: + 'Co będzie synchronizowane z zewnętrznym dostawcą OpenID. Możesz wybrać pomiędzy synchronizacją statusu użytkowników, ich przynależności do grup lub synchronizacją obu.', + }, + sync_interval: { + label: 'Interwał synchronizacji', + helper: 'Odstęp czasu w sekundach pomiędzy synchronizacjami katalogu.', + }, + user_behavior: { + label: 'Zachowanie kont użytkowników', + helper: + 'Wybierz jak postępować z kontami użytkowników, które nie znajdują się w katalogu zewnętrznego dostawcy. Możesz wybrać między zachowaniem ich, dezaktywacją lub całkowitym usunięciem.', + }, + admin_behavior: { + label: 'Zachowanie kont administratorów', + helper: + 'Wybierz, jak postępować z kontami administratorów Defguard, które nie znajdują się w katalogu zewnętrznego dostawcy. Możesz wybrać między zachowaniem ich, dezaktywacją lub całkowitym usunięciem.', + }, + admin_email: { + label: 'E-mail administratora', + helper: + 'Adres e-mail konta, za pośrednictwem którego będzię odbywać się synchronizacja, np. e-mail konta osoby, która skonfigurowała konto usługi Google. Więcej szczegółów możesz znaleźć w naszej dokumentacji.', + }, + service_account_used: { + label: 'Używane konto usługi', + helper: + 'Obecnie używane konto usługi Google do synchronizacji. Możesz je zmienić, przesyłając nowy plik klucza konta usługi.', + }, + service_account_key_file: { + label: 'Plik klucza konta usługi', + helper: + 'Prześlij nowy plik klucza konta usługi, aby ustawić konto usługi używane do synchronizacji. UWAGA: Przesłany plik nie będzie widoczny po zapisaniu ustawień i ponownym załadowaniu strony, ponieważ jego zawartość jest poufna i nie jest przesyłana z powrotem do panelu.', + uploaded: 'Przesłany plik', + uploadPrompt: 'Prześlij plik klucza konta usługi', + }, }, }, }, diff --git a/web/src/pages/auth/AuthPage.tsx b/web/src/pages/auth/AuthPage.tsx index 503232488..c109fb70d 100644 --- a/web/src/pages/auth/AuthPage.tsx +++ b/web/src/pages/auth/AuthPage.tsx @@ -6,7 +6,6 @@ import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../i18n/i18n-react'; import SvgDefguardLogoLogin from '../../shared/components/svg/DefguardLogoLogin'; -import { isUserAdmin } from '../../shared/helpers/isUserAdmin'; import { useAppStore } from '../../shared/hooks/store/useAppStore'; import { useAuthStore } from '../../shared/hooks/store/useAuthStore'; import useApi from '../../shared/hooks/useApi'; @@ -110,9 +109,8 @@ export const AuthPage = () => { // authorization finished if (user) { - const isAdmin = isUserAdmin(user); let navigateURL = '/me'; - if (isAdmin) { + if (user.is_admin) { // check where to navigate administrator const appInfo = await getAppInfo(); const settings = await getSettings(); @@ -130,7 +128,7 @@ export const AuthPage = () => { navigateURL = '/admin/users'; } } - setAuthStore({ user, isAdmin }); + setAuthStore({ user }); resetMFAStore(); navigate(navigateURL, { replace: true }); } diff --git a/web/src/pages/groups/components/GroupsList/GroupsList.tsx b/web/src/pages/groups/components/GroupsList/GroupsList.tsx index 33aa81db6..ba734893c 100644 --- a/web/src/pages/groups/components/GroupsList/GroupsList.tsx +++ b/web/src/pages/groups/components/GroupsList/GroupsList.tsx @@ -42,8 +42,6 @@ export const GroupsList = ({ groups, search }: Props) => { ); }, [groups, search]); - const renderRow = useCallback((data: ListData) => , []); - const listHeaders = useMemo((): ListHeader[] => { return [ { @@ -60,6 +58,17 @@ export const GroupsList = ({ groups, search }: Props) => { ]; }, []); + const adminGroupCount = useCallback(() => { + return groups.filter((group) => group.is_admin).length; + }, [groups]); + + const renderRow = useCallback( + (data: ListData) => ( + + ), + [adminGroupCount], + ); + return ( { type RowProps = { group: GroupInfo; + disableDelete: boolean; }; -const CustomRow = ({ group }: RowProps) => { +const CustomRow = ({ group, disableDelete }: RowProps) => { const openModal = useAddGroupModal((s) => s.open); const [isDeleteModalOpen, setDeleteModalOpen] = useState(false); @@ -142,7 +152,7 @@ const CustomRow = ({ group }: RowProps) => { openModal(group); }} /> - {group.name.toLowerCase() !== 'admin' && ( + {!disableDelete && ( export type ModifyGroupFormFields = { name: string; members: string[]; + is_admin: boolean; }; const ModalContent = () => { @@ -131,6 +133,7 @@ const ModalContent = () => { return isUndefined(names?.find((n) => n === name)); }, LL.form.error.invalid()), members: z.array(z.string()), + is_admin: z.boolean(), }), [LL.form.error, groupInfo, groups], ); @@ -140,11 +143,13 @@ const ModalContent = () => { return { name: groupInfo.name, members: groupInfo.members ?? [], + is_admin: groupInfo.is_admin, }; } return { name: '', members: [], + is_admin: false, }; }, [groupInfo]); @@ -162,6 +167,7 @@ const ModalContent = () => { const sendValues: ModifyGroupsRequest = { name: values.name, members: values.members, + is_admin: values.is_admin, }; if (groupInfo) { editGroupMutation({ ...sendValues, originalName: groupInfo.name }); @@ -173,6 +179,14 @@ const ModalContent = () => { return (
+
+ + +
{users && } diff --git a/web/src/pages/groups/components/modals/AddGroupModal/style.scss b/web/src/pages/groups/components/modals/AddGroupModal/style.scss index 9c1756453..8947dd97b 100644 --- a/web/src/pages/groups/components/modals/AddGroupModal/style.scss +++ b/web/src/pages/groups/components/modals/AddGroupModal/style.scss @@ -28,6 +28,13 @@ } } + .group-settings { + padding-bottom: 25px; + display: flex; + flex-flow: column; + row-gap: 10px; + } + .select-all { cursor: pointer; height: 60px; diff --git a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx index dc5c44bc1..ccf4a1e93 100644 --- a/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx +++ b/web/src/pages/settings/components/OpenIdSettings/OpenIdSettings.tsx @@ -8,8 +8,7 @@ import { BigInfoBox } from '../../../../shared/defguard-ui/components/Layout/Big import { LoaderSpinner } from '../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; import useApi from '../../../../shared/hooks/useApi'; import { QueryKeys } from '../../../../shared/queries'; -import { OpenIdGeneralSettings } from './components/OpenIdGeneralSettings'; -import { OpenIdSettingsForm } from './components/OpenIdSettingsForm'; +import { OpenIdSettingsRootForm } from './components/OpenIdSettingsRootForm'; export const OpenIdSettings = () => { const { LL } = useI18nContext(); @@ -56,12 +55,7 @@ export const OpenIdSettings = () => { /> )} -
- -
-
- -
+ ); }; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx new file mode 100644 index 000000000..9e80c3305 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/DirectorySyncSettings.tsx @@ -0,0 +1,259 @@ +import './style.scss'; + +import parse from 'html-react-parser'; +import { useMemo, useState } from 'react'; +import { useController, UseFormReturn } from 'react-hook-form'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { FormCheckBox } from '../../../../../shared/defguard-ui/components/Form/FormCheckBox/FormCheckBox'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { FormSelect } from '../../../../../shared/defguard-ui/components/Form/FormSelect/FormSelect'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { ButtonStyleVariant } from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import SvgIconDownload from '../../../../../shared/defguard-ui/components/svg/IconDownload'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { OpenIdProvider } from '../../../../../shared/types'; +import { titleCase } from '../../../../../shared/utils/titleCase'; + +type FormFields = OpenIdProvider; + +const SUPPORTED_SYNC_PROVIDERS = ['Google']; + +export const DirsyncSettings = ({ + currentProvider, + formControl, +}: { + currentProvider: OpenIdProvider | null; + formControl: UseFormReturn; +}) => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + const [googleServiceAccountFileName, setGoogleServiceAccountFileName] = useState< + string | null + >(null); + const { + settings: { testDirsync }, + } = useApi(); + const { control, setValue } = formControl; + const toaster = useToaster(); + + const userBehaviorOptions = useMemo( + () => [ + { + value: 'keep', + label: localLL.form.selects.behavior.keep(), + key: 1, + }, + { + value: 'disable', + label: localLL.form.selects.behavior.disable(), + key: 2, + }, + { + value: 'delete', + label: localLL.form.selects.behavior.delete(), + key: 3, + }, + ], + [localLL.form.selects.behavior], + ); + + const syncTarget = useMemo( + () => [ + { + value: 'all', + label: localLL.form.selects.synchronize.all(), + key: 1, + }, + { + value: 'users', + label: localLL.form.selects.synchronize.users(), + key: 2, + }, + { + value: 'groups', + label: localLL.form.selects.synchronize.groups(), + key: 3, + }, + ], + [localLL.form.selects.synchronize], + ); + + const { + field: { value: enabled }, + } = useController({ control, name: 'directory_sync_enabled' }); + + return ( +
+
+

{localLL.form.directory_sync_settings.title()}

+ {localLL.form.directory_sync_settings.helper()} +
+
+ {SUPPORTED_SYNC_PROVIDERS.includes(currentProvider?.name ?? '') ? ( + currentProvider?.name === 'Google' ? ( + <> +
+ +
+ ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.sync_target.helper())} + } + disabled={!enterpriseEnabled} + /> + {parse(localLL.form.labels.sync_interval.helper())} + } + disabled={!enterpriseEnabled} + /> + ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.user_behavior.helper())} + } + disabled={!enterpriseEnabled} + /> + ({ + key: val, + displayValue: titleCase(val), + })} + labelExtras={ + {parse(localLL.form.labels.admin_behavior.helper())} + } + disabled={!enterpriseEnabled} + /> + {parse(localLL.form.labels.admin_email.helper())} + } + required={enabled} + /> +
+ +
+ + {parse(localLL.form.labels.service_account_used.helper())} + + } + disabled={!enterpriseEnabled} + required={enabled} + /> +
+
+ + {localLL.form.labels.service_account_key_file.helper()} +
+
+ { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const key = JSON.parse(e.target?.result as string); + setValue('google_service_account_key', key.private_key); + setValue('google_service_account_email', key.client_email); + setGoogleServiceAccountFileName(file.name); + }; + reader.readAsText(file); + } + }} + disabled={!enterpriseEnabled} + /> +
+ {' '} +

+ {googleServiceAccountFileName + ? `${localLL.form.labels.service_account_key_file.uploaded()}: ${googleServiceAccountFileName}` + : localLL.form.labels.service_account_key_file.uploadPrompt()} +

+
+
+
+
+ +
+ + ) : null + ) : ( +

+ {localLL.form.directory_sync_settings.notSupported()} +

+ )} +
+
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx new file mode 100644 index 000000000..14db2d563 --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdProviderSettings.tsx @@ -0,0 +1,175 @@ +import './style.scss'; + +import parse from 'html-react-parser'; +import { useCallback, useMemo } from 'react'; +import { UseFormReturn } from 'react-hook-form'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { Helper } from '../../../../../shared/defguard-ui/components/Layout/Helper/Helper'; +import { Select } from '../../../../../shared/defguard-ui/components/Layout/Select/Select'; +import { + SelectOption, + SelectSelectedValue, + SelectSizeVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Select/types'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import { OpenIdProvider } from '../../../../../shared/types'; + +export const OpenIdSettingsForm = ({ + setCurrentProvider, + currentProvider, + formControl, +}: { + setCurrentProvider: (provider: OpenIdProvider | null) => void; + currentProvider: OpenIdProvider | null; + formControl: UseFormReturn; +}) => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + const { control } = formControl; + + const options: SelectOption[] = useMemo( + () => [ + { + value: 'Google', + label: 'Google', + key: 1, + }, + { + value: 'Microsoft', + label: 'Microsoft', + key: 2, + }, + { + value: 'Custom', + label: localLL.form.custom(), + key: 3, + }, + ], + [localLL.form], + ); + + const renderSelected = useCallback( + (selected: string): SelectSelectedValue => { + const option = options.find((o) => o.value === selected); + + if (!option) throw Error("Selected value doesn't exist"); + + return { + key: option.key, + displayValue: option.label, + }; + }, + [options], + ); + + const getProviderUrl = useCallback(({ name }: { name: string }): string | null => { + switch (name) { + case 'Google': + return 'https://accounts.google.com'; + case 'Microsoft': + return `https://login.microsoftonline.com//v2.0`; + default: + return null; + } + }, []); + + const getProviderDisplayName = useCallback( + ({ name }: { name: string }): string | null => { + switch (name) { + case 'Google': + return 'Google'; + case 'Microsoft': + return 'Microsoft'; + default: + return null; + } + }, + [], + ); + + const handleChange = useCallback( + (val: string) => { + setCurrentProvider({ + ...currentProvider, + id: currentProvider?.id ?? 0, + name: val, + base_url: getProviderUrl({ name: val }) ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + display_name: + getProviderDisplayName({ name: val }) ?? currentProvider?.display_name ?? '', + google_service_account_email: currentProvider?.google_service_account_email ?? '', + google_service_account_key: currentProvider?.google_service_account_key ?? '', + admin_email: currentProvider?.admin_email ?? '', + directory_sync_enabled: currentProvider?.directory_sync_enabled ?? false, + directory_sync_interval: currentProvider?.directory_sync_interval ?? 600, + directory_sync_user_behavior: + currentProvider?.directory_sync_user_behavior ?? 'keep', + directory_sync_admin_behavior: + currentProvider?.directory_sync_admin_behavior ?? 'keep', + directory_sync_target: currentProvider?.directory_sync_target ?? 'all', + }); + }, + [currentProvider, getProviderUrl, getProviderDisplayName, setCurrentProvider], + ); + + return ( +
+
+

{localLL.form.title()}

+ {parse(localLL.form.helper())} +
+ handleChange(res)} - label={localLL.form.labels.provider.label()} - labelExtras={{parse(localLL.form.labels.provider.helper())}} - disabled={!enterpriseEnabled} - /> - {parse(localLL.form.labels.base_url.helper())}} - disabled={currentProvider?.name === 'Google' || !enterpriseEnabled} - /> - {parse(localLL.form.labels.client_id.helper())}} - disabled={!enterpriseEnabled} - /> - {parse(localLL.form.labels.client_secret.helper())} - } - type="password" - disabled={!enterpriseEnabled} - /> - {parse(localLL.form.labels.display_name.helper())} - } - disabled={!enterpriseEnabled || currentProvider?.name !== 'Custom'} - /> - - - {localLL.form.documentation()} - -
- ); -}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx new file mode 100644 index 000000000..3fd5fa8fc --- /dev/null +++ b/web/src/pages/settings/components/OpenIdSettings/components/OpenIdSettingsRootForm.tsx @@ -0,0 +1,218 @@ +import './style.scss'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import IconCheckmarkWhite from '../../../../../shared/components/svg/IconCheckmarkWhite'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { LoaderSpinner } from '../../../../../shared/defguard-ui/components/Layout/LoaderSpinner/LoaderSpinner'; +import { useAppStore } from '../../../../../shared/hooks/store/useAppStore'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { OpenIdProvider } from '../../../../../shared/types'; +import { DirsyncSettings } from './DirectorySyncSettings'; +import { OpenIdGeneralSettings } from './OpenIdGeneralSettings'; +import { OpenIdSettingsForm } from './OpenIdProviderSettings'; + +type FormFields = OpenIdProvider; + +export const OpenIdSettingsRootForm = () => { + const { LL } = useI18nContext(); + const localLL = LL.settingsPage.openIdSettings; + const [currentProvider, setCurrentProvider] = useState(null); + const queryClient = useQueryClient(); + const enterpriseEnabled = useAppStore((state) => state.enterprise_status?.enabled); + + const { + settings: { fetchOpenIdProviders, addOpenIdProvider, deleteOpenIdProvider }, + } = useApi(); + + const { isLoading } = useQuery({ + queryFn: fetchOpenIdProviders, + queryKey: [QueryKeys.FETCH_OPENID_PROVIDERS], + refetchOnMount: true, + refetchOnWindowFocus: false, + onSuccess: (provider) => { + setCurrentProvider(provider); + }, + retry: false, + enabled: enterpriseEnabled, + }); + + const toaster = useToaster(); + + const { mutate } = useMutation({ + mutationFn: addOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (error) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + }); + + const { mutate: deleteProvider } = useMutation({ + mutationFn: deleteOpenIdProvider, + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.FETCH_OPENID_PROVIDERS]); + toaster.success(LL.settingsPage.messages.editSuccess()); + }, + onError: (error) => { + toaster.error(LL.messages.error()); + console.error(error); + }, + }); + + const schema = useMemo( + () => + z.object({ + name: z.string().min(1, LL.form.error.required()), + base_url: z + .string() + .url(LL.form.error.invalid()) + .min(1, LL.form.error.required()), + client_id: z.string().min(1, LL.form.error.required()), + client_secret: z.string().min(1, LL.form.error.required()), + display_name: z.string(), + admin_email: z.string(), + google_service_account_email: z.string(), + google_service_account_key: z.string(), + directory_sync_enabled: z.boolean(), + directory_sync_interval: z.number().min(60, LL.form.error.invalid()), + directory_sync_user_behavior: z.string(), + directory_sync_admin_behavior: z.string(), + directory_sync_target: z.string(), + }), + [LL.form.error], + ); + + const defaultValues = useMemo( + (): FormFields => ({ + id: currentProvider?.id ?? 0, + name: currentProvider?.name ?? '', + base_url: currentProvider?.base_url ?? '', + client_id: currentProvider?.client_id ?? '', + client_secret: currentProvider?.client_secret ?? '', + display_name: currentProvider?.display_name ?? '', + admin_email: currentProvider?.admin_email ?? '', + google_service_account_email: currentProvider?.google_service_account_email ?? '', + google_service_account_key: currentProvider?.google_service_account_key ?? '', + directory_sync_enabled: currentProvider?.directory_sync_enabled ?? false, + directory_sync_interval: currentProvider?.directory_sync_interval ?? 600, + directory_sync_user_behavior: + currentProvider?.directory_sync_user_behavior ?? 'keep', + directory_sync_admin_behavior: + currentProvider?.directory_sync_admin_behavior ?? 'keep', + directory_sync_target: currentProvider?.directory_sync_target ?? 'all', + }), + [currentProvider], + ); + + const formControl = useForm({ + resolver: zodResolver(schema), + defaultValues, + mode: 'all', + }); + + const { handleSubmit, reset } = formControl; + + // Make sure the form data is fresh + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const conditionallyRequired: (keyof OpenIdProvider)[] = [ + 'admin_email', + 'google_service_account_email', + ]; + + const handleValidSubmit: SubmitHandler = (data) => { + // Some fields are required only if directory sync is enabled. + // Check if the required fields are filled in. + const formValues = formControl.getValues(); + const dirsync_enabled = formValues.directory_sync_enabled; + if (dirsync_enabled) { + const missingRequiredFields = conditionallyRequired.filter( + (field) => + formValues[field]?.toString().length === 0 || formValues[field] === null, + ); + if (missingRequiredFields.length) { + for (const field of missingRequiredFields) { + formControl.setError(field, { + type: 'required', + message: LL.form.error.required(), + }); + } + return; + } + } + mutate(data); + }; + + const handleDeleteProvider = useCallback(() => { + if (currentProvider) { + deleteProvider(currentProvider.name); + setCurrentProvider(null); + } + }, [currentProvider, deleteProvider]); + + return ( +
+
+
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+ +
+
+ + +
+ + )} +
+ ); +}; diff --git a/web/src/pages/settings/components/OpenIdSettings/components/style.scss b/web/src/pages/settings/components/OpenIdSettings/components/style.scss index 2318b3d18..1e10f0144 100644 --- a/web/src/pages/settings/components/OpenIdSettings/components/style.scss +++ b/web/src/pages/settings/components/OpenIdSettings/components/style.scss @@ -1,6 +1,7 @@ @use '@scssutils' as *; -#openid-settings { +#openid-settings, +#dirsync-settings { & > header { & > .btn { margin-left: auto; @@ -11,6 +12,7 @@ gap: 10px; } } + form { width: 100%; display: flex; @@ -21,12 +23,123 @@ width: 100%; } } + .select { padding-bottom: 25px; } + .checkbox-row { display: flex; align-items: center; gap: 10px; } + + #sync-not-supported { + text-align: center; + margin: 20px 0; + color: var(--text-body-tertiary); + } + + .hidden-input { + display: none; + } + + .file-upload-container { + background-color: var(--surface-frame-bg); + display: flex; + justify-content: center; + align-items: center; + border: 2px dashed var(--border-primary); + border-radius: 10px; + position: relative; + padding: 20px; + z-index: 0; + } + + .file-upload-container.dragging { + border-color: var(--border-secondary); + } + + .file-upload { + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; + z-index: 1; + cursor: pointer; + } + + .select-container { + margin-bottom: 0; + } + + .upload-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + + p { + color: var(--text-body-secondary); + font-size: 14px; + } + + & > svg { + width: 20px; + height: 20px; + transform: rotate(180deg); + } + } + + .test-connection { + display: flex; + width: 100%; + justify-content: flex-end; + } + + #enable-dir-sync { + margin-bottom: 25px; + } +} + +#root-form { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + grid-column: 1 / -1; + + & > .controls { + grid-column: 1 / -1; + display: flex; + gap: 10px; + justify-content: flex-end; + } + + & > .left { + grid-column: 1; + } + + & > .right { + grid-column: 2; + } + + & > .left, + & > .right { + width: 100%; + max-width: 750px; + display: flex; + flex-flow: column; + row-gap: 48px; + } +} + +.loader { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + grid-column: 1 / -1; + min-height: 300px; } diff --git a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx index 7e0428616..850051b92 100644 --- a/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx +++ b/web/src/pages/users/UserProfile/ProfileDetails/ProfileDetailsForm/ProfileDetailsForm.tsx @@ -62,7 +62,7 @@ export const ProfileDetailsForm = () => { const setUserProfile = useUserProfileStore((state) => state.setState); const submitButton = useRef(null); const queryClient = useQueryClient(); - const isAdmin = useAuthStore((state) => state.isAdmin); + const isAdmin = useAuthStore((state) => state.user?.is_admin); const isMe = useUserProfileStore((state) => state.isMe); const [fetchGroups, setFetchGroups] = useState(false); const { diff --git a/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx b/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx index 1ef8fd984..0e172be58 100644 --- a/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx +++ b/web/src/pages/users/UserProfile/UserDevices/UserDevices.tsx @@ -21,7 +21,7 @@ export const UserDevices = () => { const { LL } = useI18nContext(); const userProfile = useUserProfileStore((state) => state.userProfile); const initAddDevice = useAddDevicePageStore((state) => state.init); - const isAdmin = useAuthStore((state) => state.isAdmin); + const isAdmin = useAuthStore((state) => state.user?.is_admin); const canManageDevices = !!( userProfile && (!settings?.admin_device_management || isAdmin) diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx index ae820377c..c3caccea5 100644 --- a/web/src/pages/users/UserProfile/UserProfile.tsx +++ b/web/src/pages/users/UserProfile/UserProfile.tsx @@ -127,7 +127,7 @@ const EditModeControls = () => { const { LL } = useI18nContext(); const { breakpoint } = useBreakpoint(deviceBreakpoints); const userProfile = useUserProfileStore((state) => state.userProfile); - const isAdmin = useAuthStore((state) => state.isAdmin); + const isAdmin = useAuthStore((state) => state.user?.is_admin); const isMe = useUserProfileStore((state) => state.isMe); const setUserProfileState = useUserProfileStore((state) => state.setState); const setDeleteUserModalState = useModalStore((state) => state.setDeleteUserModal); diff --git a/web/src/shared/components/Layout/RenderMarkdown/RenderMarkdown.tsx b/web/src/shared/components/Layout/RenderMarkdown/RenderMarkdown.tsx new file mode 100644 index 000000000..d8bfc4ac8 --- /dev/null +++ b/web/src/shared/components/Layout/RenderMarkdown/RenderMarkdown.tsx @@ -0,0 +1,26 @@ +import Markdown from 'react-markdown'; + +type Props = { + content: string; +}; +export const RenderMarkdown = ({ content }: Props) => { + const parse = (): string => { + const lines = content.split(/\r?\n/); + + const processedLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const isLastLine = i === lines.length - 1; + const currentLine = lines[i]; + + if (isLastLine && currentLine.trim() === '') { + processedLines.push(currentLine); + } else { + processedLines.push(currentLine.trim()); + } + } + + return processedLines.join('\n'); + }; + return {parse()}; +}; diff --git a/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx new file mode 100644 index 000000000..919849dde --- /dev/null +++ b/web/src/shared/components/Layout/VersionUpdateToast/VersionUpdateToast.tsx @@ -0,0 +1,113 @@ +import './style.scss'; + +import dayjs from 'dayjs'; +import { useCallback, useEffect } from 'react'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { ToastOptions } from '../../../defguard-ui/components/Layout/ToastManager/Toast/types'; +import { useToastsStore } from '../../../defguard-ui/hooks/toasts/useToastStore'; +import { useUpdatesStore } from '../../../hooks/store/useUpdatesStore'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const VersionUpdateToast = ({ id }: ToastOptions) => { + const removeToast = useToastsStore((s) => s.removeToast); + const updateData = useUpdatesStore((s) => s.update); + const dismissal = useUpdatesStore((s) => s.dismissal); + const setUpdateStore = useUpdatesStore((s) => s.setStore); + const { LL } = useI18nContext(); + + const closeToast = useCallback(() => { + removeToast(id); + }, [id, removeToast]); + + const handleOpenModal = () => { + setUpdateStore({ modalVisible: true }); + closeToast(); + }; + + const handleDismiss = () => { + if (updateData) { + setUpdateStore({ + dismissal: { + dismissedAt: dayjs.utc().toISOString(), + version: updateData.version, + }, + }); + closeToast(); + } + }; + + useEffect(() => { + if (dismissal && dismissal.version === updateData?.version) { + closeToast(); + } + }, [closeToast, dismissal, updateData?.version]); + + if (!updateData) return null; + + return ( +
+
+

+ {LL.modals.updatesNotificationToaster.title({ + version: updateData.version, + })} +

+ {updateData.critical && ( + + + + + )} + + + + + + +
+
+ + +
+
+ ); +}; diff --git a/web/src/shared/components/Layout/VersionUpdateToast/style.scss b/web/src/shared/components/Layout/VersionUpdateToast/style.scss new file mode 100644 index 000000000..a8f777be7 --- /dev/null +++ b/web/src/shared/components/Layout/VersionUpdateToast/style.scss @@ -0,0 +1,63 @@ +.update-toaster { + box-sizing: border-box; + padding: 10px 20px 15px; + box-shadow: 0px 6px 10px 0px rgba(0, 0, 0, 0.1); + border-radius: 10px; + background-color: var(--surface-nav-bg); + border-radius: 15px; + + min-width: 270px; + max-width: 100%; + + .top { + padding-bottom: 8px; + display: flex; + align-items: center; + flex-flow: row nowrap; + min-width: 230px; + column-gap: 8px; + + p { + color: var(--text-body-primary); + text-wrap: nowrap; + white-space: none; + @include typography(app-number); + } + + & > a { + margin-left: auto; + border: none; + background-color: transparent; + cursor: pointer; + width: 22px; + height: 22px; + display: inline-flex; + flex-flow: row; + align-content: center; + align-items: center; + justify-content: center; + padding: 4px; + box-sizing: border-box; + text-decoration: none; + } + } + + .bottom { + min-width: 230px; + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + + & > * { + cursor: pointer; + } + + button { + border: none; + background: transparent; + color: var(--text-body-primary); + @include typography(app-copyright); + } + } +} diff --git a/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx b/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx new file mode 100644 index 000000000..4646738b0 --- /dev/null +++ b/web/src/shared/components/modals/UpdateNotificationModal/UpdateNotificationModal.tsx @@ -0,0 +1,96 @@ +// eslint-disable-next-line simple-import-sort/imports +import { shallow } from 'zustand/shallow'; + +import { Modal } from '../../../defguard-ui/components/Layout/modals/Modal/Modal'; +import { useUpdatesStore } from '../../../hooks/store/useUpdatesStore'; +import { UpdateNotificationModalIcons } from './components/UpdateNotificationModalIcons'; +import { Button } from '../../../defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../defguard-ui/components/Layout/Button/types'; +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { RenderMarkdown } from '../../Layout/RenderMarkdown/RenderMarkdown'; +import './style.scss'; +import dayjs from 'dayjs'; + +export const UpdateNotificationModal = () => { + const isOpen = useUpdatesStore((s) => s.modalVisible); + const close = useUpdatesStore((s) => s.closeModal, shallow); + + return ( + { + close(); + }} + className="updates-modal" + id="updates-modal" + disableClose + > + + + ); +}; + +const ModalContent = () => { + const { LL } = useI18nContext(); + const localLL = LL.modals.updatesNotification; + const data = useUpdatesStore((s) => s.update); + const setStore = useUpdatesStore((s) => s.setStore, shallow); + if (!data) return null; + return ( +
+
+
+ +

{localLL.header.title()}

+
+
+

+ {localLL.header.newVersion({ + version: data.version, + })} +

+ {data.critical && ( +
+ + {localLL.header.criticalBadge()} +
+ )} +
+
+
+
+ +
+
+
+
+
+ ); +}; diff --git a/web/src/shared/components/modals/UpdateNotificationModal/components/UpdateNotificationModalIcons.tsx b/web/src/shared/components/modals/UpdateNotificationModal/components/UpdateNotificationModalIcons.tsx new file mode 100644 index 000000000..7b3914e9d --- /dev/null +++ b/web/src/shared/components/modals/UpdateNotificationModal/components/UpdateNotificationModalIcons.tsx @@ -0,0 +1,62 @@ +type Icon = 'update' | 'alert'; + +type Props = { + variant: Icon; +}; + +export const UpdateNotificationModalIcons = ({ variant }: Props) => { + switch (variant) { + case 'alert': + return ( + + + + + ); + case 'update': + return ( + + + + + + ); + } +}; diff --git a/web/src/shared/components/modals/UpdateNotificationModal/style.scss b/web/src/shared/components/modals/UpdateNotificationModal/style.scss new file mode 100644 index 000000000..5906bb71e --- /dev/null +++ b/web/src/shared/components/modals/UpdateNotificationModal/style.scss @@ -0,0 +1,206 @@ +// modal content setup +#updates-modal { + max-width: 100%; + overflow-x: hidden; + + @include media-breakpoint-up(lg) { + background-color: transparent; + box-shadow: var(--box-shadow); + } + + .content-wrapper { + background-color: var(--surface-nav-bg); + border-radius: 0; + + @include media-breakpoint-down(lg) { + display: flex; + flex-flow: column; + align-items: center; + justify-content: flex-start; + width: 100%; + box-sizing: border-box; + padding: 20px 20px 40px; + row-gap: 30px; + } + + @include media-breakpoint-up(lg) { + background-color: var(--surface-main-primary); + border-radius: 15px; + } + + & > .top { + box-sizing: border-box; + display: flex; + flex-flow: column; + align-items: center; + justify-content: center; + row-gap: 10px; + background-color: var(--surface-main-primary); + width: 100%; + padding: 20px; + + @include media-breakpoint-down(lg) { + border-radius: 15px; + min-width: 280px; + max-width: calc(100% - 40px); + } + + @include media-breakpoint-up(md) { + padding: 50px; + } + + @include media-breakpoint-up(lg) { + background-color: transparent; + padding: 56px 20px; + border-top-left-radius: 15px; + border-top-right-radius: 15px; + } + + & > div { + display: flex; + flex-flow: row; + align-items: center; + } + + .header { + column-gap: 20px; + + svg { + width: 30px; + height: 30px; + + @include media-breakpoint-up(lg) { + width: 50px; + height: 50px; + } + } + + p { + color: var(--surface-icon-secondary); + + text-wrap: nowrap; + + @include media-breakpoint-down(md) { + font-size: 24px; + } + + @include typography(app-title); + } + } + + .info { + column-gap: 8px; + + @include media-breakpoint-down(md) { + display: flex; + flex-flow: column; + row-gap: 10px; + } + + .version { + color: var(--surface-icon-secondary); + + @include media-breakpoint-down(md) { + font-size: 16px; + } + + @include typography(app-welcome-2); + } + + .badge { + display: flex; + flex-flow: row; + column-gap: 8px; + box-sizing: border-box; + padding: 5px 20px; + border-radius: 5px; + background-color: var(--surface-nav-bg); + align-items: center; + justify-content: center; + min-height: 25px; + color: var(--surface-alert-primary); + + span, + p { + @include typography(app-body-2); + } + } + } + } + + & > .bottom { + background-color: var(--surface-nav-bg); + + @include media-breakpoint-up(lg) { + border-radius: 15px; + padding: 30px 50px 50px; + } + + & > .content { + padding-bottom: 40px; + display: flex; + flex-flow: column; + row-gap: 20px; + + ul { + padding-left: 20px; + box-sizing: border-box; + } + + p, + span, + a, + li { + @include typography(app-body-2); + } + + h1 { + @include typography(app-welcome-1); + } + + h2 { + @include typography(markdown-h4); + } + + h3 { + @include typography(markdown-h5); + } + } + + .controls { + width: 100%; + display: flex; + gap: 20px; + flex-flow: column-reverse; + + @include media-breakpoint-up(md) { + flex-flow: row; + } + + .btn, + a { + width: 100%; + text-decoration: none; + } + + .btn { + max-height: 47px; + } + } + } + } +} + +// modal container setup +.modal-root { + .modal.updates-modal { + min-height: 100dvh; + + @include media-breakpoint-up(lg) { + grid-template-columns: max(920px); + padding-top: 200px; + justify-content: center; + align-items: start; + } + } +} diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index b61bef8c8..95acffb5b 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit b61bef8c893b4a27f62a3463d847274591520398 +Subproject commit 95acffb5bd8e12e836f75f8e659b0c3c9a9bea69 diff --git a/web/src/shared/hooks/store/useAuthStore.ts b/web/src/shared/hooks/store/useAuthStore.ts index ef8e9fa81..b0a6cc24e 100644 --- a/web/src/shared/hooks/store/useAuthStore.ts +++ b/web/src/shared/hooks/store/useAuthStore.ts @@ -9,20 +9,18 @@ export const useAuthStore = createWithEqualityFn()( persist( (set, get) => ({ user: undefined, - isAdmin: undefined, openIdParams: undefined, loginSubject: new Subject(), setState: (newState) => set({ ...get(), ...newState }), resetState: () => set({ user: undefined, - isAdmin: undefined, openIdParams: undefined, }), }), { name: 'auth-store', - partialize: (store) => pick(store, ['user', 'isAdmin', 'authLocation']), + partialize: (store) => pick(store, ['user', 'authLocation']), storage: createJSONStorage(() => sessionStorage), }, ), @@ -31,7 +29,6 @@ export const useAuthStore = createWithEqualityFn()( export interface AuthStore { loginSubject: Subject; user?: User; - isAdmin?: boolean; // If this is set, redirect user to allow page and nowhere else openIdParams?: URLSearchParams; setState: (newState: Partial) => void; diff --git a/web/src/shared/hooks/store/useUpdatesStore.tsx b/web/src/shared/hooks/store/useUpdatesStore.tsx new file mode 100644 index 000000000..8a3e749aa --- /dev/null +++ b/web/src/shared/hooks/store/useUpdatesStore.tsx @@ -0,0 +1,75 @@ +import { isObject, pick } from 'lodash-es'; +import { persist } from 'zustand/middleware'; +import { createWithEqualityFn } from 'zustand/traditional'; + +import { VersionUpdateToast } from '../../components/Layout/VersionUpdateToast/VersionUpdateToast'; +import { ToastType } from '../../defguard-ui/components/Layout/ToastManager/Toast/types'; +import { useToastsStore } from '../../defguard-ui/hooks/toasts/useToastStore'; + +const keysToPersist: Array = ['dismissal']; + +const defaultState: StoreValues = { + modalVisible: false, + dismissal: undefined, + update: undefined, +}; + +export const useUpdatesStore = createWithEqualityFn()( + persist( + (set, get) => ({ + ...defaultState, + setStore: (vals) => set(vals), + openModal: () => set({ modalVisible: true }), + closeModal: () => set({ modalVisible: false }), + setUpdate: (update) => { + const state = get(); + if (!state.dismissal || state.dismissal.version !== update.version) { + useToastsStore.getState().addToast({ + customComponent: VersionUpdateToast, + message: '', + type: ToastType.INFO, + }); + set({ update: update }); + } else { + set({ update: update }); + } + }, + clearUpdate: () => set({ update: undefined }), + }), + { + name: 'updates-store', + version: 1, + partialize: (s) => pick(s, keysToPersist), + }, + ), + isObject, +); + +type Store = StoreValues & StoreMethods; + +type Dismissal = { + version: string; + dismissedAt: string; +}; + +export type UpdateInfo = { + version: string; + critical: boolean; + // Markdown + notes: string; + release_notes_url: string; +}; + +type StoreValues = { + modalVisible: boolean; + dismissal?: Dismissal; + update?: UpdateInfo; +}; + +type StoreMethods = { + setStore: (values: Partial) => void; + openModal: () => void; + closeModal: () => void; + setUpdate: (value: NonNullable) => void; + clearUpdate: () => void; +}; diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 14b8bcb01..9cc19d235 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -476,6 +476,17 @@ const useApi = (props?: HookProps): ApiHook => { return {}; }); + const getNewVersion: ApiHook['getNewVersion'] = () => + client.get('/updates').then((res) => { + if (res.status === 204) { + return null; + } + return res.data; + }); + + const testDirsync: ApiHook['settings']['testDirsync'] = () => + client.get('/test_directory_sync').then(unpackRequest); + useEffect(() => { client.interceptors.response.use( (res) => { @@ -504,6 +515,7 @@ const useApi = (props?: HookProps): ApiHook => { return { getAppInfo, + getNewVersion, changePasswordSelf, getEnterpriseStatus, getEnterpriseInfo, @@ -645,6 +657,7 @@ const useApi = (props?: HookProps): ApiHook => { addOpenIdProvider, deleteOpenIdProvider, editOpenIdProvider, + testDirsync, }, support: { downloadSupportData, diff --git a/web/src/shared/queries.ts b/web/src/shared/queries.ts index c930639cd..f4d3c6459 100644 --- a/web/src/shared/queries.ts +++ b/web/src/shared/queries.ts @@ -31,4 +31,5 @@ export const QueryKeys = { FETCH_ENTERPRISE_STATUS: 'FETCH_ENTERPRISE_STATUS', FETCH_ENTERPRISE_SETTINGS: 'FETCH_ENTERPRISE_SETTINGS', FETCH_ENTERPRISE_INFO: 'FETCH_ENTERPRISE_INFO', + FETCH_NEW_VERSION: 'FETCH_NEW_VERSION', }; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 5274ead6d..7fc4d0b8f 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -6,6 +6,8 @@ import { } from '@github/webauthn-json'; import { AxiosError, AxiosPromise } from 'axios'; +import { UpdateInfo } from './hooks/store/useUpdatesStore'; + export type ApiError = AxiosError; export type ApiErrorResponse = { @@ -47,6 +49,7 @@ export type User = { authorized_apps?: OAuth2AuthorizedApps[]; is_active: boolean; enrolled: boolean; + is_admin: boolean; }; export type UserProfile = { @@ -414,6 +417,7 @@ export type ModifyGroupsRequest = { name: string; // array of usernames members?: string[]; + is_admin: boolean; }; export type AddUsersToGroupsRequest = { @@ -434,6 +438,7 @@ export type AuthenticationKey = { export interface ApiHook { getAppInfo: () => Promise; + getNewVersion: () => Promise; changePasswordSelf: (data: ChangePasswordSelfRequest) => Promise; getEnterpriseStatus: () => Promise; getEnterpriseInfo: () => Promise; @@ -589,6 +594,7 @@ export interface ApiHook { addOpenIdProvider: (data: OpenIdProvider) => Promise; deleteOpenIdProvider: (name: string) => Promise; editOpenIdProvider: (data: OpenIdProvider) => Promise; + testDirsync: () => Promise; }; support: { downloadSupportData: () => Promise; @@ -920,6 +926,14 @@ export interface OpenIdProvider { client_id: string; client_secret: string; display_name: string; + google_service_account_key?: string; + google_service_account_email?: string; + admin_email?: string; + directory_sync_enabled: boolean; + directory_sync_interval: number; + directory_sync_user_behavior: 'keep' | 'disable' | 'delete'; + directory_sync_admin_behavior: 'keep' | 'disable' | 'delete'; + directory_sync_target: 'all' | 'users' | 'groups'; } export interface EditOpenidClientRequest { @@ -1053,4 +1067,10 @@ export type GroupInfo = { name: string; members: string[]; vpn_locations: string[]; + is_admin: boolean; +}; + +export type DirsyncTestResponse = { + message: string; + success: boolean; }; diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index c9aa3c3a4..b935329d2 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -38,10 +38,9 @@ export const validateIpOrDomainList = ( allowMasks = false, allowIPv6 = false, ): boolean => { - const trimed = val.replace(' ', ''); - const split = trimed.split(splitWith); + const trimmed = val.replace(' ', ''); + const split = trimmed.split(splitWith); for (const value of split) { - console.log(allowIPv6 && !validateIPv6(value, allowMasks)); if ( !validateIPv4(value, allowMasks) && !patternValidDomain.test(value) && @@ -53,7 +52,7 @@ export const validateIpOrDomainList = ( return true; }; -// Returns flase when invalid +// Returns false when invalid export const validateIPv4 = (ip: string, allowMask = false): boolean => { if (allowMask) { if (ip.includes('/')) { diff --git a/web/vite.config.mts b/web/vite.config.mts index bcabb2ba0..6d699f994 100644 --- a/web/vite.config.mts +++ b/web/vite.config.mts @@ -1,57 +1,74 @@ +import 'dotenv/config'; + import react from '@vitejs/plugin-react-swc'; import autoprefixer from 'autoprefixer'; import * as path from 'path'; import { defineConfig } from 'vite'; -export default defineConfig({ - clearScreen: false, - plugins: [react()], - server: { - strictPort: false, - port: 3000, - proxy: { - '/api': { - target: 'http://127.0.0.1:8000/', - changeOrigin: true, - }, - '/.well-known': { - target: 'http://127.0.0.1:8000/', - changeOrigin: true, +// eslint-disable-next-line no-empty-pattern +export default ({}) => { + let proxyTarget = 'http://127.0.0.1:8000'; + const envProxyTarget = process.env.PROXY_TARGET; + + if (envProxyTarget && envProxyTarget.length > 0) { + proxyTarget = envProxyTarget; + } + + return defineConfig({ + clearScreen: false, + plugins: [react()], + server: { + strictPort: false, + port: 3000, + cors: true, + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + '/.well-known': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, + '/svg': { + target: proxyTarget, + changeOrigin: true, + secure: false, + }, }, - '/svg': { - target: 'http://127.0.0.1:8000/', - changeOrigin: true, + fs: { + allow: ['.'], }, }, - fs: { - allow: ['.'], - }, - }, - envPrefix: ['VITE_'], - assetsInclude: ['./src/shared/assets/**/*'], - resolve: { - alias: { - '@scss': path.resolve(__dirname, '/src/shared/scss'), - '@scssutils': path.resolve(__dirname, '/src/shared/scss/global'), - }, - }, - build: { - chunkSizeWarningLimit: 10000, - rollupOptions: { - logLevel: 'silent', - onwarn: (warning, warn) => { - return; + envPrefix: ['VITE_'], + assetsInclude: ['./src/shared/assets/**/*'], + resolve: { + alias: { + '@scss': path.resolve(__dirname, './src/shared/scss'), + '@scssutils': path.resolve(__dirname, './src/shared/scss/global'), }, }, - }, - css: { - preprocessorOptions: { - scss: { - additionalData: `@use "@scssutils" as *;\n`, + build: { + chunkSizeWarningLimit: 10000, + rollupOptions: { + logLevel: 'silent', + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onwarn: (_warning, _warn) => { + return; + }, }, }, - postcss: { - plugins: [autoprefixer], + css: { + preprocessorOptions: { + scss: { + additionalData: `@use "@scssutils" as *;\n`, + }, + }, + postcss: { + plugins: [autoprefixer], + }, }, - }, -}); + }); +};