feat: add operator mode

This commit is contained in:
Ruben Fiszel
2022-12-13 19:40:14 +01:00
parent d813c007c0
commit 3485b07b25
33 changed files with 1144 additions and 841 deletions
@@ -0,0 +1,3 @@
-- Add down migration script here
ALTER TABLE workspace_invite DROP COLUMN operator;
ALTER TABLE workspace_settings DROP COLUMN auto_invite_operator;
@@ -0,0 +1,4 @@
-- Add up migration script here
ALTER TABLE workspace_invite ADD COLUMN operator BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE workspace_settings ADD COLUMN auto_invite_operator BOOLEAN DEFAULT false;
ALTER TABLE completed_job ADD COLUMN email VARCHAR(50) NOT NULL DEFAULT 'missing@email.xyz';
+274 -182
View File
@@ -1,5 +1,19 @@
{
"db": "PostgreSQL",
"006f03e979abdf8055b1c598bc9806337216a6abf74db4eb64b0acb918a0de08": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Bool",
"Text",
"Text"
]
}
},
"query": "UPDATE usr SET disabled = $1 WHERE username = $2 AND workspace_id = $3"
},
"0355b53b1d45955ca56b2829372ce9c656d7f0ad7b8d0709161047f0d8cdc4f4": {
"describe": {
"columns": [
@@ -176,20 +190,6 @@
},
"query": "SELECT flow_status FROM queue WHERE id = $1 AND workspace_id = $2"
},
"0a76ed47629cac693ba7f169a1229b62bd900bc007a63fbae3fa7374ba66df65": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin)\n VALUES ($1, $2, $3)"
},
"0a9a191273c735c41d56ea46a39ffca075a0550eada87df7162c5037164ad6bf": {
"describe": {
"columns": [],
@@ -204,20 +204,20 @@
},
"query": "\n INSERT INTO capture\n (workspace_id, path, created_by)\n VALUES ($1, $2, $3)\n ON CONFLICT (workspace_id, path)\n DO UPDATE SET created_at = now()\n "
},
"0ae9160591ae00117d20a616cfe07e38f0c32953c7e881e916c389255190b72d": {
"0c5faa1e3233ca15240f80c9ba805f839d9f1f7450d1ea1f2b8e9faf8533892d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Text",
"Text",
"Bool",
"Bool"
]
}
},
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin)\n VALUES ($1, $2, $3, $4)"
"query": "DELETE FROM workspace_invite WHERE\n workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4"
},
"0d6412bc3ebb1d58bdd9cbcef774dacf9016fa402af5c1b4e339b9a3d7163d5e": {
"describe": {
@@ -404,6 +404,11 @@
"name": "auto_invite_domain",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "auto_invite_operator",
"ordinal": 6,
"type_info": "Bool"
}
],
"nullable": [
@@ -412,6 +417,7 @@
true,
true,
false,
true,
true
],
"parameters": {
@@ -460,9 +466,15 @@
"name": "is_admin",
"ordinal": 2,
"type_info": "Bool"
},
{
"name": "operator",
"ordinal": 3,
"type_info": "Bool"
}
],
"nullable": [
false,
false,
false,
false
@@ -841,33 +853,6 @@
},
"query": "SELECT flow_status, parent_job FROM completed_job WHERE id = $1 AND workspace_id = $2 UNION ALL SELECT flow_status, parent_job FROM queue WHERE id = $1 AND workspace_id = $2 "
},
"2bf44d998d7acd17ec6d98f81395f8bdac49f58880fbbb9350bf0142cd2efdc7": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
}
},
"query": "DELETE FROM workspace_invite WHERE\n workspace_id = $1 AND email = $2 AND is_admin = $3"
},
"2c6c2358fc24d92db179306a0f18a83d71bf15d8dc76502c451634dff35715a6": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin)\n VALUES ($1, $2, false)\n ON CONFLICT DO NOTHING"
},
"2e11e3ef361c41e6e055dd4805cb9d5e45eaa486e35fc5f01dae311189d6800f": {
"describe": {
"columns": [
@@ -933,6 +918,22 @@
},
"query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3"
},
"31a6e8a73fa568e28b8ca6e35a38b663e0b07d15a212b81883ad9c327d99a7c4": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Bool"
]
}
},
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator)\n VALUES ($1, $2, $3, $4, $5)"
},
"355dcb2cbebd13f0e3bdd4929b9e431b0e6d72716d1c4f9ab6af6adce5b5e4b3": {
"describe": {
"columns": [
@@ -1174,27 +1175,6 @@
},
"query": "UPDATE variable SET path = $1 WHERE path = $2 AND workspace_id = $3"
},
"4ad5fa2f08236507aad911a95697e84fc0c3a274ba0e928da28c4d146cf8f1a8": {
"describe": {
"columns": [
{
"name": "is_admin",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "SELECT is_admin FROM usr where username = $1 AND workspace_id = $2"
},
"4de3ba6930b91c639f9f3939dab7b3ab35aa792484e4ccb8084c336a2ec60f45": {
"describe": {
"columns": [],
@@ -1467,6 +1447,11 @@
"name": "auto_invite_domain",
"ordinal": 5,
"type_info": "Varchar"
},
{
"name": "auto_invite_operator",
"ordinal": 6,
"type_info": "Bool"
}
],
"nullable": [
@@ -1475,6 +1460,7 @@
true,
true,
false,
true,
true
],
"parameters": {
@@ -1656,6 +1642,18 @@
},
"query": "DELETE FROM group_ WHERE name = $1 AND workspace_id = $2"
},
"5c377fffc224a06f693c125f4c13b0a9ccfc217190ba6cf78246294bbc6c93bc": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "UPDATE workspace_settings SET auto_invite_domain = NULL, auto_invite_operator = NULL WHERE workspace_id = $1"
},
"5fee34ce48f01bb55707227548ea4d1e77ba75abfae412a902b1d8d6a1963725": {
"describe": {
"columns": [],
@@ -2079,6 +2077,72 @@
},
"query": "SELECT email, login_type::text, verified, super_admin, name, company from password LIMIT $1 OFFSET $2"
},
"77c3915246e37ae158d0c600e2402b93ce4fb1179d034daf231e00797e214207": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Uuid",
"Varchar",
"Timestamptz",
"Timestamptz",
"Bool",
"Int8",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text",
"Text",
"Bool",
"Varchar",
"Text",
{
"Custom": {
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies"
]
},
"name": "job_kind"
}
},
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Bool",
"Bool",
{
"Custom": {
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash"
]
},
"name": "script_lang"
}
},
"Numeric",
"Varchar"
]
}
},
"query": "INSERT INTO completed_job AS cj\n ( workspace_id\n , id\n , parent_job\n , created_by\n , created_at\n , started_at\n , duration_ms\n , success\n , script_hash\n , script_path\n , args\n , result\n , logs\n , raw_code\n , raw_lock\n , canceled\n , canceled_by\n , canceled_reason\n , job_kind\n , schedule_path\n , permissioned_as\n , flow_status\n , raw_flow\n , is_flow_step\n , is_skipped\n , language\n , email )\n VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, EXTRACT(milliseconds FROM (now() - $6))), $7, $8, $9,$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27)\n ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)"
},
"7a511ce8dbbf761423b527672ab02156aba5594623dc269992b34398673ca387": {
"describe": {
"columns": [
@@ -2111,19 +2175,6 @@
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin)\n VALUES ('demo', $1, false)\n ON CONFLICT DO NOTHING"
},
"7b0a6bf71751668eea56923f70e198c36386a82b8a8abbf4f55caeb0618478fe": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
}
},
"query": "UPDATE workspace_settings SET auto_invite_domain = $1 WHERE workspace_id = $2"
},
"7b1239ad6460e8f5fb41bfe12f662a779528784ec8cf3f6dcce5545ab90bf234": {
"describe": {
"columns": [
@@ -2182,6 +2233,20 @@
},
"query": "SELECT name FROM group_ WHERE workspace_id = $1 ORDER BY name desc"
},
"7fcffd77d0957cff4dff7aa822f7bcab9ec10563738e47ab02657baa9b29179d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n VALUES ($1, $2, false, $3)\n ON CONFLICT DO NOTHING"
},
"808ff76fdb74482d5d3201417c8a2470e2867cb08b9a46d244653eb366d8ee5e": {
"describe": {
"columns": [],
@@ -2705,70 +2770,26 @@
},
"query": "SELECT email, login_type::TEXT, super_admin, verified, name, company FROM password WHERE email = $1"
},
"90d477b39e9d2f439d378c21041af8bc7720cc21ae150a62ecaa6235142aea05": {
"924e1d5cbb682db0d6121dd911f38cf8948d0e59ca47428daa4f43fce686ba80": {
"describe": {
"columns": [],
"nullable": [],
"columns": [
{
"name": "is_admin",
"ordinal": 0,
"type_info": "Bool"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Uuid",
"Varchar",
"Timestamptz",
"Timestamptz",
"Bool",
"Int8",
"Varchar",
"Jsonb",
"Jsonb",
"Text",
"Text",
"Text",
"Bool",
"Varchar",
"Text",
{
"Custom": {
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies"
]
},
"name": "job_kind"
}
},
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Bool",
"Bool",
{
"Custom": {
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash"
]
},
"name": "script_lang"
}
},
"Numeric"
"Text"
]
}
},
"query": "INSERT INTO completed_job AS cj\n ( workspace_id\n , id\n , parent_job\n , created_by\n , created_at\n , started_at\n , duration_ms\n , success\n , script_hash\n , script_path\n , args\n , result\n , logs\n , raw_code\n , raw_lock\n , canceled\n , canceled_by\n , canceled_reason\n , job_kind\n , schedule_path\n , permissioned_as\n , flow_status\n , raw_flow\n , is_flow_step\n , is_skipped\n , language )\n VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, EXTRACT(milliseconds FROM (now() - $6))), $7, $8, $9,$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)\n ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)"
"query": "SELECT is_admin FROM usr where username = $1 AND workspace_id = $2 AND disabled = false"
},
"9251404312374679a4be2f9def508fcadce446405e91d95100c3397ec26e5882": {
"describe": {
@@ -2792,6 +2813,20 @@
},
"query": "INSERT INTO app_version\n (flow_id, value, created_by)\n VALUES ($1, $2, $3) RETURNING id"
},
"930ad84a4db26fa6d2c8447d447099e944a0ea7b1266b4d02cee620fe3d761a5": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Text"
]
}
},
"query": "UPDATE workspace_settings SET auto_invite_domain = $1, auto_invite_operator = $2 WHERE workspace_id = $3"
},
"9490a4388f43e45e32911f1129e623f2d73ce2da7a948fe134bea1c87cdbefd1": {
"describe": {
"columns": [
@@ -2949,6 +2984,32 @@
},
"query": "DELETE FROM token WHERE token = $1 RETURNING email"
},
"9db64c9ff790d8c833c1e831a87803c32103ce9de68cc9c08d6f56cc988d7e37": {
"describe": {
"columns": [
{
"name": "workspace_id",
"ordinal": 0,
"type_info": "Varchar"
},
{
"name": "auto_invite_operator",
"ordinal": 1,
"type_info": "Bool"
}
],
"nullable": [
false,
true
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT workspace_id, auto_invite_operator FROM workspace_settings WHERE auto_invite_domain = $1"
},
"9def921a0b697e4cbcb6ad88196ff6f66358f3b819aaf4e279596ccfc433fa92": {
"describe": {
"columns": [
@@ -3375,9 +3436,15 @@
"name": "is_admin",
"ordinal": 2,
"type_info": "Bool"
},
{
"name": "operator",
"ordinal": 3,
"type_info": "Bool"
}
],
"nullable": [
false,
false,
false,
false
@@ -3597,6 +3664,21 @@
},
"query": "SELECT null FROM queue WHERE id = $1 FOR UPDATE"
},
"be7a99a5bb6858323ca61dd51077010f51ba58ae76b9a413339255024dcb524d": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Bool",
"Bool"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n VALUES ($1, $2, $3, $4)"
},
"bf1d8e043338867e1da1ed236ff6c85a566d5fd58d4b0d5c3a10454513811ba3": {
"describe": {
"columns": [],
@@ -3641,6 +3723,20 @@
},
"query": "UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, edited_at = now(), schema = $6, dependency_job = NULL WHERE path = $7 AND workspace_id = $8"
},
"c1149f74ef0cca4a48be80f523d339d78e8e20e2bfacac9ad1b6b75c5ae370fb": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Bool",
"Text",
"Text"
]
}
},
"query": "UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3"
},
"c2849e67b9fea0dc46e6d7000f5a0c9dab89ae80a183d9255f8fdb356b4bc61c": {
"describe": {
"columns": [
@@ -3734,26 +3830,6 @@
},
"query": "\n INSERT INTO resume_job\n (id, resume_id, job, flow, value, approver)\n VALUES ($1, $2, $3, $4, $5, $6)\n "
},
"c5429cd91368fcdc4da65ef6d84b32b2726f5ea938bc53bf6ed63465bd0eb0f2": {
"describe": {
"columns": [
{
"name": "workspace_id",
"ordinal": 0,
"type_info": "Varchar"
}
],
"nullable": [
false
],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "SELECT workspace_id FROM workspace_settings WHERE auto_invite_domain = $1"
},
"c59dd666b9a316c027e8c319b80ccbab3a220d93b64357981bd4a03324dad1d0": {
"describe": {
"columns": [
@@ -3883,18 +3959,6 @@
},
"query": "UPDATE queue SET running = false WHERE last_ping < now() - ($1 || ' seconds')::interval AND running = true AND job_kind != $2 AND same_worker = false RETURNING id, workspace_id, last_ping"
},
"ccb05bf4c0a0f297f75498a684673ae7e4f6ed47f7a8f5d7154e0e0350865d41": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text"
]
}
},
"query": "UPDATE workspace_settings SET auto_invite_domain = NULL WHERE workspace_id = $1"
},
"d0308abac80575038203b60bb66d3b39b586939da0421a595e47c7a759616431": {
"describe": {
"columns": [],
@@ -3978,6 +4042,20 @@
},
"query": "SELECT app.id, app.path, app.summary, app.versions, app.policy,\n app.extra_perms, app_version.value, \n app_version.created_at, app_version.created_by from app, app_version \n WHERE app.path = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]"
},
"d4bf68b4c2bfd11db84787e41e92c13612fda7352e841f3cdef45ea762588f04": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator)\n SELECT $1::text, email, false, $3 FROM password WHERE email LIKE CONCAT('%', $2::text) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING"
},
"d4eb7aea60894b65498144b9bf522beba612f36368d62fe4e94b5b9e26349d32": {
"describe": {
"columns": [
@@ -4425,6 +4503,33 @@
},
"query": "INSERT INTO workspace_settings\n (workspace_id, slack_team_id, slack_name, slack_email)\n VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id) DO UPDATE SET slack_team_id = $2, slack_name = $3, slack_email = $4"
},
"ec85a425f88044c6ed4f8fcea223c28eb9fb8c16c89a52d4c4552bd149badafa": {
"describe": {
"columns": [
{
"name": "is_admin",
"ordinal": 0,
"type_info": "Bool"
},
{
"name": "operator",
"ordinal": 1,
"type_info": "Bool"
}
],
"nullable": [
false,
false
],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator"
},
"ed400dbf4a1fa597bc984240cfe9069369a8a265b127116187d78c0ba93ae076": {
"describe": {
"columns": [
@@ -4823,18 +4928,5 @@
}
},
"query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3"
},
"fceeeaa8d3c97273c8e77b3badd9e7cf82deead59c0d0619e0c303ea665f7d72": {
"describe": {
"columns": [],
"nullable": [],
"parameters": {
"Left": [
"Text",
"Text"
]
}
},
"query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin)\n SELECT $1::text, email, false FROM password WHERE email LIKE CONCAT('%', $2::text) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING"
}
}
+6 -1
View File
@@ -1 +1,6 @@
gmail.com
gmail.com
hotmail.com
live.com
yahoo.com
protonmail.com
yandex.com
+21 -9
View File
@@ -638,9 +638,12 @@ paths:
type: string
is_admin:
type: boolean
operator:
type: boolean
required:
- email
- is_admin
- operator
responses:
"200":
description: status
@@ -669,9 +672,12 @@ paths:
type: string
is_admin:
type: boolean
operator:
type: boolean
required:
- email
- is_admin
- operator
responses:
"200":
description: status
@@ -781,6 +787,8 @@ paths:
type: string
auto_invite_domain:
type: string
auto_invite_operator:
type: boolean
/w/{workspace}/workspaces/edit_slack_command:
post:
@@ -825,10 +833,8 @@ paths:
schema:
type: object
properties:
set:
operator:
type: boolean
required:
- set
responses:
"200":
@@ -4183,6 +4189,8 @@ components:
enum: [python3, deno, go, bash]
is_skipped:
type: boolean
email:
type: string
required:
- id
- created_by
@@ -4195,6 +4203,7 @@ components:
- permissioned_as
- is_flow_step
- is_skipped
- email
Job:
allOf:
@@ -4245,12 +4254,8 @@ components:
Usage:
type: object
properties:
duration_ms:
type: integer
jobs:
type: integer
flows:
type: integer
executions:
type: number
Login:
type: object
@@ -4282,6 +4287,10 @@ components:
properties:
is_admin:
type: boolean
operator:
type: boolean
disabled:
type: boolean
TruncatedToken:
type: object
@@ -4858,10 +4867,13 @@ components:
type: string
is_admin:
type: boolean
operator:
type: boolean
required:
- workspace_id
- email
- is_admin
- operator
GlobalUserInfo:
type: object
+7 -1
View File
@@ -238,6 +238,7 @@ pub struct CompletedJob {
pub is_flow_step: bool,
pub language: Option<ScriptLang>,
pub is_skipped: bool,
pub email: String,
}
#[derive(Deserialize, Clone)]
@@ -400,6 +401,7 @@ async fn list_jobs(
"is_flow_step",
"language",
"false as is_skipped",
"email",
],
);
let sqlc = list_completed_jobs_query(
@@ -432,6 +434,7 @@ async fn list_jobs(
"is_flow_step",
"language",
"is_skipped",
"email",
],
);
let sql = format!(
@@ -807,6 +810,7 @@ struct UnifiedJob {
is_flow_step: bool,
language: Option<ScriptLang>,
is_skipped: bool,
email: String,
}
impl From<UnifiedJob> for Job {
@@ -839,6 +843,7 @@ impl From<UnifiedJob> for Job {
is_flow_step: uj.is_flow_step,
language: uj.language,
is_skipped: uj.is_skipped,
email: uj.email,
}),
"QueuedJob" => Job::QueuedJob(QueuedJob {
workspace_id: uj.workspace_id,
@@ -868,7 +873,7 @@ impl From<UnifiedJob> for Job {
language: uj.language,
same_worker: false,
pre_run_error: None,
email: "".to_string(),
email: uj.email,
}),
t => panic!("job type {} not valid", t),
}
@@ -1392,6 +1397,7 @@ async fn list_completed_jobs(
"is_flow_step",
"language",
"is_skipped",
"email",
],
)
.sql()?;
+66 -35
View File
@@ -123,7 +123,7 @@ impl AuthCache {
let is_admin = super_admin
|| sqlx::query_scalar!(
"SELECT is_admin FROM usr where username = $1 AND \
workspace_id = $2",
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
@@ -162,7 +162,7 @@ impl AuthCache {
if w_id.is_some() {
let row_o = sqlx::query_as::<_, (String, bool)>(
"SELECT username, is_admin FROM usr where email = $1 AND \
workspace_id = $2",
workspace_id = $2 AND disabled = false",
)
.bind(&email)
.bind(&w_id.as_ref().unwrap())
@@ -368,9 +368,7 @@ pub struct User {
#[derive(FromRow, Serialize)]
pub struct Usage {
pub duration_ms: i64,
pub jobs: i64,
pub flows: i64,
pub executions: i64,
}
#[derive(Serialize)]
@@ -409,6 +407,7 @@ pub struct WorkspaceInvite {
pub workspace_id: String,
pub email: String,
pub is_admin: bool,
pub operator: bool,
}
#[derive(FromRow, Serialize)]
@@ -465,7 +464,8 @@ pub struct EditUser {
#[derive(Deserialize)]
pub struct EditWorkspaceUser {
pub is_admin: Option<bool>,
pub enabled: Option<bool>,
pub operator: Option<bool>,
pub disabled: Option<bool>,
}
#[derive(Deserialize)]
@@ -542,14 +542,12 @@ async fn list_users(
SELECT usr.*, usage.*
FROM usr
, LATERAL (
SELECT COALESCE(SUM(duration_ms), 0) duration_ms
, COALESCE(SUM(job_kind IN ('flow', 'flowpreview') ::int), 0) flows
, COALESCE(SUM(job_kind NOT IN ('flow', 'flowpreview') ::int), 0) jobs
SELECT COALESCE(SUM(duration_ms + 1000)/1000 , 0) executions
FROM completed_job
WHERE workspace_id = usr.workspace_id
AND created_by = usr.username
AND parent_job IS NULL
AND now() - '2 week'::interval < created_at
AND job_kind NOT IN ('flow', 'flowpreview')
AND email = usr.email
AND now() - '5 week'::interval < created_at
) usage
WHERE workspace_id = $1
",
@@ -863,31 +861,40 @@ async fn accept_invite(
}
let mut tx = db.begin().await?;
let is_admin = sqlx::query_scalar!(
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin",
let r = sqlx::query!(
"DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator",
nu.workspace_id,
email,
)
.fetch_optional(&mut tx)
.await?;
if let Some(is_admin) = is_admin {
tx = add_user_to_workspace(&nu.workspace_id, &email, &nu.username, is_admin, tx).await?;
let is_some = r.is_some();
if let Some(r) = r {
tx = add_user_to_workspace(
&nu.workspace_id,
&email,
&nu.username,
r.is_admin,
r.operator,
tx,
)
.await?;
audit_log(
&mut tx,
&nu.username,
"users.accept_invite",
ActionKind::Create,
&nu.workspace_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
}
audit_log(
&mut tx,
&nu.username,
"users.accept_invite",
ActionKind::Create,
&nu.workspace_id,
Some(&email),
None,
)
.await?;
tx.commit().await?;
if is_admin.is_some() {
if is_some {
Ok((
StatusCode::CREATED,
format!(
@@ -905,6 +912,7 @@ async fn add_user_to_workspace<'c>(
email: &str,
username: &str,
is_admin: bool,
operator: bool,
mut tx: sqlx::Transaction<'c, sqlx::Postgres>,
) -> error::Result<sqlx::Transaction<'c, sqlx::Postgres>> {
let already_exists_username = sqlx::query_scalar!(
@@ -941,12 +949,13 @@ async fn add_user_to_workspace<'c>(
sqlx::query!(
"INSERT INTO usr
(workspace_id, email, username, is_admin)
VALUES ($1, $2, $3, $4)",
(workspace_id, email, username, is_admin, operator)
VALUES ($1, $2, $3, $4, $5)",
&w_id,
email,
username,
is_admin
is_admin,
operator
)
.execute(&mut tx)
.await?;
@@ -993,6 +1002,28 @@ async fn update_workspace_user(
.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,
&username,
@@ -1118,15 +1149,15 @@ async fn create_user(
audit_log(
&mut tx,
&email,
"users.update",
ActionKind::Update,
"users.add_global",
ActionKind::Create,
"global",
Some(&nu.email),
None,
)
.await?;
tx.commit().await?;
invite_user_to_all_auto_invite_worspaces(&db, &email).await?;
invite_user_to_all_auto_invite_worspaces(&db, &nu.email).await?;
Ok((StatusCode::CREATED, format!("email {} created", nu.email)))
}
+27 -20
View File
@@ -77,6 +77,7 @@ pub struct WorkspaceSettings {
pub slack_command_script: Option<String>,
pub slack_email: String,
pub auto_invite_domain: Option<String>,
pub auto_invite_operator: Option<bool>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -101,7 +102,7 @@ struct EditCommandScript {
#[derive(Deserialize)]
struct EditAutoInvite {
set: bool,
operator: Option<bool>,
}
#[derive(Deserialize)]
@@ -145,6 +146,7 @@ struct ValidateUsername {
pub struct NewWorkspaceInvite {
pub email: String,
pub is_admin: bool,
pub operator: bool,
}
async fn list_pending_invites(
@@ -277,7 +279,7 @@ async fn edit_auto_invite(
let mut tx = db.begin().await?;
if ea.set {
if let Some(operator) = ea.operator {
if BANNED_DOMAINS.contains(domain) {
return Err(Error::BadRequest(format!(
"Domain {} is not allowed",
@@ -286,8 +288,9 @@ async fn edit_auto_invite(
}
sqlx::query!(
"UPDATE workspace_settings SET auto_invite_domain = $1 WHERE workspace_id = $2",
"UPDATE workspace_settings SET auto_invite_domain = $1, auto_invite_operator = $2 WHERE workspace_id = $3",
domain,
operator,
&w_id
)
.execute(&mut tx)
@@ -295,20 +298,21 @@ async fn edit_auto_invite(
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin)
SELECT $1::text, email, false FROM password WHERE email LIKE CONCAT('%', $2::text) AND NOT EXISTS (
(workspace_id, email, is_admin, operator)
SELECT $1::text, email, false, $3 FROM password WHERE email LIKE CONCAT('%', $2::text) AND NOT EXISTS (
SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email
)
ON CONFLICT DO NOTHING",
&w_id,
&domain
&domain,
operator
)
.execute(&mut tx)
.await?;
} else {
sqlx::query!(
"UPDATE workspace_settings SET auto_invite_domain = NULL WHERE workspace_id = $1",
&w_id
"UPDATE workspace_settings SET auto_invite_domain = NULL, auto_invite_operator = NULL WHERE workspace_id = $1",
&w_id,
)
.execute(&mut tx)
.await?;
@@ -320,7 +324,7 @@ async fn edit_auto_invite(
ActionKind::Update,
&w_id,
Some(&authed.email),
Some([("set", &ea.set.to_string()[..])].into()),
Some([("operator", &format!("{:?}", ea.operator)[..])].into()),
)
.await?;
tx.commit().await?;
@@ -533,20 +537,21 @@ async fn delete_workspace(
pub async fn invite_user_to_all_auto_invite_worspaces(db: &DB, email: &str) -> Result<()> {
let mut tx = db.begin().await?;
let domain = email.split('@').last().unwrap();
let workspaces = sqlx::query_scalar!(
"SELECT workspace_id FROM workspace_settings WHERE auto_invite_domain = $1",
let workspaces = sqlx::query!(
"SELECT workspace_id, auto_invite_operator FROM workspace_settings WHERE auto_invite_domain = $1",
domain
)
.fetch_all(&mut tx)
.await?;
for w in workspaces {
for r in workspaces {
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin)
VALUES ($1, $2, false)
(workspace_id, email, is_admin, operator)
VALUES ($1, $2, false, $3)
ON CONFLICT DO NOTHING",
w,
r.workspace_id,
email,
r.auto_invite_operator
)
.execute(&mut tx)
.await?;
@@ -567,11 +572,12 @@ async fn invite_user(
sqlx::query!(
"INSERT INTO workspace_invite
(workspace_id, email, is_admin)
VALUES ($1, $2, $3)",
(workspace_id, email, is_admin, operator)
VALUES ($1, $2, $3, $4)",
&w_id,
nu.email,
nu.is_admin
nu.is_admin,
nu.operator
)
.execute(&mut tx)
.await?;
@@ -596,10 +602,11 @@ async fn delete_invite(
sqlx::query!(
"DELETE FROM workspace_invite WHERE
workspace_id = $1 AND email = $2 AND is_admin = $3",
workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4",
&w_id,
nu.email,
nu.is_admin
nu.is_admin,
nu.operator
)
.execute(&mut tx)
.await?;
+5 -3
View File
@@ -126,9 +126,10 @@ pub async fn add_completed_job(
, raw_flow
, is_flow_step
, is_skipped
, language )
, language
, email )
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, EXTRACT(milliseconds FROM (now() - $6))), $7, $8, $9,\
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27)
ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)",
queued_job.workspace_id,
queued_job.id,
@@ -155,7 +156,8 @@ pub async fn add_completed_job(
queued_job.is_flow_step,
skipped,
queued_job.language: ScriptLang,
duration: Option<i64>
duration: Option<i64>,
queued_job.email
)
.execute(&mut tx)
.await
+74 -70
View File
@@ -2,7 +2,7 @@
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { FlowService, ScheduleService, type Flow } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import {
encodeState,
formatCron,
@@ -186,80 +186,84 @@
let flowViewer: Drawer
</script>
<UnsavedConfirmationModal />
{#if !$userStore?.operator}
<UnsavedConfirmationModal />
<Drawer bind:this={flowViewer} size="75%">
<DrawerContent title="View Graph" on:close={flowViewer.closeDrawer} noPadding>
<div class="overflow-hidden h-full w-full">
<FlowGraphViewer flow={$flowStore} />
</div>
</DrawerContent>
</Drawer>
<Drawer bind:this={flowViewer} size="75%">
<DrawerContent title="View Graph" on:close={flowViewer.closeDrawer} noPadding>
<div class="overflow-hidden h-full w-full">
<FlowGraphViewer flow={$flowStore} />
</div>
</DrawerContent>
</Drawer>
<div class="flex flex-col flex-1 h-screen">
<!-- Nav between steps-->
<div
class="justify-between flex flex-row w-full items-center pl-2.5 pr-6 space-x-4 overflow-x-auto scrollbar-hidden max-h-12 h-full"
>
<div class="flex flex-row">
<FlowImportExportMenu />
<Button
btnClasses="inline-flex"
startIcon={{ icon: faEye }}
variant="border"
color="light"
size="sm"
on:click={flowViewer.openDrawer}
>
View Graph
</Button>
</div>
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
select('settings')
document.getElementById('path')?.focus()
}}
>
{$flowStore.path && $flowStore.path != '' ? $flowStore.path : 'Choose a path'}
</Button>
<Button
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
select('settings')
document.getElementById('flow-summary')?.focus()
}}
>
<div class="max-w-[10em] !truncate">
{$flowStore.summary == '' || !$flowStore.summary ? 'No summary' : $flowStore.summary}
</div>
</Button>
</div>
<div class="flex flex-row space-x-2">
<FlowPreviewButtons />
<div class="center-center">
<div class="flex flex-col flex-1 h-screen">
<!-- Nav between steps-->
<div
class="justify-between flex flex-row w-full items-center pl-2.5 pr-6 space-x-4 overflow-x-auto scrollbar-hidden max-h-12 h-full"
>
<div class="flex flex-row">
<FlowImportExportMenu />
<Button
disabled={pathError != ''}
startIcon={{ icon: faSave }}
btnClasses="inline-flex"
startIcon={{ icon: faEye }}
variant="border"
color="light"
size="sm"
on:click={saveFlow}>Save</Button
on:click={flowViewer.openDrawer}
>
View Graph
</Button>
</div>
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
select('settings')
document.getElementById('path')?.focus()
}}
>
{$flowStore.path && $flowStore.path != '' ? $flowStore.path : 'Choose a path'}
</Button>
<Button
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
select('settings')
document.getElementById('flow-summary')?.focus()
}}
>
<div class="max-w-[10em] !truncate">
{$flowStore.summary == '' || !$flowStore.summary ? 'No summary' : $flowStore.summary}
</div>
</Button>
</div>
<div class="flex flex-row space-x-2">
<FlowPreviewButtons />
<div class="center-center">
<Button
disabled={pathError != ''}
startIcon={{ icon: faSave }}
size="sm"
on:click={saveFlow}>Save</Button
>
</div>
</div>
</div>
</div>
<!-- metadata -->
{#if $flowStateStore}
<FlowEditor {initialPath} {loading} />
{:else}
<CenteredPage>Loading...</CenteredPage>
{/if}
</div>
<!-- metadata -->
{#if $flowStateStore}
<FlowEditor {initialPath} {loading} />
{:else}
<CenteredPage>Loading...</CenteredPage>
{/if}
</div>
{:else}
Flow Builder not available to operators
{/if}
@@ -31,7 +31,7 @@
company
}
})
sendUserToast(`Successfully added ${email}. Welcome to them!`)
sendUserToast(`Added ${email}`)
dispatch('new')
}
</script>
@@ -39,7 +39,7 @@
<div class="flex flex-row space-x-1">
<input type="email" on:keyup={handleKeyUp} placeholder="email" bind:value={email} />
<Toggle class="mx-2" bind:checked={is_super_admin} options={{ right: 'admin' }} />
<Toggle class="mx-2" bind:checked={is_super_admin} options={{ right: 'superadmin' }} />
<input on:keyup={handleKeyUp} type="password" placeholder="password" bind:value={password} />
<input type="text" on:keyup={handleKeyUp} placeholder="name" bind:value={name} />
<input type="text" on:keyup={handleKeyUp} placeholder="company" bind:value={company} />
+21 -6
View File
@@ -3,8 +3,8 @@
import { createEventDispatcher } from 'svelte'
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { Button } from './common'
import Toggle from '$lib/components/Toggle.svelte'
import { Button, ToggleButton, ToggleButtonGroup } from './common'
import Tooltip from './Tooltip.svelte'
const dispatch = createEventDispatcher()
@@ -24,18 +24,33 @@
workspace: $workspaceStore!,
requestBody: {
email,
is_admin
is_admin: selected == 'admin',
operator: selected == 'operator'
}
})
sendUserToast(`Successfully invited ${email}. Welcome to them!`)
sendUserToast(`Invited ${email}`)
dispatch('new')
}
let selected: 'operator' | 'author' | 'admin' = 'author'
</script>
<div class="flex flex-row">
<input type="email" on:keyup={handleKeyUp} placeholder="email" bind:value={email} class="mr-4" />
<Toggle bind:checked={is_admin} options={{ right: 'admin' }} />
<ToggleButtonGroup bind:selected>
<ToggleButton position="left" value="operator" size="sm"
>Operator <Tooltip
>An operator can only execute and view scripts/flows/apps from your workspace, and only
those that he has visibility on</Tooltip
></ToggleButton
>
<ToggleButton position="center" value="author" size="sm"
>Author <Tooltip
>An Author can execute and view scripts/flows/apps, but he can also create new ones</Tooltip
></ToggleButton
>
<ToggleButton position="right" value="admin" size="sm">Admin</ToggleButton>
</ToggleButtonGroup>
<Button
variant="contained"
color="blue"
@@ -58,7 +58,7 @@
path: resourceToEdit.path,
requestBody: { path, value: args, description }
})
sendUserToast(`Successfully updated resource at ${path}`)
sendUserToast(`Updated resource at ${path}`)
dispatch('refresh')
drawer.closeDrawer?.()
} else {
+212 -208
View File
@@ -5,7 +5,7 @@
import { page } from '$app/stores'
import { inferArgs } from '$lib/infer'
import { initialCode, isInitialCode } from '$lib/script_helpers'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { encodeState, sendUserToast, setQueryWithoutLoad } from '$lib/utils'
import Path from './Path.svelte'
import RadioButton from './RadioButton.svelte'
@@ -24,7 +24,7 @@
export let script: Script
export let initialPath: string = ''
export let template: 'pgsql'| 'mysql' | 'script' = 'script'
export let template: 'pgsql' | 'mysql' | 'script' = 'script'
export let initialArgs: Record<string, any> = {}
const langs: [string, SupportedLanguage][] = [
@@ -91,228 +91,232 @@
$: kind = script.kind as 'script' | 'trigger' | 'approval' | undefined
</script>
<UnsavedConfirmationModal />
<div class="flex flex-col h-screen">
<!-- Nav between steps-->
<div class="flex flex-col w-full px-2 py-1 border-b shadow-sm">
<div
class="justify-between flex flex-row w-full items-center overflow-x-auto scrollbar-hidden px-2"
>
<div class="flex flex-row py-1">
<Breadcrumb
items={['Metadata', 'Code', 'Advanced']}
selectedIndex={step}
on:select={(e) => changeStep(e.detail.index + 1)}
disabled={pathError != ''}
>
<svelte:fragment slot="separator">/</svelte:fragment>
</Breadcrumb>
</div>
{#if !$userStore?.operator}
<UnsavedConfirmationModal />
<div class="flex flex-col h-screen">
<!-- Nav between steps-->
<div class="flex flex-col w-full px-2 py-1 border-b shadow-sm">
<div
class="justify-between flex flex-row w-full items-center overflow-x-auto scrollbar-hidden px-2"
>
<div class="flex flex-row py-1">
<Breadcrumb
items={['Metadata', 'Code', 'Advanced']}
selectedIndex={step}
on:select={(e) => changeStep(e.detail.index + 1)}
disabled={pathError != ''}
>
<svelte:fragment slot="separator">/</svelte:fragment>
</Breadcrumb>
</div>
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
await changeStep(1)
setTimeout(() => pathC?.focus(), 100)
}}
>
{script.path}
</Button>
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
await changeStep(1)
setTimeout(() => pathC?.focus(), 100)
}}
>
{script.path}
</Button>
<Button
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
await changeStep(1)
setTimeout(() => summaryC?.focus(), 100)
}}
>
<div class="max-w-[10em] !truncate">
{script.summary == '' || !script.summary ? 'No summary' : script.summary}
</div>
</Button>
</div>
<div class="flex flex-row gap-x-2">
<Button
variant="border"
size="sm"
btnClasses={step == 1 ? 'hidden sm:invisible' : ''}
on:click={() => changeStep(step - 1)}
>
Back
</Button>
<Button
size="sm"
variant={step == 1 ? 'contained' : 'border'}
btnClasses={step == 3 ? 'invisible' : ''}
disabled={step === 1 && pathError !== ''}
on:click={() => changeStep(step + 1)}
>
Next
</Button>
<Button
size="sm"
variant={step == 1 ? 'border' : 'contained'}
disabled={step === 1 && pathError !== ''}
btnClasses={step == 1 && initialPath == '' ? 'invisible' : ''}
startIcon={{ icon: faSave }}
on:click={editScript}>Save</Button
>
<Button
startIcon={{ icon: faPen }}
variant="contained"
color="light"
size="xs"
on:click={async () => {
await changeStep(1)
setTimeout(() => summaryC?.focus(), 100)
}}
>
<div class="max-w-[10em] !truncate">
{script.summary == '' || !script.summary ? 'No summary' : script.summary}
</div>
</Button>
</div>
<div class="flex flex-row gap-x-2">
<Button
variant="border"
size="sm"
btnClasses={step == 1 ? 'hidden sm:invisible' : ''}
on:click={() => changeStep(step - 1)}
>
Back
</Button>
<Button
size="sm"
variant={step == 1 ? 'contained' : 'border'}
btnClasses={step == 3 ? 'invisible' : ''}
disabled={step === 1 && pathError !== ''}
on:click={() => changeStep(step + 1)}
>
Next
</Button>
<Button
size="sm"
variant={step == 1 ? 'border' : 'contained'}
disabled={step === 1 && pathError !== ''}
btnClasses={step == 1 && initialPath == '' ? 'invisible' : ''}
startIcon={{ icon: faSave }}
on:click={editScript}>Save</Button
>
</div>
</div>
</div>
</div>
<!-- metadata -->
{#if step === 1}
<CenteredPage>
<div class="space-y-6">
<h2 class="border-b pb-1 mt-4">Path & Summary</h2>
<Path
bind:this={pathC}
bind:error={pathError}
bind:path={script.path}
{initialPath}
on:enter={() => changeStep(2)}
namePlaceholder="my_script"
kind="script"
/>
<label class="block ">
<span class="text-gray-700 text-sm">Summary <Required required={false} /></span>
<input
type="text"
bind:this={summaryC}
bind:value={script.summary}
placeholder="A very short summary of the script displayed when the script is listed"
rows="1"
<!-- metadata -->
{#if step === 1}
<CenteredPage>
<div class="space-y-6">
<h2 class="border-b pb-1 mt-4">Path & Summary</h2>
<Path
bind:this={pathC}
bind:error={pathError}
bind:path={script.path}
{initialPath}
on:enter={() => changeStep(2)}
namePlaceholder="my_script"
kind="script"
/>
</label>
<h2 class="border-b pb-1 mt-4">Language</h2>
<div class="flex flex-row gap-2 flex-wrap">
{#each langs as [label, lang]}
{@const isPicked = script.language == lang && template == 'script'}
<label class="block ">
<span class="text-gray-700 text-sm">Summary <Required required={false} /></span>
<input
type="text"
bind:this={summaryC}
bind:value={script.summary}
placeholder="A very short summary of the script displayed when the script is listed"
rows="1"
/>
</label>
<h2 class="border-b pb-1 mt-4">Language</h2>
<div class="flex flex-row gap-2 flex-wrap">
{#each langs as [label, lang]}
{@const isPicked = script.language == lang && template == 'script'}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'dark'}
btnClasses={isPicked ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
script.language = lang
template = 'script'
initContent(lang, script.kind, template)
}}
>
<LanguageIcon {lang} /><span class="ml-2">{label}</span>
</Button>
{/each}
<Button
size="sm"
variant="border"
color={isPicked ? 'blue' : 'dark'}
btnClasses={isPicked ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
color={template == 'pgsql' ? 'blue' : 'dark'}
btnClasses={template == 'pgsql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
script.language = lang
template = 'script'
initContent(lang, script.kind, template)
script.language = Script.language.DENO
template = 'pgsql'
initContent(script.language, script.kind, template)
}}
>
<LanguageIcon {lang} /><span class="ml-2">{label}</span>
<LanguageIcon lang="pgsql" /><span class="ml-2">PostgreSQL</span>
</Button>
{/each}
<Button
size="sm"
variant="border"
color={template == 'pgsql' ? 'blue' : 'dark'}
btnClasses={template == 'pgsql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
script.language = Script.language.DENO
template = 'pgsql'
initContent(script.language, script.kind, template)
}}
>
<LanguageIcon lang="pgsql" /><span class="ml-2">PostgreSQL</span>
</Button>
<Button
size="sm"
variant="border"
color={template == 'mysql' ? 'blue' : 'dark'}
btnClasses={template == 'mysql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
script.language = Script.language.DENO
template = 'mysql'
initContent(script.language, script.kind, template)
}}
>
<LanguageIcon lang="mysql" /><span class="ml-2">MySQL</span>
</Button>
</div>
<h2 class="border-b pb-1 mt-4">Advanced</h2>
<div>
<Button
color="light"
size="sm"
endIcon={{ icon: viewScriptKind ? faChevronUp : faChevronDown }}
on:click={() => (viewScriptKind = !viewScriptKind)}
>
Tag this script as having a specific purpose inside flows
</Button>
</div>
{#if viewScriptKind}
<div class="max-w-lg">
<RadioButton
label="Script Type"
options={[
['Common Script', Script.kind.SCRIPT],
[
{
title: 'Trigger Script',
desc: `First module of flows to trigger them based on watching changes external periodically using an internal state`
},
Script.kind.TRIGGER
],
[
{
title: 'Error Handler',
desc: `Handle errors for flows after all retries attempts have been exhausted`
},
Script.kind.FAILURE
],
[
{
title: 'Approval Script',
desc: `Send notification externally to ask for approval to continue a flow`
},
Script.kind.APPROVAL
]
// ['Command Handler', Script.kind.COMMAND]
]}
on:change={(e) => {
if (isInitialCode(script.content)) {
template = 'script'
initContent(script.language, e.detail, template)
}
<Button
size="sm"
variant="border"
color={template == 'mysql' ? 'blue' : 'dark'}
btnClasses={template == 'mysql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
on:click={() => {
script.language = Script.language.DENO
template = 'mysql'
initContent(script.language, script.kind, template)
}}
bind:value={script.kind}
>
<LanguageIcon lang="mysql" /><span class="ml-2">MySQL</span>
</Button>
</div>
<h2 class="border-b pb-1 mt-4">Advanced</h2>
<div>
<Button
color="light"
size="sm"
endIcon={{ icon: viewScriptKind ? faChevronUp : faChevronDown }}
on:click={() => (viewScriptKind = !viewScriptKind)}
>
Tag this script as having a specific purpose inside flows
</Button>
</div>
{#if viewScriptKind}
<div class="max-w-lg">
<RadioButton
label="Script Type"
options={[
['Common Script', Script.kind.SCRIPT],
[
{
title: 'Trigger Script',
desc: `First module of flows to trigger them based on watching changes external periodically using an internal state`
},
Script.kind.TRIGGER
],
[
{
title: 'Error Handler',
desc: `Handle errors for flows after all retries attempts have been exhausted`
},
Script.kind.FAILURE
],
[
{
title: 'Approval Script',
desc: `Send notification externally to ask for approval to continue a flow`
},
Script.kind.APPROVAL
]
// ['Command Handler', Script.kind.COMMAND]
]}
on:change={(e) => {
if (isInitialCode(script.content)) {
template = 'script'
initContent(script.language, e.detail, template)
}
}}
bind:value={script.kind}
/>
</div>
{/if}
<div class="ml-3">
<Toggle
bind:checked={script.is_template}
options={{ right: 'Save as a workspace template' }}
/>
</div>
{/if}
<div class="ml-3">
<Toggle
bind:checked={script.is_template}
options={{ right: 'Save as a workspace template' }}
/>
</div>
</div>
</CenteredPage>
{:else if step === 2}
<ScriptEditor
bind:schema={script.schema}
path={script.path}
bind:code={script.content}
lang={script.language}
{initialArgs}
{kind}
/>
{:else if step === 3}
<CenteredPage>
<ScriptSchema
bind:summary={script.summary}
bind:description={script.description}
</CenteredPage>
{:else if step === 2}
<ScriptEditor
bind:schema={script.schema}
path={script.path}
bind:code={script.content}
lang={script.language}
{initialArgs}
{kind}
/>
</CenteredPage>
{/if}
</div>
{:else if step === 3}
<CenteredPage>
<ScriptSchema
bind:summary={script.summary}
bind:description={script.description}
bind:schema={script.schema}
/>
</CenteredPage>
{/if}
</div>
{:else}
Script Builder not available to operators
{/if}
@@ -26,9 +26,6 @@
let users: GlobalUserInfo[] = []
let filteredUsers: GlobalUserInfo[] = []
let deleteConfirmedCallback: (() => void) | undefined = undefined
$: openConfirmation = Boolean(deleteConfirmedCallback)
async function loadVersion(): Promise<void> {
version = await SettingsService.backendVersion()
}
@@ -47,39 +44,13 @@
<Drawer bind:this={drawer} on:open={listUsers} size="900px">
<DrawerContent overflow_y={false} title="Superadmin Settings" on:close={drawer.closeDrawer}>
<ConfirmationModal
open={openConfirmation}
title="Remove user"
confirmationText="Remove"
on:canceled={() => {
deleteConfirmedCallback = undefined
}}
on:confirmed={() => {
if (deleteConfirmedCallback) {
deleteConfirmedCallback()
}
deleteConfirmedCallback = undefined
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to remove this user?</span>
<Alert type="info" title="Bypass confirmation">
<div>
You can press
<Badge color="dark-gray">SHIFT</Badge>
while removing a variable to bypass confirmation.
</div>
</Alert>
</div>
</ConfirmationModal>
<div class="flex flex-col h-full">
<div>
<div class="text-xs pt-1 text-gray-500 ">
Windmill {version}
</div>
<PageHeader title="All users" primary={false} />
<PageHeader title="All global users" primary={false} />
<div class="pb-1" />
<InviteGlobalUser on:new={listUsers} />
@@ -91,21 +62,23 @@
<TableCustom>
<tr slot="header-row" class="sticky top-0 bg-white border-b">
<th>email</th>
<th>superadmin</th>
<th>auth</th>
<th>name</th>
<th>company</th>
<th />
<th />
</tr>
<tbody slot="body" class="overflow-y-auto h-full max-h-full">
{#if filteredUsers && users}
{#each filteredUsers as { email, super_admin, login_type, name, company } (email)}
<tr class="border">
<td>{email}</td>
<td>{super_admin ? 'yes' : ''}</td>
<td>{login_type}</td>
<td><span class="break-words">{name ?? ''}</span></td>
<td><span class="break-words">{company ?? ''}</span></td>
<td
>{#if super_admin}<Badge>Superadmin</Badge>{/if}</td
>
<td>
<div class="flex flex-row gap-x-1">
<button
@@ -119,18 +92,15 @@
})
sendUserToast('User updated')
listUsers()
}}>{super_admin ? 'demote' : 'promote'}</button
}}>{super_admin ? 'non-superadmin' : 'superadmin'}</button
>
|
<button
class="text-red-500"
on:click={async () => {
deleteConfirmedCallback = async () => {
await UserService.globalUserDelete({ email })
sendUserToast(`User ${email} removed`)
await listUsers()
}
openConfirmation = false
await UserService.globalUserDelete({ email })
sendUserToast(`User ${email} removed`)
listUsers()
}}>remove</button
>
</div>
@@ -92,7 +92,7 @@
description: variable.description
}
})
sendUserToast(`Successfully created variable ${path}`)
sendUserToast(`Created variable ${path}`)
dispatch('create')
drawer.closeDrawer()
}
@@ -115,7 +115,7 @@
description: getV.description != variable.description ? variable.description : undefined
}
})
sendUserToast(`Successfully updated variable at ${initialPath}`)
sendUserToast(`Updated variable ${initialPath}`)
dispatch('create')
drawer.closeDrawer()
} catch (err) {
@@ -25,6 +25,7 @@
import ContextPanel from './contextPanel/ContextPanel.svelte'
import { classNames } from '$lib/utils'
import AppPreview from './AppPreview.svelte'
import { userStore } from '$lib/stores'
export let app: App
export let path: string
@@ -67,63 +68,67 @@
$: width = $breakpoint === 'sm' ? 'w-[640px]' : 'w-full '
</script>
{#if initialMode !== 'preview'}
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} bind:breakpoint={$breakpoint} />
{/if}
{#if !$userStore?.operator}
{#if initialMode !== 'preview'}
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} bind:breakpoint={$breakpoint} />
{/if}
{#if previewing}
<AppPreview app={$appStore} />
{#if previewing}
<AppPreview app={$appStore} />
{:else}
<SplitPanesWrapper class="max-w-full overflow-hidden">
<Pane size={20} minSize={15} maxSize={40}>
<ContextPanel appPath={path} />
</Pane>
<Pane size={60}>
<div class="p-4 bg-gray-100 min-h-full w-full relative">
{#if $appStore.grid}
<div class={classNames('mx-auto h-full', width)}>
<GridEditor />
</div>
{/if}
{#if $connectingInput.opened}
<div
class="absolute top-0 left-0 w-full h-full bg-black border-2 bg-opacity-25 z-1 flex justify-center items-center"
/>
{/if}
</div>
</Pane>
<Pane size={30} minSize={25} maxSize={40}>
<Tabs bind:selected={selectedTab}>
<Tab value="insert" size="xs">
<div class="m-1 flex flex-row gap-2">
<Icon data={faPlus} />
<span>Insert</span>
</div>
</Tab>
<Tab value="settings" size="xs">
<div class="m-1 flex flex-row gap-2">
<Icon data={faSliders} />
<span>Settings</span>
</div>
</Tab>
<svelte:fragment slot="content">
<TabContent value="settings">
{#if $selectedComponent !== undefined}
{#each $appStore.grid as gridItem (gridItem.id)}
{#if gridItem.data.id === $selectedComponent}
<ComponentPanel bind:component={gridItem.data} />
{/if}
{/each}
{/if}
{#if $selectedComponent === undefined}
<div class="p-4 text-sm">No component selected.</div>
{/if}
</TabContent>
<TabContent value="insert">
<ComponentList />
</TabContent>
</svelte:fragment>
</Tabs>
</Pane>
</SplitPanesWrapper>
{/if}
{:else}
<SplitPanesWrapper class="max-w-full overflow-hidden">
<Pane size={20} minSize={15} maxSize={40}>
<ContextPanel appPath={path} />
</Pane>
<Pane size={60}>
<div class="p-4 bg-gray-100 min-h-full w-full relative">
{#if $appStore.grid}
<div class={classNames('mx-auto h-full', width)}>
<GridEditor />
</div>
{/if}
{#if $connectingInput.opened}
<div
class="absolute top-0 left-0 w-full h-full bg-black border-2 bg-opacity-25 z-1 flex justify-center items-center"
/>
{/if}
</div>
</Pane>
<Pane size={30} minSize={25} maxSize={40}>
<Tabs bind:selected={selectedTab}>
<Tab value="insert" size="xs">
<div class="m-1 flex flex-row gap-2">
<Icon data={faPlus} />
<span>Insert</span>
</div>
</Tab>
<Tab value="settings" size="xs">
<div class="m-1 flex flex-row gap-2">
<Icon data={faSliders} />
<span>Settings</span>
</div>
</Tab>
<svelte:fragment slot="content">
<TabContent value="settings">
{#if $selectedComponent !== undefined}
{#each $appStore.grid as gridItem (gridItem.id)}
{#if gridItem.data.id === $selectedComponent}
<ComponentPanel bind:component={gridItem.data} />
{/if}
{/each}
{/if}
{#if $selectedComponent === undefined}
<div class="p-4 text-sm">No component selected.</div>
{/if}
</TabContent>
<TabContent value="insert">
<ComponentList />
</TabContent>
</svelte:fragment>
</Tabs>
</Pane>
</SplitPanesWrapper>
App editor not available to operators
{/if}
@@ -41,10 +41,10 @@
}
})
.then(() => {
sendUserToast('Saved successfully.')
sendUserToast('Saved')
})
.catch(() => {
sendUserToast('Error during saving. Please try again later.', true)
sendUserToast('Error during saving. Please try again later', true)
})
.finally(() => {
loading.save = false
@@ -2,7 +2,7 @@
import Dropdown from '$lib/components/Dropdown.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import { AppService, type ListableApp } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { faEdit, faEye, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
@@ -32,18 +32,20 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<span class="hidden md:inline-flex gap-x-1">
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/apps/edit/{path}"
>
Edit
</Button>
</div>
{#if !$userStore?.operator}
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/apps/edit/{path}"
>
Edit
</Button>
</div>
{/if}
{/if}
<Button
href="/apps/get/{path}"
@@ -4,7 +4,7 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
import {
faArchive,
@@ -34,7 +34,7 @@
try {
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
dispatch('change')
sendUserToast(`Successfully archived flow ${path}`)
sendUserToast(`Archived flow ${path}`)
} catch (err) {
sendUserToast(`Could not archive this flow ${err.body}`, true)
}
@@ -56,30 +56,32 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<span class="hidden md:inline-flex gap-x-1">
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/flows/edit/{path}"
>
Edit
</Button>
</div>
{:else}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faCodeFork }}
href="/flows/add?template={path}"
>
Fork
</Button>
</div>
{#if !$userStore?.operator}
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/flows/edit/{path}"
>
Edit
</Button>
</div>
{:else}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faCodeFork }}
href="/flows/add?template={path}"
>
Fork
</Button>
</div>
{/if}
{/if}
<Button
@@ -4,7 +4,7 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { ScriptService, type Script } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { capitalize, sendUserToast } from '$lib/utils'
import {
faArchive,
@@ -44,7 +44,7 @@
async function archiveScript(path: string): Promise<void> {
await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path })
dispatch('change')
sendUserToast(`Successfully archived script ${path}`)
sendUserToast(`Archived script ${path}`)
}
</script>
@@ -74,30 +74,32 @@
<svelte:fragment slot="actions">
<span class="hidden md:inline-flex gap-x-1">
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/scripts/edit/{hash}?step=2"
>
Edit
</Button>
</div>
{:else}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faCodeFork }}
href="/scripts/add?template={path}"
>
Fork
</Button>
</div>
{#if !$userStore?.operator}
{#if canWrite}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faEdit }}
href="/scripts/edit/{hash}?step=2"
>
Edit
</Button>
</div>
{:else}
<div>
<Button
color="light"
size="xs"
variant="border"
startIcon={{ icon: faCodeFork }}
href="/scripts/add?template={path}"
>
Fork
</Button>
</div>
{/if}
{/if}
<Button
@@ -128,6 +130,7 @@
icon: faEye,
href: `/scripts/get/${hash}`
},
{
displayName: 'Edit',
icon: faEdit,
@@ -55,7 +55,8 @@
return {
canWrite:
canWrite(script.path, script.extra_perms, $userStore) &&
script.workspace_id == $workspaceStore,
script.workspace_id == $workspaceStore &&
!$userStore?.operator,
...script
}
})
@@ -66,7 +67,10 @@
async function loadFlows(): Promise<void> {
flows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map((x: Flow) => {
return {
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
canWrite:
canWrite(x.path, x.extra_perms, $userStore) &&
x.workspace_id == $workspaceStore &&
!$userStore?.operator,
...x
}
})
@@ -77,7 +81,9 @@
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
return {
canWrite:
canWrite(app.path!, app.extra_perms!, $userStore) && app.workspace_id == $workspaceStore,
canWrite(app.path!, app.extra_perms!, $userStore) &&
app.workspace_id == $workspaceStore &&
!$userStore?.operator,
...app
}
})
@@ -18,21 +18,21 @@
const mainMenuLinks = [
{ label: 'Home', href: '/', icon: faHomeAlt },
{ label: 'Runs', href: '/runs', icon: faPlay },
{ label: 'Variables', href: '/variables', icon: faDollarSign },
{ label: 'Resources', href: '/resources', icon: faCubes }
{ label: 'Variables', href: '/variables', icon: faDollarSign, disabled: $userStore?.operator },
{ label: 'Resources', href: '/resources', icon: faCubes, disabled: $userStore?.operator }
]
$: secondaryMenuLinks = [
{ label: 'Schedules', href: '/schedules', icon: faCalendar },
{ label: 'Groups', href: '/groups', icon: faUsersCog },
{ label: 'Audit Logs', href: '/audit_logs', icon: faEye },
{ label: 'Schedules', href: '/schedules', icon: faCalendar, disabled: $userStore?.operator },
{ label: 'Groups', href: '/groups', icon: faUsersCog, disabled: $userStore?.operator },
{ label: 'Audit Logs', href: '/audit_logs', icon: faEye, disabled: $userStore?.operator },
{
label: 'Workspace',
href: '/workspace_settings',
icon: faCog,
disabled: !($userStore?.is_admin || $superadmin)
},
{ label: 'Workers', href: '/workers', icon: faRobot }
{ label: 'Workers', href: '/workers', icon: faRobot, disabled: $userStore?.operator }
]
const thirdMenuLinks = [
@@ -3,7 +3,7 @@
import { userStore, usersWorkspaceStore, superadmin, usageStore } from '$lib/stores'
import { classNames, isCloudHosted } from '$lib/utils'
import { faCrown, faUser } from '@fortawesome/free-solid-svg-icons'
import { faCrown, faPlay, faUser } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import Icon from 'svelte-awesome'
@@ -31,6 +31,8 @@
{$userStore?.username ?? ($superadmin ? $superadmin : '___')}
{#if $userStore?.is_admin || $superadmin}
<Icon data={faCrown} scale={0.6} />
{:else if $userStore?.operator}
<Icon class="ml-2" data={faPlay} scale={0.8} />
{/if}
</span>
{/if}
@@ -42,7 +44,15 @@
<p class="text-sm font-medium text-gray-900 truncate" role="none">
{$usersWorkspaceStore?.email}
</p>
<span class="text-xs text-gray-600">
{#if $userStore?.is_admin}
Admin of this workspace <Icon data={faCrown} scale={0.6} />
{:else if $userStore?.operator}
Operator in this workspace <Icon class="ml-2" data={faPlay} scale={0.8} />
{/if}
</span>
</div>
<div class="py-1" role="none">
<a
on:click={() => dispatch('user-settings')}
+1
View File
@@ -9,6 +9,7 @@ export interface UserExt {
username: string;
is_admin: boolean;
is_super_admin: boolean;
operator: boolean;
created_at: string;
groups: string[];
pgroups: string[];
+21 -19
View File
@@ -157,25 +157,27 @@
>
Run
</Button>
<Button
href="/flows/edit/{path}"
variant="contained"
color="blue"
size="xs"
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Edit
</Button>
<Button
href="/flows/add?template={flow.path}"
variant="contained"
color="blue"
size="xs"
startIcon={{ icon: faCodeFork }}
>
Use as template/Fork
</Button>
{#if !$userStore?.operator}
<Button
href="/flows/edit/{path}"
variant="contained"
color="blue"
size="xs"
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Edit
</Button>
<Button
href="/flows/add?template={flow.path}"
variant="contained"
color="blue"
size="xs"
startIcon={{ icon: faCodeFork }}
>
Use as template/Fork
</Button>
{/if}
<Button href="/runs/{flow.path}" color="blue" size="xs" startIcon={{ icon: faList }}>
View runs
</Button>
+28 -24
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { FlowService, type OpenFlow } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
import PageHeader from '$lib/components/PageHeader.svelte'
import CreateActionsFlow from '$lib/components/flows/CreateActionsFlow.svelte'
@@ -130,32 +130,36 @@
{/if}
<PageHeader title="Home">
<div class="flex flex-row gap-2 flex-wrap">
<CreateApp />
<CreateActionsScript />
<CreateActionsFlow />
{#if !$userStore?.operator}
<CreateApp />
<CreateActionsScript />
<CreateActionsFlow />
{/if}
</div>
</PageHeader>
<Tabs bind:selected={tab}>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
</div>
</Tab>
<Tab size="md" value="hubscripts">
<div class="flex gap-2 items-center my-1">
<Globe2 size={18} />
Hub Scripts
</div>
</Tab>
<Tab size="md" value="hubflows">
<div class="flex gap-2 items-center my-1">
<Globe2 size={18} />
Hub Flows
</div>
</Tab>
</Tabs>
{#if !$userStore?.operator}
<Tabs bind:selected={tab}>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
</div>
</Tab>
<Tab size="md" value="hubscripts">
<div class="flex gap-2 items-center my-1">
<Globe2 size={18} />
Hub Scripts
</div>
</Tab>
<Tab size="md" value="hubflows">
<div class="flex gap-2 items-center my-1">
<Globe2 size={18} />
Hub Flows
</div>
</Tab>
</Tabs>
{/if}
<div class="my-2" />
<div class="flex flex-col gap-y-16">
<div class="flex flex-col">
+10 -8
View File
@@ -193,14 +193,16 @@
size="xs"
startIcon={{ icon: faRefresh }}>Run again</Button
>
{#if canWrite(job?.script_path ?? '', {}, $userStore)}
<Button
disabled={not_same_workspace}
href={editHref}
color="blue"
size="xs"
startIcon={{ icon: faEdit }}>Edit</Button
>
{#if !$userStore?.operator}
{#if canWrite(job?.script_path ?? '', {}, $userStore)}
<Button
disabled={not_same_workspace}
href={editHref}
color="blue"
size="xs"
startIcon={{ icon: faEdit }}>Edit</Button
>
{/if}
{/if}
<Button
disabled={not_same_workspace}
@@ -157,24 +157,26 @@
>
Run
</Button>
<Button
href={`/scripts/edit/${script.hash}?step=2`}
color="blue"
size="md"
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Edit
</Button>
{#if !topHash}
{#if !$userStore?.operator}
<Button
href={`/scripts/add?template=${script.path}`}
variant="border"
href={`/scripts/edit/${script.hash}?step=2`}
color="blue"
size="md"
startIcon={{ icon: faCodeFork }}
startIcon={{ icon: faEdit }}
disabled={!can_write}
>
Fork
Edit
</Button>
{#if !topHash}
<Button
href={`/scripts/add?template=${script.path}`}
variant="border"
size="md"
startIcon={{ icon: faCodeFork }}
>
Fork
</Button>
{/if}
{/if}
</div>
</div>
@@ -69,8 +69,7 @@
requestBody: args,
scheduledFor
})
sendUserToast(`Job <a href='/run/${run}?workspace=${$workspaceStore}'>${run}</a> started`)
goto('/run/' + run + '?workspace=' + $workspaceStore)
await goto('/run/' + run + '?workspace=' + $workspaceStore)
} catch (err) {
sendUserToast(`Could not create job: ${err.body}`, true)
}
@@ -8,6 +8,7 @@
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
const rd = $page.url.searchParams.get('rd')
@@ -45,10 +46,10 @@
if (auto_invite) {
await WorkspaceService.editAutoInvite({
workspace: id,
requestBody: { set: true }
requestBody: { operator: operatorOnly }
})
}
sendUserToast(`Successfully created workspace id: ${id}`)
sendUserToast(`Created workspace id: ${id}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
workspaceStore.set(id)
@@ -94,6 +95,7 @@
$: domain = $usersWorkspaceStore?.email.split('@')[1]
let auto_invite = false
let operatorOnly = false
</script>
<CenteredModal title="New Workspace">
@@ -120,6 +122,16 @@
bind:checked={auto_invite}
options={{ right: `Auto invite users with the same domain (${domain})` }}
/>
{#if auto_invite}
<Toggle
bind:checked={operatorOnly}
options={{ right: `Auto invited users to join as operators` }}
/>
<Tooltip
>An operator can only execute and view scripts/flows/apps from your workspace, and only those
that he has visibility on</Tooltip
>
{/if}
{#if !isDomainAllowed}
<div class="text-gray-600 text-sm mb-4 mt-2">{domain} domain not allowed for auto-invite</div>
{/if}
@@ -131,6 +131,8 @@
<span class="font-mono font-semibold">{invite.workspace_id}</span>
{#if invite.is_admin}
<span class="text-sm">as an admin</span>
{:else if invite.operator}
<span class="text-sm">as an operator</span>
{/if}
</div>
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
+155 -59
View File
@@ -28,6 +28,9 @@
import Tooltip from '$lib/components/Tooltip.svelte'
import { faScroll, faBarsStaggered } from '@fortawesome/free-solid-svg-icons'
import SearchItems from '$lib/components/SearchItems.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
let users: User[] | undefined = undefined
let invites: WorkspaceInvite[] = []
@@ -38,6 +41,7 @@
let team_name: string | undefined
let auto_invite_domain: string | undefined
let itemKind: 'flow' | 'script' = 'flow'
let operatorOnly: boolean | undefined = undefined
// function getDropDownItems(username: string): DropdownItem[] {
// return [
@@ -76,6 +80,7 @@
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
team_name = settings.slack_name
auto_invite_domain = settings.auto_invite_domain
operatorOnly = settings.auto_invite_operator
scriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/')
initialPath = scriptPath
}
@@ -104,6 +109,34 @@
loadSettings()
}
}
$: operatorOnly != undefined && auto_invite_domain && setOperatorOnly()
async function removeAllInvitesFromDomain() {
await Promise.all(
invites
.filter((x) => x.email.endsWith('@' + auto_invite_domain ?? ''))
.map(({ email, is_admin, operator }) =>
WorkspaceService.deleteInvite({
workspace: $workspaceStore ?? '',
requestBody: {
email,
is_admin,
operator
}
})
)
)
}
async function setOperatorOnly() {
await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: operatorOnly }
})
loadSettings()
listInvites()
}
</script>
<SearchItems
@@ -127,47 +160,94 @@
<tr slot="header-row">
<th>email</th>
<th>username</th>
<th>role</th>
<th colspan="3">jobs &amp; flows (<abbr title="past two weeks">2w</abbr>)</th>
<th
>executions (<abbr title="past 5 weeks">5w</abbr>) <Tooltip
>An execution is calculated as 1 for any runs of scripts + 1 for each seconds above
the first one</Tooltip
>
</th>
<th />
<th />
<th />
</tr>
<tbody slot="body">
{#if filteredUsers}
{#each filteredUsers as { email, username, is_admin, usage } (email)}
{#each filteredUsers as { email, username, is_admin, operator, usage, disabled } (email)}
<tr class="border">
<td>{email}</td>
<td>{username}</td>
<td>{is_admin ? 'admin' : 'user'}</td>
<td>{usage?.jobs}</td>
<td>{usage?.flows}</td>
<td>{msToSec(usage?.duration_ms)}s</td>
<td class="whitespace-nowrap"
><button
class="ml-2 text-red-500"
on:click={async () => {
await UserService.deleteUser({
workspace: $workspaceStore ?? '',
username
})
sendUserToast('User removed')
listUsers()
}}>remove</button
>
-
<button
class="text-blue-500"
on:click={async () => {
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: {
is_admin: !is_admin
}
})
sendUserToast('User updated')
listUsers()
}}>{is_admin ? 'demote' : 'promote'}</button
<td>{usage?.executions}</td>
<td
><div class="flex gap-1"
>{#if disabled}
<Badge color="red">disabled</Badge>
{/if}</div
></td
>
<td>
<div>
<ToggleButtonGroup
selected={is_admin ? 'admin' : operator ? 'operator' : 'author'}
on:selected={async (e) => {
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()
}}
>
<ToggleButton position="left" value="operator" size="xs"
>Operator <Tooltip
>An operator can only execute and view scripts/flows/apps from your
workspace, and only those that he has visibility on</Tooltip
></ToggleButton
>
<ToggleButton position="center" value="author" size="xs"
>Author <Tooltip
>An Author can execute and view scripts/flows/apps, but he can also create
new ones</Tooltip
></ToggleButton
>
<ToggleButton position="right" value="admin" size="xs">Admin</ToggleButton>
</ToggleButtonGroup>
</div>
</td>
<td>
<div class="flex gap-1">
<button
class="text-blue-500"
on:click={async () => {
await UserService.updateUser({
workspace: $workspaceStore ?? '',
username,
requestBody: {
disabled: !disabled
}
})
listUsers()
}}>{disabled ? 'enable' : 'disable'}</button
>
|
<button
class="text-red-500"
on:click={async () => {
await UserService.deleteUser({
workspace: $workspaceStore ?? '',
username
})
sendUserToast('User removed')
listUsers()
}}>remove</button
>
</div>
</td>
</tr>
{/each}
{:else}
@@ -196,10 +276,12 @@
<th />
</tr>
<tbody slot="body">
{#each invites as { email, is_admin }}
{#each invites as { email, is_admin, operator }}
<tr class="border">
<td>{email}</td>
<td>{is_admin ? 'admin' : 'user'}</td>
<td
>{#if operator}<Badge>operator</Badge>{:else if is_admin}<Badge>admin</Badge>{/if}
</td>
<td>
<button
class="ml-2 text-red-500"
@@ -208,11 +290,12 @@
workspace: $workspaceStore ?? '',
requestBody: {
email,
is_admin
is_admin,
operator
}
})
listInvites()
}}>remove</button
}}>cancel</button
></td
>
</tr>
@@ -224,29 +307,42 @@
<PageHeader title="Auto Invite" primary={false} />
<div class="flex gap-2">
{#if auto_invite_domain != domain}
<Button
disabled={!allowedAutoDomain}
on:click={async () => {
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { set: true }
})
loadSettings()
listInvites()
}}>Set auto-invite to {domain}</Button
>
<div>
<Button
disabled={!allowedAutoDomain}
on:click={async () => {
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: false }
})
loadSettings()
listInvites()
}}>Set auto-invite to {domain}</Button
>
</div>
{/if}
{#if auto_invite_domain}
<Button
on:click={async () => {
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { set: false }
})
loadSettings()
listInvites()
}}>Unset auto-invite from {auto_invite_domain}</Button
>
<div class="flex flex-col gap-y-2">
<Toggle
bind:checked={operatorOnly}
options={{
right: `Auto-invited users to join as operators`
}}
/>
<div>
<Button
on:click={async () => {
await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: undefined }
})
loadSettings()
listInvites()
}}>Unset auto-invite from {auto_invite_domain} domain</Button
>
</div>
</div>
{/if}
</div>
{#if !allowedAutoDomain}
@@ -334,7 +430,7 @@
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Successfully deleted workspace ${$workspaceStore}`)
sendUserToast(`Deleted workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')