feat: instance groups workspace (#6380)

* feat: instancd groups mapping to workspace

* npm run check

* nits

* improve apis

* sqlx prepare and ee-repo ref

* adding workspace assignment in groups page

* nits

* correct rollback

* sqlx

* ee repo ref

* revert accidental ee-repo-ref commit to main

* ee repo ref

* revert accidental ee-repo-ref commit to main
This commit is contained in:
Alexander Petric
2025-08-14 05:06:00 -04:00
committed by GitHub
parent 57155dd318
commit 58975b58dc
48 changed files with 1330 additions and 253 deletions
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n workspace_settings\n SET\n error_handler = NULL,\n error_handler_extra_args = NULL,\n error_handler_muted_on_cancel = NULL\n WHERE \n workspace_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "04f8d738b1073b8c58db0965e8fdcdb872dce2c1872359c1bebb553a29ba1637"
}
@@ -147,6 +147,16 @@
"ordinal": 28,
"name": "ducklake",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"name": "auto_add_instance_groups",
"type_info": "TextArray"
},
{
"ordinal": 30,
"name": "auto_add_instance_groups_roles",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -183,6 +193,8 @@
true,
true,
false,
true,
true,
true
]
},
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) \n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow \n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "0a56301b5aaf57339cb2904c8f617366b74e891034d32f2867ccb019da869fc8"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator) VALUES ($1, $2, $3, false, $4) ON CONFLICT DO NOTHING",
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via) VALUES ($1, $2, $3, false, $4, $5) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
@@ -8,10 +8,11 @@
"Varchar",
"Varchar",
"Varchar",
"Bool"
"Bool",
"Jsonb"
]
},
"nullable": []
},
"hash": "e822d186203fe809b764007ad7c02870a3b7d93ae43b40ac2cd3181dffab0837"
"hash": "0d7ce0397ef15c9d6cdaeaa2730a9e27fb7387ca24980df1481e6a94622ef006"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n workspace_settings\n SET\n error_handler = NULL,\n error_handler_extra_args = NULL,\n error_handler_muted_on_cancel = NULL\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0fa105c49c8345916716514444bd3616ae4d114216c659233fbbc3c047e6b30a"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "emails",
"type_info": "VarcharArray"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
true,
null
]
},
"hash": "10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(auto_add_instance_groups, '{}') FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "coalesce",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "18e550f4ec23d465632449b88c4b25931f145f771b93828e8e6dfcc1f906443d"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n ig.name as group_name,\n ws.workspace_id,\n w.name as workspace_name,\n ws.auto_add_instance_groups_roles->ig.name as role\n FROM instance_group ig\n INNER JOIN workspace_settings ws ON ws.auto_add_instance_groups IS NOT NULL\n AND ig.name = ANY(ws.auto_add_instance_groups)\n INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n ORDER BY ig.name, ws.workspace_id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "group_name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "workspace_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "role",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "1e6c125c884002a1565b11a2c308ce89f9726d21bbb3cc712ad0b6450cbb44e6"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM email_to_igroup WHERE igroup = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "21099fabde943edc90d3a0125e8490691b2701d11f918da3bb5ae610d5c023a0"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT username, added_via\n FROM usr\n WHERE workspace_id = $1 AND email = $2\n AND added_via->>'source' = 'instance_group'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "added_via",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n workspace_settings\n SET\n error_handler = $1,\n error_handler_extra_args = $2,\n error_handler_muted_on_cancel = $3\n WHERE \n workspace_id = $4\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Json",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "2549197c6750bb1a20041b2d7c2654a788a32a9282ed314c2b3bcafe6550efca"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_add_instance_groups = NULL, auto_add_instance_groups_roles = NULL WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "31db242523c534b2abbb5f4985f56dd4493d19611ed988d694d9f4289dc29c48"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n auto_add_instance_groups_roles\n FROM workspace_settings\n WHERE\n auto_add_instance_groups IS NOT NULL\n AND $1 = ANY(auto_add_instance_groups)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "auto_add_instance_groups_roles",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "3ebc91867be0a4830ef4dbbe833aed348f78e3cb68fb1f3c855a491bdcda5017"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT usr.username \n FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username AND usr_to_group.workspace_id = $2\n WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "3fdfcab1a54c166b1d8d43215d61268a251160db4630f0342522091668f36af0"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET auto_add_instance_groups = $2, auto_add_instance_groups_roles = $3 WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray",
"Jsonb"
]
},
"nullable": []
},
"hash": "404233e74aaafd987879c6c87d1fd80928e6fade09e60a9a2d35121c81885cca"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT workspace_id, username, email\n FROM usr\n WHERE email = $1\n AND added_via->>'source' = 'instance_group'\n AND added_via->>'group' = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET\n auto_add_instance_groups = array_remove(auto_add_instance_groups, $1),\n auto_add_instance_groups_roles = auto_add_instance_groups_roles - $1\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "4a446b97cf813226d25ca40cefca2999cdcf938f817327e9168b3edb0fce7fb7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT usage.usage FROM usage \n WHERE is_workspace = false \n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND id = $1",
"query": "\n SELECT usage.usage FROM usage\n WHERE is_workspace = false\n AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)\n AND id = $1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
"hash": "a7b028e832396ee4d1ad6dfd44ba6134344f3eb37dbf0254154eba31b9cc2ed3"
"hash": "51cfe6efb154934f8cfbe77a7313891ef5c46a7febe4125d164a5ae5b55af2d6"
}
@@ -147,6 +147,16 @@
"ordinal": 28,
"name": "ducklake",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"name": "auto_add_instance_groups",
"type_info": "TextArray"
},
{
"ordinal": 30,
"name": "auto_add_instance_groups_roles",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -183,6 +193,8 @@
true,
true,
false,
true,
true,
true
]
},
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -57,6 +62,7 @@
false,
false,
false,
true,
true
]
},
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -58,6 +63,7 @@
false,
false,
false,
true,
true
]
},
@@ -45,11 +45,16 @@
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 9,
"ordinal": 10,
"name": "name",
"type_info": "Varchar"
}
@@ -69,6 +74,7 @@
false,
false,
true,
true,
false,
true
]
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM group_ WHERE (group_.extra_perms ->> CONCAT('u/', $1::text))::boolean AND name = $2 AND workspace_id = $4) OR exists(\n SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f \n WHERE $2 = g.name AND $4 = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY($3::text[])\n AND value::boolean)",
"query": "SELECT EXISTS(SELECT 1 FROM group_ WHERE (group_.extra_perms ->> CONCAT('u/', $1::text))::boolean AND name = $2 AND workspace_id = $4) OR exists(\n SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f\n WHERE $2 = g.name AND $4 = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY($3::text[])\n AND value::boolean)",
"describe": {
"columns": [
{
@@ -21,5 +21,5 @@
null
]
},
"hash": "388d6fd335a3f8a405b2d465892cf21a68d4b50ace25ef88c4cdf5b347c3d5eb"
"hash": "6b2820973026b1628cb93fda943bc389a261a3bb975c86ac02dd1c552da92295"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT usr.username\n FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username AND usr_to_group.workspace_id = $2\n WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "6f36c05e6097066441acab227c0d5206d5a9a9828b04e4c107f1ecab4a9363d4"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id FROM workspace_settings WHERE auto_add_instance_groups IS NOT NULL AND $1 = ANY(auto_add_instance_groups)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "711c72a72298bb656882b35071e910dab721cda070f9c39bba3729bdc1467496"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT usr.email, usage.executions\n FROM usr\n , LATERAL (\n SELECT COALESCE(SUM(duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_as_completed_job\n WHERE workspace_id = $1\n AND job_kind NOT IN ('flow', 'flowpreview', 'flownode')\n AND email = usr.email\n AND now() - '1 week'::interval < created_at \n ) usage\n WHERE workspace_id = $1\n ",
"query": "\n SELECT usr.email, usage.executions\n FROM usr\n , LATERAL (\n SELECT COALESCE(SUM(duration_ms + 1000)/1000 , 0)::BIGINT executions\n FROM v2_as_completed_job\n WHERE workspace_id = $1\n AND job_kind NOT IN ('flow', 'flowpreview', 'flownode')\n AND email = usr.email\n AND now() - '1 week'::interval < created_at\n ) usage\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
null
]
},
"hash": "bff39cc57aba0729ddef1d53f3806c6736556f0a14b489d6708f9879393f9ea3"
"hash": "89940a53f29b173b6a8717f057a81a4f2edad152581950fdd80d758a0d242c17"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET is_admin = true WHERE workspace_id = $1 AND email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "94feb54cc965f19bfaf09966fd431b925c565f08c100bea812d3ac0e28664ccb"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT email FROM usr WHERE added_via->>'source' = 'instance_group' AND added_via->>'group' = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n workspace_settings\n SET\n error_handler = $1,\n error_handler_extra_args = $2,\n error_handler_muted_on_cancel = $3\n WHERE\n workspace_id = $4\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Json",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "97b6e5779661343587c297b275df9e75fd22924ad2dbe124bb361f3e7fd8bfe0"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n auto_invite_domain,\n auto_invite_operator,\n auto_add,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n error_handler,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n large_file_storage,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations\n FROM \n workspace_settings\n WHERE \n workspace_id = $1\n ",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n auto_invite_domain,\n auto_invite_operator,\n auto_add,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n error_handler,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n large_file_storage,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_add_instance_groups,\n auto_add_instance_groups_roles\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
@@ -147,6 +147,16 @@
"ordinal": 28,
"name": "git_app_installations",
"type_info": "Jsonb"
},
{
"ordinal": 29,
"name": "auto_add_instance_groups",
"type_info": "TextArray"
},
{
"ordinal": 30,
"name": "auto_add_instance_groups_roles",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -183,8 +193,10 @@
true,
true,
true,
false
false,
true,
true
]
},
"hash": "8960b73f0d3cbfa0729d24ecbe3f635592feee6a5724fdff662c6ac1f3c6ddc8"
"hash": "ac1fd12e9ec92022be38aee0e91e9002e0e810d0e76dce5ced1000a7cb514adb"
}
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "c269f14ae9ae4e96eff9483eb84cccbdfa316e5db051f1d52085a2d5447c81ae"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema \n FROM flow \n LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $1",
"query": "SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema\n FROM flow\n LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $1",
"describe": {
"columns": [
{
@@ -42,5 +42,5 @@
true
]
},
"hash": "974c7e623f3dfa440e134eaaa8d029334c0645147200219c39b2c00b30941172"
"hash": "ccbf71572dfc60b69a1666d4f1a883ba724d1e9c3303df32b001737b80a4b9f9"
}
@@ -42,6 +42,11 @@
"ordinal": 7,
"name": "role",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "added_via",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -57,6 +62,7 @@
false,
false,
false,
true,
true
]
},
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT email_to_igroup.email\n FROM email_to_igroup\n INNER JOIN instance_group ON instance_group.name = email_to_igroup.igroup\n WHERE instance_group.name = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET added_via = $1 WHERE workspace_id = $2 AND email = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc"
}
@@ -41,11 +41,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true
]
},
+1 -1
View File
@@ -1 +1 @@
5a3f583656e7f754705ea3b5263593092cbba404
391a67b7af2c874ee86971cc299ade066de00fc8
@@ -0,0 +1,4 @@
-- Remove auto-add columns for instance groups
ALTER TABLE workspace_settings
DROP COLUMN auto_add_instance_groups,
DROP COLUMN auto_add_instance_groups_roles;
@@ -0,0 +1,4 @@
-- Add auto-add columns for instance groups
ALTER TABLE workspace_settings
ADD COLUMN auto_add_instance_groups text[] DEFAULT '{}',
ADD COLUMN auto_add_instance_groups_roles jsonb DEFAULT '{}';
@@ -0,0 +1,3 @@
-- Remove added_via tracking
DROP INDEX idx_usr_added_via;
ALTER TABLE usr DROP COLUMN added_via;
@@ -0,0 +1,8 @@
-- Add added_via column to track how users were added to workspaces
-- NULL = manual addition
-- {"source": "domain", "domain": "company.com"} = domain auto-add
-- {"source": "instance_group", "group": "developers", "role": "developer"} = instance group auto-add
ALTER TABLE usr ADD COLUMN added_via jsonb DEFAULT NULL;
-- Add index for efficient queries on added_via
CREATE INDEX idx_usr_added_via ON usr USING gin (added_via);
+106
View File
@@ -1944,6 +1944,14 @@ paths:
type: boolean
auto_add:
type: boolean
auto_add_instance_groups:
type: array
items:
type: string
auto_add_instance_groups_roles:
type: object
additionalProperties:
type: string
plan:
type: string
customer_id:
@@ -2351,6 +2359,39 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/edit_instance_groups:
post:
summary: edit instance groups
operationId: editInstanceGroups
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Instance Groups Configuration
required: true
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: string
roles:
type: object
additionalProperties:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_webhook:
post:
summary: edit webhook
@@ -10713,6 +10754,21 @@ paths:
type: array
items:
$ref: "#/components/schemas/InstanceGroup"
/groups/list_with_workspaces:
get:
summary: list instance groups with workspace information
operationId: listInstanceGroupsWithWorkspaces
tags:
- group
responses:
"200":
description: instance group list with workspaces
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/InstanceGroupWithWorkspaces"
/groups/get/{name}:
get:
@@ -14583,6 +14639,10 @@ components:
type: array
items:
type: string
added_via:
nullable: true
allOf:
- $ref: "#/components/schemas/UserSource"
required:
- email
- username
@@ -14594,6 +14654,22 @@ components:
- folders
- folders_owners
UserSource:
type: object
properties:
source:
type: string
enum: [domain, instance_group, manual]
description: "How the user was added to the workspace"
domain:
type: string
description: "The domain used for auto-invite (when source is 'domain')"
group:
type: string
description: "The instance group name (when source is 'instance_group')"
required:
- source
UserUsage:
type: object
properties:
@@ -16811,6 +16887,8 @@ components:
InstanceGroup:
type: object
required:
- name
properties:
name:
type: string
@@ -16820,6 +16898,34 @@ components:
type: array
items:
type: string
InstanceGroupWithWorkspaces:
type: object
required:
- name
properties:
name:
type: string
summary:
type: string
emails:
type: array
items:
type: string
workspaces:
type: array
items:
$ref: "#/components/schemas/WorkspaceInfo"
WorkspaceInfo:
type: object
properties:
workspace_id:
type: string
workspace_name:
type: string
role:
type: string
required:
- name
+82 -2
View File
@@ -44,6 +44,7 @@ pub fn workspaced_service() -> Router {
pub fn global_service() -> Router {
Router::new()
.route("/list", get(list_igroups))
.route("/list_with_workspaces", get(list_igroups_with_workspaces))
.route("/get/:name", get(get_igroup))
.route("/create", post(create_igroup))
.route("/update/:name", post(update_igroup))
@@ -187,7 +188,7 @@ pub async fn require_is_owner(
) -> Result<()> {
let is_owner = query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE (group_.extra_perms ->> CONCAT('u/', $1::text))::boolean AND name = $2 AND workspace_id = $4) OR exists(
SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f
SELECT 1 FROM group_ g, jsonb_each_text(g.extra_perms) f
WHERE $2 = g.name AND $4 = g.workspace_id AND SPLIT_PART(key, '/', 1) = 'g' AND key = ANY($3::text[])
AND value::boolean)",
username,
@@ -418,7 +419,7 @@ async fn get_group(
let group = not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
let members = sqlx::query_scalar!(
"SELECT usr.username
"SELECT usr.username
FROM usr_to_group LEFT JOIN usr ON usr_to_group.usr = usr.username AND usr_to_group.workspace_id = $2
WHERE group_ = $1 AND usr.workspace_id = $2 AND usr_to_group.workspace_id = $2",
name,
@@ -638,6 +639,21 @@ struct IGroup {
summary: Option<String>,
emails: Option<Vec<String>>,
}
#[derive(Serialize)]
struct IGroupWithWorkspaces {
name: String,
summary: Option<String>,
emails: Option<Vec<String>>,
workspaces: Vec<WorkspaceInfo>,
}
#[derive(Serialize, Clone)]
struct WorkspaceInfo {
workspace_id: String,
workspace_name: String,
role: String,
}
async fn list_igroups(Extension(db): Extension<DB>) -> JsonResult<Vec<IGroup>> {
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
@@ -652,6 +668,70 @@ async fn list_igroups(Extension(db): Extension<DB>) -> JsonResult<Vec<IGroup>> {
return Ok(Json(groups));
}
async fn list_igroups_with_workspaces(Extension(db): Extension<DB>) -> JsonResult<Vec<IGroupWithWorkspaces>> {
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// Get all instance groups with their emails first
let groups = sqlx::query_as!(
IGroup,
"SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary"
)
.fetch_all(&mut *tx)
.await?;
// Get all workspace mappings for instance groups in a single query
let workspace_mappings = sqlx::query!(
r#"
SELECT
ig.name as group_name,
ws.workspace_id,
w.name as workspace_name,
ws.auto_add_instance_groups_roles->ig.name as role
FROM instance_group ig
INNER JOIN workspace_settings ws ON ws.auto_add_instance_groups IS NOT NULL
AND ig.name = ANY(ws.auto_add_instance_groups)
INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false
ORDER BY ig.name, ws.workspace_id
"#
)
.fetch_all(&mut *tx)
.await?;
// Create a map of group_name -> Vec<WorkspaceInfo>
let mut workspaces_by_group: std::collections::HashMap<String, Vec<WorkspaceInfo>> = std::collections::HashMap::new();
for mapping in workspace_mappings {
let role = mapping.role
.and_then(|r| r.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "developer".to_string());
let workspace_info = WorkspaceInfo {
workspace_id: mapping.workspace_id.clone(),
workspace_name: mapping.workspace_name,
role,
};
workspaces_by_group
.entry(mapping.group_name)
.or_insert_with(Vec::new)
.push(workspace_info);
}
let mut result = Vec::new();
for group in groups {
let workspaces = workspaces_by_group.get(&group.name).cloned().unwrap_or_default();
result.push(IGroupWithWorkspaces {
name: group.name,
summary: group.summary,
emails: group.emails,
workspaces,
});
}
tx.commit().await?;
return Ok(Json(result));
}
async fn get_igroup(Path(name): Path<String>, Extension(db): Extension<DB>) -> JsonResult<IGroup> {
let group = sqlx::query_as!(
IGroup,
+120 -75
View File
@@ -9,6 +9,7 @@
#![allow(non_snake_case)]
use quick_cache::sync::Cache;
use sqlx::{Postgres, Transaction};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -262,6 +263,8 @@ pub struct User {
pub operator: bool,
pub disabled: bool,
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added_via: Option<serde_json::Value>,
}
#[derive(Serialize)]
@@ -494,7 +497,7 @@ async fn list_user_usage(
WHERE workspace_id = $1
AND job_kind NOT IN ('flow', 'flowpreview', 'flownode')
AND email = usr.email
AND now() - '1 week'::interval < created_at
AND now() - '1 week'::interval < created_at
) usage
WHERE workspace_id = $1
",
@@ -786,8 +789,8 @@ async fn get_usage(
) -> Result<String> {
let usage = sqlx::query_scalar!(
"
SELECT usage.usage FROM usage
WHERE is_workspace = false
SELECT usage.usage FROM usage
WHERE is_workspace = false
AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)
AND id = $1",
email
@@ -810,6 +813,8 @@ pub struct User2 {
pub role: Option<String>,
pub super_admin: bool,
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub added_via: Option<serde_json::Value>,
}
async fn get_user(w_id: &str, username: &str, db: &DB) -> Result<Option<UserInfo>> {
@@ -1315,49 +1320,15 @@ async fn update_workspace_user(
require_admin(authed.is_admin, &authed.username)?;
if let Some(a) = eu.is_admin {
sqlx::query_scalar!(
"UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
if let Some(a) = eu.operator {
sqlx::query_scalar!(
"UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
if let Some(a) = eu.disabled {
sqlx::query_scalar!(
"UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3",
a,
&username_to_update,
&w_id
)
.execute(&mut *tx)
.await?;
}
audit_log(
&mut *tx,
&authed,
"users.update",
ActionKind::Update,
update_workspace_user_internal(
&w_id,
Some(&username_to_update),
None,
)
.await?;
&username_to_update,
eu.is_admin,
eu.operator,
eu.disabled,
&mut tx,
Some(&authed)
).await?;
let user_email = sqlx::query_scalar!(
"SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
@@ -1500,6 +1471,105 @@ async fn create_user(
crate::users_oss::create_user(authed, db, webhook, argon2, nu).await
}
/// Internal helper for updating workspace user permissions - used by both API and system operations
pub async fn update_workspace_user_internal(
w_id: &str,
username_to_update: &str,
is_admin: Option<bool>,
operator: Option<bool>,
disabled: Option<bool>,
tx: &mut Transaction<'_, Postgres>,
authed: Option<&ApiAuthed>, // None for system operations
) -> Result<()> {
if let Some(a) = is_admin {
sqlx::query_scalar!(
"UPDATE usr SET is_admin = $1 WHERE username = $2 AND workspace_id = $3",
a,
username_to_update,
w_id
)
.execute(&mut **tx)
.await?;
}
if let Some(a) = operator {
sqlx::query_scalar!(
"UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3",
a,
username_to_update,
w_id
)
.execute(&mut **tx)
.await?;
}
if let Some(a) = disabled {
sqlx::query_scalar!(
"UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3",
a,
username_to_update,
w_id
)
.execute(&mut **tx)
.await?;
}
// Only audit if we have an authenticated user (API calls)
if let Some(auth) = authed {
audit_log(
&mut **tx,
auth,
"users.update",
ActionKind::Update,
w_id,
Some(username_to_update),
None,
)
.await?;
}
Ok(())
}
/// Internal helper for deleting workspace users - used by both API and system operations
pub async fn delete_workspace_user_internal(
w_id: &str,
username_to_delete: &str,
email_to_delete: &str,
tx: &mut Transaction<'_, Postgres>,
authed: Option<&ApiAuthed>, // None for system operations
) -> Result<()> {
sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 AND workspace_id = $2",
email_to_delete,
w_id
)
.execute(&mut **tx)
.await?;
sqlx::query!(
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
username_to_delete,
w_id
)
.execute(&mut **tx)
.await?;
// Only audit if we have an authenticated user (API calls)
if let Some(auth) = authed {
audit_log(
&mut **tx,
auth,
"users.delete",
ActionKind::Delete,
w_id,
Some(username_to_delete),
None,
)
.await?;
}
Ok(())
}
async fn delete_workspace_user(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1519,32 +1589,7 @@ async fn delete_workspace_user(
let email_to_delete = not_found_if_none(email_to_delete_o, "User", &username_to_delete)?;
sqlx::query_scalar!(
"DELETE FROM usr WHERE email = $1 AND workspace_id = $2",
email_to_delete,
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
&username_to_delete,
&w_id
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.delete",
ActionKind::Delete,
&w_id,
Some(&username_to_delete),
None,
)
.await?;
delete_workspace_user_internal(&w_id, &username_to_delete, &email_to_delete, &mut tx, Some(&authed)).await?;
tx.commit().await?;
handle_deployment_metadata(
@@ -2096,8 +2141,8 @@ async fn get_all_runnables(
})?;
let mut tx = db.clone().begin(&nauthed).await?;
let flows = sqlx::query!(
"SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema
FROM flow
"SELECT flow.workspace_id as workspace, flow.path, summary, description, flow_version.schema
FROM flow
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.workspace_id = $1",
workspace
@@ -2580,9 +2625,9 @@ async fn update_username_in_workpsace<'c>(
// ---- flows ----
sqlx::query!(
r#"INSERT INTO flow
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
FROM flow
FROM flow
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
new_username,
old_username,
+37 -8
View File
@@ -101,6 +101,7 @@ pub fn workspaced_service() -> Router {
)
.route("/edit_webhook", post(edit_webhook))
.route("/edit_auto_invite", post(edit_auto_invite))
.route("/edit_instance_groups", post(edit_instance_groups))
.route("/edit_deploy_to", post(edit_deploy_to))
.route(
"/get_secondary_storage_names",
@@ -252,6 +253,10 @@ pub struct WorkspaceSettings {
pub operator_settings: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_app_installations: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_add_instance_groups: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_add_instance_groups_roles: Option<serde_json::Value>,
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug)]
@@ -462,7 +467,7 @@ async fn get_settings(
let settings = sqlx::query_as!(
WorkspaceSettings,
r#"
SELECT
SELECT
workspace_id,
slack_team_id,
teams_team_id,
@@ -491,10 +496,12 @@ async fn get_settings(
mute_critical_alerts,
color,
operator_settings,
git_app_installations
FROM
git_app_installations,
auto_add_instance_groups,
auto_add_instance_groups_roles
FROM
workspace_settings
WHERE
WHERE
workspace_id = $1
"#,
&w_id
@@ -726,6 +733,28 @@ async fn edit_auto_invite(
crate::workspaces_oss::edit_auto_invite(authed, db, w_id, ea).await
}
#[cfg(feature = "private")]
async fn edit_instance_groups(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(config): Json<crate::workspaces_ee::EditInstanceGroups>,
) -> Result<String> {
crate::workspaces_ee::edit_instance_groups(authed, db, w_id, config).await
}
#[cfg(not(feature = "private"))]
async fn edit_instance_groups(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_config): Json<serde_json::Value>,
) -> Result<String> {
Err(Error::BadRequest(
"Instance groups are only available on Windmill Enterprise Edition".to_string(),
))
}
async fn edit_webhook(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1716,13 +1745,13 @@ async fn edit_error_handler(
sqlx::query!(
r#"
UPDATE
UPDATE
workspace_settings
SET
error_handler = $1,
error_handler_extra_args = $2,
error_handler_muted_on_cancel = $3
WHERE
WHERE
workspace_id = $4
"#,
error_handler,
@@ -1735,13 +1764,13 @@ async fn edit_error_handler(
} else {
sqlx::query!(
r#"
UPDATE
UPDATE
workspace_settings
SET
error_handler = NULL,
error_handler_extra_args = NULL,
error_handler_muted_on_cancel = NULL
WHERE
WHERE
workspace_id = $1
"#,
&w_id
@@ -13,10 +13,11 @@
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { CancelablePromise, User, UserUsage } from '$lib/gen'
import { UserService, WorkspaceService, type WorkspaceInvite } from '$lib/gen'
import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { Loader2, Mails, Search } from 'lucide-svelte'
import { Loader2, Mails, Search, Plus } from 'lucide-svelte'
import Select from '$lib/components/select/Select.svelte'
import SearchItems from '../SearchItems.svelte'
import Cell from '../table/Cell.svelte'
import Row from '../table/Row.svelte'
@@ -34,11 +35,47 @@
let autoAdd: boolean | undefined = $state(undefined)
let nbDisplayed = $state(30)
// Instance group auto-add settings
let instanceGroups: Array<{name: string, summary?: string, emails?: string[]}> = $state([])
let autoAddInstanceGroups: string[] = $state([])
let autoAddInstanceGroupsRoles: Record<string, string> = $state({})
// Add new instance group form state
let selectedNewInstanceGroup: string | undefined = $state(undefined)
let selectedNewRole: string | undefined = $state('developer')
// Available groups for dropdowns - filter out already configured groups
let availableGroupItems = $derived(
instanceGroups
.filter(group => !autoAddInstanceGroups.includes(group.name))
.map(group => ({
value: group.name,
label: group.name + (group.summary ? ` - ${group.summary}` : '')
}))
)
// Sort users so manual users come first, then instance group users
let sortedUsers = $derived(() => {
const userList = (filteredUsers || users || []).slice()
return userList.sort((a: User, b: User) => {
const aIsInstanceGroup = a.added_via?.source === 'instance_group' ? 1 : 0
const bIsInstanceGroup = b.added_via?.source === 'instance_group' ? 1 : 0
return aIsInstanceGroup - bIsInstanceGroup
})
})
let hasNonManualUsers = $derived(
(filteredUsers || users || []).some((user: User) => user.added_via?.source === 'instance_group' || user.added_via?.source === 'domain')
)
async function loadSettings(): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
auto_invite_domain = settings.auto_invite_domain
operatorOnly = settings.auto_invite_operator
autoAdd = settings.auto_add
autoAddInstanceGroups = settings.auto_add_instance_groups || []
autoAddInstanceGroupsRoles = settings.auto_add_instance_groups_roles || {}
}
let getUsagePromise: CancelablePromise<UserUsage[]> | undefined = undefined
@@ -77,6 +114,87 @@
allowedAutoDomain = await WorkspaceService.isDomainAllowed()
}
async function loadInstanceGroups(): Promise<void> {
try {
instanceGroups = await GroupService.listInstanceGroups()
} catch (e) {
console.warn('Failed to load instance groups:', e)
instanceGroups = []
}
}
async function saveInstanceGroupSettings(): Promise<void> {
try {
await WorkspaceService.editInstanceGroups({
workspace: $workspaceStore ?? '',
requestBody: {
groups: autoAddInstanceGroups,
roles: autoAddInstanceGroupsRoles
}
})
sendUserToast('Instance group settings saved')
// Refresh user list to show newly auto-added users
listUsers()
} catch (e) {
console.error('Failed to save instance group settings:', e)
sendUserToast('Failed to save settings', true)
}
}
async function addInstanceGroup(): Promise<void> {
if (!selectedNewInstanceGroup || !selectedNewRole) return
const groupToAdd = selectedNewInstanceGroup
const roleToAdd = selectedNewRole
try {
autoAddInstanceGroups = [...autoAddInstanceGroups, groupToAdd]
autoAddInstanceGroupsRoles[groupToAdd] = roleToAdd
// Reset form
selectedNewInstanceGroup = undefined
selectedNewRole = 'developer'
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroups = autoAddInstanceGroups.filter(g => g !== groupToAdd)
delete autoAddInstanceGroupsRoles[groupToAdd]
sendUserToast('Failed to add instance group', true)
}
}
async function removeInstanceGroup(groupName: string): Promise<void> {
const previousGroups = [...autoAddInstanceGroups]
const previousRole = autoAddInstanceGroupsRoles[groupName]
try {
autoAddInstanceGroups = autoAddInstanceGroups.filter(g => g !== groupName)
delete autoAddInstanceGroupsRoles[groupName]
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroups = previousGroups
if (previousRole) {
autoAddInstanceGroupsRoles[groupName] = previousRole
}
sendUserToast('Failed to remove instance group', true)
}
}
async function updateGroupRole(groupName: string, role: string): Promise<void> {
const previousRole = autoAddInstanceGroupsRoles[groupName]
try {
autoAddInstanceGroupsRoles[groupName] = role
await saveInstanceGroupSettings()
} catch (e) {
// Rollback on error
autoAddInstanceGroupsRoles[groupName] = previousRole
sendUserToast('Failed to update role', true)
}
}
let domain = $derived($userStore?.email.split('@')[1])
$effect(() => {
@@ -87,6 +205,7 @@
getUsage()
listInvites()
loadSettings()
loadInstanceGroups()
})
}
})
@@ -100,6 +219,7 @@
})
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let removeInstanceGroupConfirmedCallback: (() => void) | undefined = $state(undefined)
async function removeAllInvitesFromDomain() {
await Promise.all(
@@ -274,7 +394,10 @@
invite_all: !isCloudHosted(),
auto_add: showInvites ? (autoAdd ?? false) : true
}
: { operator: undefined, auto_add: undefined }
: {
operator: undefined,
auto_add: undefined
}
})
loadSettings()
listInvites()
@@ -292,6 +415,179 @@
</div>
{/snippet}
</Popover>
{#if instanceGroups.length > 0}
<Popover
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
usePointerDownOutside
floatingClass="!z-20"
>
{#snippet trigger()}
<Button
color={autoAddInstanceGroups.length > 0 ? 'green' : 'gray'}
variant="border"
size="xs"
nonCaptureEvent={true}
startIcon={{ icon: Mails }}
>Instance groups: {autoAddInstanceGroups.length}
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col p-4 min-w-[500px]">
<div class="flex flex-col gap-4">
<span class="text-sm leading-6 font-semibold">
Auto-add instance groups
</span>
<!-- Add new instance group form -->
{#if availableGroupItems.length > 0}
<div class="flex w-full mt-1 gap-2 items-end justify-between">
<div class="flex gap-2 items-end">
<div class="flex flex-col gap-1">
<span class="text-xs text-tertiary">Instance group</span>
<Select
items={availableGroupItems}
placeholder="Select group"
bind:value={selectedNewInstanceGroup}
class="max-w-[160px]"
disablePortal={true}
/>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-tertiary">Role</span>
<ToggleButtonGroup
selected={selectedNewRole}
on:selected={(e) => {
selectedNewRole = e.detail
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
<Button
color="blue"
size="xs"
startIcon={{ icon: Plus }}
disabled={!selectedNewInstanceGroup || !selectedNewRole}
onclick={addInstanceGroup}
>
Add
</Button>
</div>
{/if}
<!-- Configured groups table -->
{#if autoAddInstanceGroups.length > 0}
<div class="flex flex-col gap-2">
<p class="text-sm font-medium text-secondary">Configured groups:</p>
<div class="flex flex-col gap-1">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-tertiary">
<th class="pb-2 w-1/2">Group</th>
<th class="pb-2 w-1/4">Role</th>
<th class="pb-2 w-1/4"></th>
</tr>
</thead>
<tbody>
{#each autoAddInstanceGroups as groupName (groupName)}
{@const group = instanceGroups.find(g => g.name === groupName)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">
<div class="font-medium">{groupName}</div>
{#if group?.summary}
<div class="text-xs text-tertiary">{group.summary}</div>
{/if}
</td>
<td class="py-2">
<div>
<ToggleButtonGroup
selected={autoAddInstanceGroupsRoles[groupName] || 'developer'}
on:selected={async (e) => {
autoAddInstanceGroupsRoles[groupName] = e.detail
await updateGroupRole(groupName, e.detail)
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</td>
<td class="py-2">
<div class="flex justify-end">
<Button
color="light"
variant="contained"
btnClasses="text-red-500"
size="xs"
spacingSize="xs2"
onclick={() => {
removeInstanceGroupConfirmedCallback = async () => {
await removeInstanceGroup(groupName)
}
}}
>
Remove
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="text-center text-tertiary text-sm py-4">
No instance groups configured for auto-add
</div>
{/if}
</div>
</div>
{/snippet}
</Popover>
{/if}
<AddUser
on:new={() => {
listUsers()
@@ -313,6 +609,14 @@
<tr>
<Cell head first>Email</Cell>
<Cell head>Username</Cell>
{#if hasNonManualUsers}
<Cell head>
Added via
<Tooltip>
Shows how the user was added to the workspace: manually, via domain auto-invite, or through an instance group.
</Tooltip>
</Cell>
{/if}
<Cell head>
Executions (<abbr title="past 1w">1w</abbr>)
@@ -330,10 +634,34 @@
</Head>
<tbody class="divide-y bg-surface">
{#if filteredUsers}
{#each filteredUsers.slice(0, nbDisplayed) as { email, username, is_admin, operator, disabled } (email)}
{#each sortedUsers().slice(0, nbDisplayed) as { email, username, is_admin, operator, disabled, added_via }, index (email)}
<!-- Add separator between manual users and instance group users -->
{#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'}
<tr class="bg-surface-secondary">
<td colspan={hasNonManualUsers ? 8 : 7} class="px-4 py-2">
<div class="text-xs text-tertiary font-bold">
Instance group users
</div>
</td>
</tr>
{/if}
<tr class="!hover:bg-surface-hover">
<Cell first><a href="mailto:{email}">{truncate(email, 20)}</a></Cell>
<Cell>{truncate(username, 30)}</Cell>
{#if hasNonManualUsers}
<Cell>
<div class="flex items-center gap-2">
{#if added_via?.source === 'instance_group'}
<Badge color="blue">Group</Badge>
<span>{truncate(added_via.group || 'Unknown', 20)}</span>
{:else if added_via?.source === 'domain'}
<Badge color="blue">Auto-add</Badge>
{:else}
<Badge color="blue">Manual</Badge>
{/if}
</div>
</Cell>
{/if}
<Cell
>{#if usage?.[email] != undefined}{usage?.[email]}{:else}<Loader2
size={14}
@@ -351,55 +679,64 @@
</Cell>
<Cell>
<div>
<ToggleButtonGroup
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
on:selected={async (e) => {
if (is_admin && email == $userStore?.email && e.detail != 'admin') {
sendUserToast(
'Admins cannot be demoted by themselves, ask another admin to demote you',
true
)
e.preventDefault()
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
<span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold">
{is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'}
</span>
<Tooltip>Role is managed through instance group configuration above.</Tooltip>
</div>
{:else}
<ToggleButtonGroup
selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'}
on:selected={async (e) => {
if (is_admin && email == $userStore?.email && e.detail != 'admin') {
sendUserToast(
'Admins cannot be demoted by themselves, ask another admin to demote you',
true
)
e.preventDefault()
listUsers()
return
}
const body =
e.detail == 'admin'
? { is_admin: true, operator: false }
: e.detail == 'operator'
? { is_admin: false, operator: true }
: { is_admin: false, operator: false }
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: body
})
listUsers()
return
}
const body =
e.detail == 'admin'
? { is_admin: true, operator: false }
: e.detail == 'operator'
? { is_admin: false, operator: true }
: { is_admin: false, operator: false }
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: body
})
listUsers()
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
}}
>
{#snippet children({ item })}
<ToggleButton
value="operator"
label="Operator"
tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="developer"
label="Developer"
tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)."
{item}
/>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
<ToggleButton
value="admin"
label="Admin"
tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace."
{item}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
</div>
</Cell>
<Cell>
@@ -423,25 +760,41 @@
{disabled ? 'Enable' : 'Disable'}
</Button>
<Button
color="light"
variant="contained"
btnClasses="text-red-500"
size="xs"
spacingSize="xs2"
on:click={() => {
deleteConfirmedCallback = async () => {
await UserService.deleteUser({
workspace: $workspaceStore ?? '',
username
})
sendUserToast('User removed')
listUsers()
}
}}
>
Remove
</Button>
{#if added_via?.source === 'instance_group'}
<div class="flex items-center gap-1">
<Button
color="light"
variant="contained"
btnClasses="text-gray-400"
size="xs"
spacingSize="xs2"
disabled={true}
>
Remove
</Button>
<Tooltip>Cannot remove users synced from instance groups. Either disable the user or remove them from the SCIM group.</Tooltip>
</div>
{:else}
<Button
color="light"
variant="contained"
btnClasses="text-red-500"
size="xs"
spacingSize="xs2"
on:click={() => {
deleteConfirmedCallback = async () => {
await UserService.deleteUser({
workspace: $workspaceStore ?? '',
username
})
sendUserToast('User removed')
listUsers()
}
}}
>
Remove
</Button>
{/if}
</div>
</Cell>
</tr>
@@ -589,3 +942,24 @@
<span>Are you sure you want to remove ?</span>
</div>
</ConfirmationModal>
<div class="[&>div]:!z-[5002]">
<ConfirmationModal
open={Boolean(removeInstanceGroupConfirmedCallback)}
title="Remove instance group"
confirmationText="Remove"
on:canceled={() => {
removeInstanceGroupConfirmedCallback = undefined
}}
on:confirmed={() => {
if (removeInstanceGroupConfirmedCallback) {
removeInstanceGroupConfirmedCallback()
}
removeInstanceGroupConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this instance group from auto-add? This will not remove users already added from this group.</span>
</div>
</ConfirmationModal>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Group } from '$lib/gen'
import type { InstanceGroup } from '$lib/gen'
import type { InstanceGroupWithWorkspaces } from '$lib/gen'
import { GroupService } from '$lib/gen'
import CenteredPage from '$lib/components/CenteredPage.svelte'
@@ -25,7 +25,7 @@
let newGroupName: string = $state('')
let groups: GroupW[] | undefined = $state(undefined)
let instanceGroups: InstanceGroup[] | undefined = $state(undefined)
let instanceGroups: InstanceGroupWithWorkspaces[] | undefined = $state(undefined)
let groupDrawer: Drawer | undefined = $state()
async function loadGroups(): Promise<void> {
@@ -36,7 +36,7 @@
async function loadInstanceGroups(): Promise<void> {
try {
instanceGroups = await GroupService.listInstanceGroups()
instanceGroups = await GroupService.listInstanceGroupsWithWorkspaces()
} catch (e) {
instanceGroups = undefined
}
@@ -211,22 +211,37 @@
<tr slot="header-row">
<th>Name</th>
<th>Members</th>
<th>Workspaces</th>
</tr>
{#snippet body()}
<tbody>
{#each instanceGroups ?? [] as { name, emails }}
{#each instanceGroups ?? [] as { name, emails, workspaces }}
<tr>
<td>
<a
href="#{name}"
onclick={() => {
editGroupName = name
groupDrawer?.openDrawer()
if (name) {
editGroupName = name
groupDrawer?.openDrawer()
}
}}
>{name}
</a>
</td>
<td>{emails?.length ?? 0} members</td>
<td>
{#if workspaces && workspaces.length > 0}
{#each workspaces as workspace, index}
{#if index > 0}${", "}{/if}<a
href="/workspace_settings?tab=users&workspace={workspace.workspace_id}"
class="text-blue-500 hover:underline"
>{workspace.workspace_id}</a> ({workspace.role})
{/each}
{:else}
<span class="text-tertiary text-sm">No workspaces</span>
{/if}
</td>
</tr>
{/each}
</tbody>