mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
rework ownership permissions
This commit is contained in:
@@ -2757,6 +2757,16 @@ paths:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/ScriptPath"
|
||||
requestBody:
|
||||
description: archiveFlow
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
archived:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: flow archived
|
||||
@@ -5280,6 +5290,10 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
folders_owners:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
usage:
|
||||
$ref: "#/components/schemas/Usage"
|
||||
required:
|
||||
@@ -5291,6 +5305,7 @@ components:
|
||||
- operator
|
||||
- disabled
|
||||
- folders
|
||||
- folders_owners
|
||||
|
||||
Usage:
|
||||
type: object
|
||||
|
||||
@@ -442,7 +442,6 @@ async fn update_app(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
) -> Result<String> {
|
||||
@@ -459,10 +458,7 @@ async fn update_app(
|
||||
|
||||
if let Some(npath) = &ns.path {
|
||||
if npath != path {
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db)
|
||||
.await?;
|
||||
}
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
sqlb.set_str("path", npath);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use sql_builder::prelude::*;
|
||||
use sql_builder::SqlBuilder;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
@@ -26,10 +27,11 @@ use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{self, to_anyhow, Error, JsonResult, Result},
|
||||
flows::{Flow, ListFlowQuery, ListableFlow, NewFlow},
|
||||
jobs::JobPayload,
|
||||
schedule::Schedule,
|
||||
utils::{
|
||||
http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath,
|
||||
}, jobs::JobPayload,
|
||||
},
|
||||
};
|
||||
use windmill_queue::{push, schedule::push_scheduled_job, QueueTransaction};
|
||||
|
||||
@@ -85,9 +87,8 @@ async fn list_flows(
|
||||
.limit(per_page)
|
||||
.clone();
|
||||
|
||||
if !lq.show_archived.unwrap_or(false) {
|
||||
sqlb.and_where_eq("archived", false);
|
||||
}
|
||||
sqlb.and_where_eq("archived", lq.show_archived.unwrap_or(false));
|
||||
|
||||
if let Some(ps) = &lq.path_start {
|
||||
sqlb.and_where_like_left("path", "?".bind(ps));
|
||||
}
|
||||
@@ -325,7 +326,7 @@ async fn update_flow(
|
||||
check_schedule_conflict(tx.transaction_mut(), &w_id, &nf.path).await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &flow_path, &db).await?;
|
||||
require_owner_of_path(&authed, flow_path)?;
|
||||
}
|
||||
|
||||
let mut schedulables: Vec<Schedule> = sqlx::query_as!(
|
||||
@@ -462,17 +463,24 @@ async fn exists_flow_by_path(
|
||||
Ok(Json(exists))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Archived {
|
||||
archived: Option<bool>,
|
||||
}
|
||||
|
||||
async fn archive_flow_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(archived): Json<Archived>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE flow SET archived = true WHERE path = $1 AND workspace_id = $2",
|
||||
"UPDATE flow SET archived = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
archived.archived.unwrap_or(true),
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
|
||||
@@ -22,13 +22,13 @@ use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{self, to_anyhow, Error, JsonResult, Result},
|
||||
error::{self, to_anyhow, JsonResult, Result},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{query_scalar, FromRow, Postgres, Transaction};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -41,7 +41,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/delete/:name", delete(delete_folder))
|
||||
.route("/addowner/:name", post(add_owner))
|
||||
.route("/removeowner/:name", post(remove_owner))
|
||||
.route("/is_owner/*path", get(is_owner))
|
||||
.route("/is_owner/*path", get(is_owner_api))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
@@ -219,47 +219,29 @@ async fn create_folder(
|
||||
Ok(format!("Created folder {}", ng.name))
|
||||
}
|
||||
|
||||
pub async fn is_owner(
|
||||
Authed { username, is_admin, groups, .. }: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
pub async fn is_owner_api(
|
||||
authed: Authed,
|
||||
Path((_w_id, name)): Path<(String, String)>,
|
||||
) -> JsonResult<bool> {
|
||||
if is_admin {
|
||||
Ok(Json(true))
|
||||
Ok(Json(is_owner(&authed, &name)))
|
||||
}
|
||||
|
||||
pub fn is_owner(Authed { is_admin, folders, .. }: &Authed, name: &str) -> bool {
|
||||
if *is_admin {
|
||||
true
|
||||
} else {
|
||||
Ok(Json(
|
||||
require_is_owner(&name, &username, &groups, &w_id, &db)
|
||||
.await
|
||||
.is_ok(),
|
||||
))
|
||||
folders.into_iter().any(|x| x.0 == name && x.2)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_is_owner(
|
||||
folder_name: &str,
|
||||
username: &str,
|
||||
groups: &Vec<String>,
|
||||
w_id: &str,
|
||||
db: &DB,
|
||||
) -> Result<()> {
|
||||
let is_owner = query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM folder WHERE CONCAT('u/', $1::text) = ANY(owners) AND name = $2 AND workspace_id = $4) OR exists(
|
||||
SELECT 1 FROM folder, unnest(folder.owners) as o
|
||||
WHERE o = ANY($3::text[]) AND folder.name = $2 AND folder.workspace_id = $4)",
|
||||
username,
|
||||
folder_name,
|
||||
groups,
|
||||
w_id,
|
||||
).fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_owner {
|
||||
Err(Error::BadRequest(format!(
|
||||
"{} is not an owner of {} and hence is not authorized to perform this operation",
|
||||
username, folder_name
|
||||
)))
|
||||
} else {
|
||||
pub fn require_is_owner(authed: &Authed, name: &str) -> Result<()> {
|
||||
if is_owner(authed, name) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(windmill_common::error::Error::NotAuthorized(format!(
|
||||
"You are not owner of the folder {}",
|
||||
name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +473,6 @@ async fn delete_folder(
|
||||
|
||||
async fn add_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
@@ -500,9 +481,7 @@ async fn add_owner(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
require_is_owner(&authed, &name)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE folder SET owners = array_append(owners, $1) WHERE name = $2 AND workspace_id = $3 AND NOT $1 = ANY(owners) RETURNING name",
|
||||
@@ -538,14 +517,14 @@ pub async fn get_folders_for_user(
|
||||
username: &str,
|
||||
groups: &[String],
|
||||
db: &DB,
|
||||
) -> Result<Vec<(String, bool)>> {
|
||||
) -> Result<Vec<(String, bool, bool)>> {
|
||||
let mut perms = groups
|
||||
.into_iter()
|
||||
.map(|x| format!("g/{}", x))
|
||||
.collect::<Vec<_>>();
|
||||
perms.insert(0, format!("u/{}", username));
|
||||
let folders = sqlx::query!(
|
||||
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write FROM folder
|
||||
"SELECT name, (EXISTS (SELECT 1 FROM (SELECT key, value FROM jsonb_each_text(extra_perms) WHERE key = ANY($1)) t WHERE value::boolean IS true)) as write, $1 && owners::text[] as owner FROM folder
|
||||
WHERE extra_perms ?| $1 AND workspace_id = $2",
|
||||
&perms[..],
|
||||
w_id,
|
||||
@@ -553,7 +532,7 @@ pub async fn get_folders_for_user(
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| (x.name, x.write.unwrap_or(false)))
|
||||
.map(|x| (x.name, x.write.unwrap_or(false), x.owner.unwrap_or(false)))
|
||||
.collect();
|
||||
|
||||
Ok(folders)
|
||||
@@ -561,7 +540,6 @@ pub async fn get_folders_for_user(
|
||||
|
||||
async fn remove_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
@@ -570,9 +548,7 @@ async fn remove_owner(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
require_is_owner(&authed, &name)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE folder SET owners = array_remove(owners, $1) WHERE name = $2 AND workspace_id = $3 RETURNING name",
|
||||
|
||||
@@ -43,25 +43,29 @@ async fn add_granular_acl(
|
||||
Json(GranularAcl { owner, write }): Json<GranularAcl>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
|
||||
let (kind, path) = path
|
||||
.split_once('/')
|
||||
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
if kind == "folder" {
|
||||
crate::folders::require_is_owner(&path, &authed.username, &authed.groups, &w_id, &db)
|
||||
.await?;
|
||||
} else if kind == "group_" {
|
||||
} else {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
|
||||
}
|
||||
}
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
"name"
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
|
||||
if !authed.is_admin {
|
||||
if kind == "folder" {
|
||||
crate::folders::require_is_owner(&authed, path)?;
|
||||
} else if kind == "group_" {
|
||||
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
||||
.await?;
|
||||
} else {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
}
|
||||
|
||||
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||
"UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) WHERE {identifier} = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||
@@ -86,12 +90,22 @@ async fn remove_granular_acl(
|
||||
Json(GranularAcl { owner, write: _ }): Json<GranularAcl>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
|
||||
}
|
||||
|
||||
let (kind, path) = path
|
||||
.split_once('/')
|
||||
.ok_or_else(|| Error::BadRequest("Invalid path or kind".to_string()))?;
|
||||
|
||||
if !authed.is_admin {
|
||||
if kind == "folder" {
|
||||
crate::folders::require_is_owner(&authed, path)?;
|
||||
} else if kind == "group_" {
|
||||
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
|
||||
.await?;
|
||||
} else {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let identifier = if kind == "group_" || kind == "folder" {
|
||||
@@ -99,6 +113,11 @@ async fn remove_granular_acl(
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
|
||||
if identifier == "path" {
|
||||
require_owner_of_path(&authed, path)?;
|
||||
}
|
||||
|
||||
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
|
||||
"UPDATE {kind} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND \
|
||||
workspace_id = $3 RETURNING extra_perms"
|
||||
|
||||
@@ -626,7 +626,7 @@ async fn list_jobs(
|
||||
pub async fn resume_suspended_flow_as_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, flow_id)): Path<(String, Uuid)>,
|
||||
Path((_w_id, flow_id)): Path<(String, Uuid)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
@@ -634,16 +634,11 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
|
||||
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(
|
||||
&w_id,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
&flow.script_path.clone().unwrap_or_else(|| String::new()),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
require_owner_of_path(
|
||||
&authed,
|
||||
&flow.script_path.clone().unwrap_or_else(|| String::new()),
|
||||
)?;
|
||||
|
||||
insert_resume_job(0, job_id, &flow, value, Some(authed.username), &mut tx).await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
|
||||
@@ -386,9 +386,8 @@ async fn update_resource(
|
||||
if npath != path {
|
||||
check_path_conflict(&mut tx, &w_id, &npath).await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
|
||||
}
|
||||
require_owner_of_path(&authed, path)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET path = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
npath,
|
||||
|
||||
@@ -273,10 +273,7 @@ async fn create_script(
|
||||
let ps = get_script_by_hash_internal(tx.transaction_mut(), &w_id, p_hash).await?;
|
||||
|
||||
if ps.path != ns.path {
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &ps.path, &db)
|
||||
.await?;
|
||||
}
|
||||
require_owner_of_path(&authed, &ps.path)?;
|
||||
}
|
||||
|
||||
let ph = {
|
||||
@@ -637,6 +634,8 @@ async fn archive_script_by_path(
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_owner_of_path(&authed, path)?;
|
||||
|
||||
let hash: i64 = sqlx::query_scalar!(
|
||||
"UPDATE script SET archived = true WHERE path = $1 AND workspace_id = $2 RETURNING hash",
|
||||
path,
|
||||
|
||||
@@ -380,9 +380,8 @@ async fn update_variable(
|
||||
if let Some(npath) = ns.path {
|
||||
if npath != path {
|
||||
check_path_conflict(&mut tx, &w_id, &npath).await?;
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(&w_id, &authed.username, &authed.groups, &path, &db).await?;
|
||||
}
|
||||
require_owner_of_path(&authed, path)?;
|
||||
|
||||
let mut v = sqlx::query_scalar!(
|
||||
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
|
||||
@@ -461,7 +461,7 @@
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<div slot="actions">
|
||||
<div slot="actions" class="flex gap-1">
|
||||
{#if step > 1 && !no_back}
|
||||
<Button variant="border" on:click={back}>Back</Button>
|
||||
{/if}
|
||||
|
||||
@@ -311,21 +311,23 @@
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="dark"
|
||||
size="xs"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
if (value == undefined || !Array.isArray(value)) {
|
||||
value = []
|
||||
}
|
||||
value = value.concat('')
|
||||
}}
|
||||
>
|
||||
<Icon data={faPlus} class="mr-2" />
|
||||
Add item
|
||||
</Button>
|
||||
<div class="flex mt-2">
|
||||
<Button
|
||||
variant="border"
|
||||
color="dark"
|
||||
size="xs"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
if (value == undefined || !Array.isArray(value)) {
|
||||
value = []
|
||||
}
|
||||
value = value.concat('')
|
||||
}}
|
||||
>
|
||||
<Icon data={faPlus} class="mr-2" />
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if inputCat == 'resource-object'}
|
||||
<ObjectResourceInput {format} bind:value />
|
||||
|
||||
@@ -11,13 +11,21 @@
|
||||
type Side = 'top' | 'bottom'
|
||||
type Placement = `${Side}-${Alignment}`
|
||||
|
||||
export let dropdownItems: DropdownItem[]
|
||||
export let dropdownItems: DropdownItem[] | (() => DropdownItem[]) = []
|
||||
export let name: string | undefined = undefined
|
||||
export let placement: Placement = 'bottom-start'
|
||||
export let btnClasses = ''
|
||||
$: buttonClass = twMerge('!border-0 bg-transparent !p-[6px]', btnClasses)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function computeDropdowns(): DropdownItem[] {
|
||||
if (typeof dropdownItems === 'function') {
|
||||
return dropdownItems()
|
||||
} else {
|
||||
return dropdownItems
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu {placement} let:close>
|
||||
@@ -37,7 +45,7 @@
|
||||
{/if}
|
||||
</Button>
|
||||
{#if dropdownItems}
|
||||
{#each dropdownItems as item, i}
|
||||
{#each computeDropdowns() as item, i}
|
||||
{#if item.action}
|
||||
<button
|
||||
on:click|preventDefault|stopPropagation={(event) => {
|
||||
@@ -91,8 +99,8 @@
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class:bg-gray-50={item.disabled}
|
||||
class="block text-left px-4 py-2 text-sm text-gray-700 cursor-auto"
|
||||
class:bg-gray-200={item.disabled}
|
||||
class="block font-semibold text-left px-4 py-2 text-sm text-gray-700 cursor-auto"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
id="user-menu-item-{name}-{i}}"
|
||||
|
||||
@@ -158,8 +158,8 @@
|
||||
timeout && clearTimeout(timeout)
|
||||
})
|
||||
|
||||
async function loadOwner(path: string) {
|
||||
is_owner = await isOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
|
||||
function loadOwner(path: string) {
|
||||
is_owner = isOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
|
||||
}
|
||||
|
||||
let selected: 'graph' | 'sequence' = 'graph'
|
||||
|
||||
@@ -220,21 +220,23 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
if (value == undefined || !Array.isArray(value)) {
|
||||
value = []
|
||||
}
|
||||
value = value.concat('')
|
||||
}}
|
||||
>
|
||||
<Icon data={faPlus} class="mr-2" />
|
||||
Add item
|
||||
</Button>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="mt-1"
|
||||
on:click={() => {
|
||||
if (value == undefined || !Array.isArray(value)) {
|
||||
value = []
|
||||
}
|
||||
value = value.concat('')
|
||||
}}
|
||||
>
|
||||
<Icon data={faPlus} class="mr-2" />
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
<span class="ml-2">
|
||||
{(value ?? []).length} item{(value ?? []).length > 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
kind = kind_l
|
||||
initialPath = initialPath_l
|
||||
summary = summary_l
|
||||
await loadOwner()
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
}
|
||||
|
||||
async function loadOwner() {
|
||||
own = await isOwner(initialPath, $userStore!, $workspaceStore!)
|
||||
function loadOwner() {
|
||||
own = isOwner(initialPath, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function updatePath() {
|
||||
|
||||
@@ -337,6 +337,7 @@
|
||||
btnClasses="!p-1.5"
|
||||
variant="border"
|
||||
size="xs"
|
||||
{disabled}
|
||||
on:click={newFolder.openDrawer}
|
||||
>
|
||||
<Icon scale={0.8} data={faPlus} /></Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Schema } from '$lib/common'
|
||||
import { ResourceService, type Resource } from '$lib/gen'
|
||||
import { canWrite, emptyString, sendUserToast } from '$lib/utils'
|
||||
import { canWrite, emptyString, isOwner, sendUserToast } from '$lib/utils'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Alert, Button, Drawer, Skeleton } from './common'
|
||||
import Path from './Path.svelte'
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<Path
|
||||
disabled={!can_write}
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, $workspaceStore)}
|
||||
bind:path
|
||||
{initialPath}
|
||||
namePlaceholder="resource"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import SharedBadge from './SharedBadge.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import CollapseLink from './CollapseLink.svelte'
|
||||
|
||||
export let runnable:
|
||||
| {
|
||||
@@ -136,62 +137,67 @@
|
||||
<div class="text-xs text-gray-600">No schema</div>
|
||||
{/if}
|
||||
{#if schedulable}
|
||||
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6 mb-6">
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewOptions ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewOptions = !viewOptions)}
|
||||
>
|
||||
Schedule to run later
|
||||
</Button>
|
||||
</div>
|
||||
{#if viewOptions}
|
||||
<div transition:slide|local class="mt-6">
|
||||
<div class="border rounded-md p-3 pt-4">
|
||||
<div class="flex flex-row items-end">
|
||||
<div class="w-max md:w-2/3 mt-2 mb-1">
|
||||
<label for="run-time" />
|
||||
<input
|
||||
class="inline-block"
|
||||
type="datetime-local"
|
||||
id="run-time"
|
||||
name="run-scheduled-time"
|
||||
bind:value={scheduledForStr}
|
||||
min={getToday().toISOString().slice(0, 16)}
|
||||
/>
|
||||
<div class="mt-10" />
|
||||
<CollapseLink text="Advanced">
|
||||
<div class="flex flex-col gap-4 mt-2 border p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex">
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewOptions ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewOptions = !viewOptions)}
|
||||
>
|
||||
Schedule to run later
|
||||
</Button>
|
||||
</div>
|
||||
{#if viewOptions}
|
||||
<div transition:slide|local class="mt-6">
|
||||
<div class="border rounded-md p-3 pt-4">
|
||||
<div class="flex flex-row items-end">
|
||||
<div class="w-max md:w-2/3 mt-2 mb-1">
|
||||
<label for="run-time" />
|
||||
<input
|
||||
class="inline-block"
|
||||
type="datetime-local"
|
||||
id="run-time"
|
||||
name="run-scheduled-time"
|
||||
bind:value={scheduledForStr}
|
||||
min={getToday().toISOString().slice(0, 16)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="mx-2 mb-1"
|
||||
on:click={() => {
|
||||
scheduledForStr = undefined
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="mx-2 mb-1"
|
||||
on:click={() => {
|
||||
scheduledForStr = undefined
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
|
||||
<div class="flex items-center gap-1">
|
||||
<Toggle
|
||||
options={{
|
||||
right: `make run invisible to others`
|
||||
}}
|
||||
bind:checked={invisible_to_owner}
|
||||
/>
|
||||
<Tooltip
|
||||
>By default, runs are visible to the owner(s) of the script or flow being triggered</Tooltip
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if runnable?.path?.startsWith(`u/${$userStore?.username}`) != true && (runnable?.path?.split('/')?.length ?? 0) > 2}
|
||||
<div class="flex items-center gap-1">
|
||||
<Toggle
|
||||
options={{
|
||||
right: `make run invisible to others`
|
||||
}}
|
||||
bind:checked={invisible_to_owner}
|
||||
/>
|
||||
<Tooltip
|
||||
>By default, runs are visible to the owner(s) of the script or flow being triggered</Tooltip
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapseLink>
|
||||
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6 mb-6">
|
||||
<div class="flex-row-reverse flex grow">
|
||||
<Button
|
||||
{loading}
|
||||
@@ -214,20 +220,24 @@
|
||||
{/if}
|
||||
|
||||
{#if viewCliRun}
|
||||
<div class="my-10" />
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
endIcon={{ icon: viewCliOptions ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewCliOptions = !viewCliOptions)}
|
||||
>
|
||||
Run it from the CLI
|
||||
</Button>
|
||||
{#if viewCliOptions}
|
||||
<div transition:slide|local class="mt-2 px-4 pt-2">
|
||||
<InlineCodeCopy content={cliCommand} />
|
||||
<CliHelpBox />
|
||||
<div>
|
||||
<div class="my-20" />
|
||||
<div class="flex">
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
endIcon={{ icon: viewCliOptions ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewCliOptions = !viewCliOptions)}
|
||||
>
|
||||
Run it from the CLI
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if viewCliOptions}
|
||||
<div transition:slide|local class="mt-2 px-4 pt-2">
|
||||
<InlineCodeCopy content={cliCommand} />
|
||||
<CliHelpBox />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -38,13 +38,14 @@
|
||||
loadAcls()
|
||||
loadGroups()
|
||||
loadUsernames()
|
||||
await loadOwner()
|
||||
loadOwner()
|
||||
drawer.openDrawer()
|
||||
}
|
||||
|
||||
async function loadOwner() {
|
||||
own = await isOwner(path, $userStore!, $workspaceStore!)
|
||||
own = isOwner(path, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
async function loadAcls() {
|
||||
acls = Object.entries(
|
||||
await GranularAclService.getGranularAcls({ workspace: $workspaceStore!, path, kind })
|
||||
|
||||
@@ -11,39 +11,43 @@
|
||||
let reason = ''
|
||||
|
||||
$: {
|
||||
let username = $userStore?.username ?? ''
|
||||
let pgroups = $userStore?.pgroups ?? []
|
||||
let pusername = `u/${username}`
|
||||
let extraPermsKeys = Object.keys(extraPerms)
|
||||
|
||||
if (pusername in extraPermsKeys) {
|
||||
if (extraPerms[pusername]) {
|
||||
kind = 'write'
|
||||
} else {
|
||||
kind = 'read'
|
||||
}
|
||||
reason = 'This item was shared to you personally'
|
||||
if ($userStore?.is_admin || $userStore?.is_super_admin) {
|
||||
kind = undefined
|
||||
} else {
|
||||
let writeGroup = pgroups.find((x) => extraPermsKeys.includes(x) && extraPerms[x])
|
||||
if (writeGroup) {
|
||||
kind = 'write'
|
||||
reason = `This item was write shared to the group ${writeGroup} which you are a member of`
|
||||
} else {
|
||||
let readGroup = pgroups.find((x) => extraPermsKeys.includes(x))
|
||||
if (readGroup) {
|
||||
kind = 'read'
|
||||
reason = `This item was read-only shared to the group ${readGroup} which you are a member of`
|
||||
let username = $userStore?.username ?? ''
|
||||
let pgroups = $userStore?.pgroups ?? []
|
||||
let pusername = `u/${username}`
|
||||
let extraPermsKeys = Object.keys(extraPerms)
|
||||
|
||||
if (pusername in extraPermsKeys) {
|
||||
if (extraPerms[pusername]) {
|
||||
kind = 'write'
|
||||
} else {
|
||||
kind = undefined
|
||||
kind = 'read'
|
||||
}
|
||||
reason = 'This item was shared to you personally'
|
||||
} else {
|
||||
let writeGroup = pgroups.find((x) => extraPermsKeys.includes(x) && extraPerms[x])
|
||||
if (writeGroup) {
|
||||
kind = 'write'
|
||||
reason = `This item was write shared to the group ${writeGroup} which you are a member of`
|
||||
} else {
|
||||
let readGroup = pgroups.find((x) => extraPermsKeys.includes(x))
|
||||
if (readGroup) {
|
||||
kind = 'read'
|
||||
reason = `This item was read-only shared to the group ${readGroup} which you are a member of`
|
||||
} else {
|
||||
kind = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kind == 'read' && canWrite) {
|
||||
kind = undefined
|
||||
}
|
||||
if (kind == undefined && !canWrite) {
|
||||
kind = 'read'
|
||||
reason = ''
|
||||
if (kind == 'read' && canWrite) {
|
||||
kind = undefined
|
||||
}
|
||||
if (kind == undefined && !canWrite) {
|
||||
kind = 'read'
|
||||
reason = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { canWrite, sendUserToast } from '$lib/utils'
|
||||
import { canWrite, isOwner, sendUserToast } from '$lib/utils'
|
||||
import { VariableService } from '$lib/gen'
|
||||
import Path from './Path.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -145,7 +145,7 @@
|
||||
{/if}
|
||||
<span class="font-semibold text-gray-700">Path</span>
|
||||
<Path
|
||||
disabled={!can_write}
|
||||
disabled={initialPath != '' && !isOwner(initialPath, $userStore, $workspaceStore)}
|
||||
bind:error={pathError}
|
||||
bind:path
|
||||
{initialPath}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<span class="font-semibold truncate text-gray-800">{title ?? ''}</span>
|
||||
</div>
|
||||
{#if $$slots.actions}
|
||||
<div class="flex gap-1 items-center justify-end">
|
||||
<div class="flex gap-2 items-center justify-end">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { isOwner, sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
faCalendarAlt,
|
||||
@@ -36,9 +36,13 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function archiveFlow(path: string): Promise<void> {
|
||||
async function archiveFlow(path: string, archived: boolean): Promise<void> {
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { archived }
|
||||
})
|
||||
dispatch('change')
|
||||
sendUserToast(`Archived flow ${path}`)
|
||||
} catch (err) {
|
||||
@@ -129,76 +133,79 @@
|
||||
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View flow',
|
||||
icon: faEye,
|
||||
href: `/flows/get/${path}?workspace=${$workspaceStore}`
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/flows/edit/${path}?nodraft=true`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template/Fork',
|
||||
icon: faCodeFork,
|
||||
href: `/flows/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: faFileExport,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'flow')
|
||||
dropdownItems={() => {
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
return [
|
||||
{
|
||||
displayName: 'View flow',
|
||||
icon: faEye,
|
||||
href: `/flows/get/${path}?workspace=${$workspaceStore}`
|
||||
},
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
action: () => {
|
||||
scheduleEditor.openNew(true, path)
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/flows/edit/${path}?nodraft=true`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Share' : 'See Permissions',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveFlow(path) : null
|
||||
{
|
||||
displayName: 'Use as template/Fork',
|
||||
icon: faCodeFork,
|
||||
href: `/flows/add?template=${path}`
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrashAlt,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: faFileExport,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'flow')
|
||||
},
|
||||
disabled: !owner || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
action: () => {
|
||||
scheduleEditor.openNew(true, path)
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: owner ? 'Share' : 'See Permissions',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'flow')
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite
|
||||
}
|
||||
]}
|
||||
{
|
||||
displayName: archived ? 'Unarchive' : 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path && archiveFlow(path, !archived)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrashAlt,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteFlow(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteFlow(path)
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</Row>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { capitalize, sendUserToast } from '$lib/utils'
|
||||
import { capitalize, isOwner, sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
faCalendarAlt,
|
||||
@@ -55,6 +55,20 @@
|
||||
sendUserToast(`Archived script ${path}`)
|
||||
}
|
||||
|
||||
async function unarchiveScript(path: string): Promise<void> {
|
||||
const r = await ScriptService.getScriptByPath({ workspace: $workspaceStore!, path })
|
||||
await ScriptService.createScript({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
...r,
|
||||
parent_hash: r.hash,
|
||||
lock: r.lock?.split('\n')
|
||||
}
|
||||
})
|
||||
dispatch('change')
|
||||
sendUserToast(`Unarchived script ${path}`)
|
||||
}
|
||||
|
||||
async function deleteScript(path: string): Promise<void> {
|
||||
await ScriptService.deleteScriptByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
@@ -145,88 +159,91 @@
|
||||
</span>
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'View script',
|
||||
icon: faEye,
|
||||
href: `/scripts/get/${hash}?workspace=${$workspaceStore}`
|
||||
},
|
||||
dropdownItems={() => {
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
return [
|
||||
{
|
||||
displayName: 'View script',
|
||||
icon: faEye,
|
||||
href: `/scripts/get/${hash}?workspace=${$workspaceStore}`
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Edit code',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'Use as template',
|
||||
icon: faCodeFork,
|
||||
href: `/scripts/add?template=${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: faFileExport,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'script')
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
action: () => {
|
||||
scheduleEditor.openNew(false, path)
|
||||
{
|
||||
displayName: 'Edit code',
|
||||
icon: faEdit,
|
||||
href: `/scripts/edit/${hash}`,
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Share' : 'See Permissions',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'script')
|
||||
{
|
||||
displayName: 'Use as template',
|
||||
icon: faCodeFork,
|
||||
href: `/scripts/add?template=${path}`
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
path ? archiveScript(path) : null
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: faFileExport,
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, summary, 'script')
|
||||
},
|
||||
disabled: !owner || archived
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !canWrite || archived
|
||||
},
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrashAlt,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
{
|
||||
displayName: 'View runs',
|
||||
icon: faList,
|
||||
href: `/runs/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Schedule',
|
||||
icon: faCalendarAlt,
|
||||
action: () => {
|
||||
scheduleEditor.openNew(false, path)
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: owner ? 'Share' : 'See Permissions',
|
||||
icon: faShare,
|
||||
action: () => {
|
||||
shareModal.openDrawer && shareModal.openDrawer(path, 'script')
|
||||
},
|
||||
disabled: archived
|
||||
},
|
||||
{
|
||||
displayName: archived ? 'Unarchive' : 'Archive',
|
||||
icon: faArchive,
|
||||
action: () => {
|
||||
archived ? path && unarchiveScript(path) : path && archiveScript(path)
|
||||
},
|
||||
type: 'delete',
|
||||
disabled: !owner
|
||||
},
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? [
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrashAlt,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteScript(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteScript(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !canWrite
|
||||
}
|
||||
])
|
||||
]}
|
||||
},
|
||||
type: dlt,
|
||||
disabled: !canWrite
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</Row>
|
||||
|
||||
@@ -302,7 +302,10 @@
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if !loading && filteredItems?.length}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10" />
|
||||
{/if}
|
||||
{#if !loading}
|
||||
<div class="absolute -bottom-2 right-0 bg-white/90">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Show archived' }} /></div
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UserExt {
|
||||
groups: string[]
|
||||
pgroups: string[]
|
||||
folders: string[]
|
||||
folders_owners: string[]
|
||||
}
|
||||
|
||||
let persistedWorkspace = browser && localStorage.getItem('workspace')
|
||||
|
||||
+23
-17
@@ -1,14 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { goto } from '$app/navigation'
|
||||
import {
|
||||
AppService,
|
||||
type Flow,
|
||||
FlowService,
|
||||
Script,
|
||||
ScriptService,
|
||||
type User,
|
||||
UserService
|
||||
} from '$lib/gen'
|
||||
import { AppService, type Flow, FlowService, Script, ScriptService, type User } from '$lib/gen'
|
||||
import { toast } from '@zerodevx/svelte-toast'
|
||||
import type { Schema, SupportedLanguage } from './common'
|
||||
import { hubScripts, type UserExt, workspaceStore } from './stores'
|
||||
@@ -189,16 +181,27 @@ export function removeItemAll<T>(arr: T[], value: T) {
|
||||
return arr
|
||||
}
|
||||
|
||||
export async function isOwner(path: string, user: UserExt, workspace: string): Promise<boolean> {
|
||||
if (user.is_admin && (workspace == 'starter' || workspace == 'admin') && user.is_super_admin) {
|
||||
return true
|
||||
} else if (workspace == 'starter' || workspace == 'admin') {
|
||||
export function isOwner(
|
||||
path: string,
|
||||
user: UserExt | undefined,
|
||||
workspace: string | undefined
|
||||
): boolean {
|
||||
if (!user || !workspace) {
|
||||
return false
|
||||
}
|
||||
if (user.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
if (workspace == 'admin') {
|
||||
return false
|
||||
} else if (user.is_admin) {
|
||||
return true
|
||||
} else if (path.startsWith('u/' + user.username + '/')) {
|
||||
return true
|
||||
} else if (path.startsWith('f/')) {
|
||||
return user.folders_owners.some((x) => path.startsWith('f/' + x + '/'))
|
||||
} else {
|
||||
return await UserService.isOwnerOfPath({
|
||||
path: path,
|
||||
workspace: workspace
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +230,9 @@ export function canWrite(
|
||||
extra_perms: Record<string, boolean>,
|
||||
user?: UserExt
|
||||
): boolean {
|
||||
if (user?.is_admin || user?.is_super_admin) {
|
||||
return true
|
||||
}
|
||||
let keys = Object.keys(extra_perms)
|
||||
if (!user) {
|
||||
return false
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
import { goto } from '$app/navigation'
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { Badge, Button, Skeleton } from '$lib/components/common'
|
||||
import { Alert, Badge, Button, Skeleton } from '$lib/components/common'
|
||||
import CronInput from '$lib/components/CronInput.svelte'
|
||||
import FlowViewer from '$lib/components/FlowViewer.svelte'
|
||||
import JobArgs from '$lib/components/JobArgs.svelte'
|
||||
@@ -75,7 +75,11 @@
|
||||
}
|
||||
|
||||
async function archiveFlow(): Promise<void> {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
await FlowService.archiveFlowByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path,
|
||||
requestBody: { archived: !flow?.archived }
|
||||
})
|
||||
loadFlow()
|
||||
}
|
||||
|
||||
@@ -235,10 +239,8 @@
|
||||
>
|
||||
|
||||
{#if flow.archived}
|
||||
<div class="bg-red-100 border-l-4 border-red-500 text-orange-700 p-4" role="alert">
|
||||
<p class="font-bold">Archived</p>
|
||||
<p>This flow was archived</p>
|
||||
</div>
|
||||
<div class="mt-2" />
|
||||
<Alert type="error" title="Archived">This flow was archived</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 flex-wrap mt-2">
|
||||
@@ -358,7 +360,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<div>
|
||||
<div class="flex">
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
@@ -424,9 +426,9 @@
|
||||
color="red"
|
||||
size="md"
|
||||
startIcon={{ icon: faArchive }}
|
||||
disabled={flow.archived || !can_write}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Archive
|
||||
{flow.archived ? 'Unarchive' : 'Archive'}
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => flow?.path && deleteFlow()}
|
||||
@@ -434,7 +436,7 @@
|
||||
color="red"
|
||||
size="md"
|
||||
startIcon={{ icon: faTrash }}
|
||||
disabled={flow.archived || !can_write}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = (await FolderService.listFolders({ workspace: $workspaceStore! })).map((x) => {
|
||||
return { canWrite: canWrite(x.name, x.extra_perms ?? {}, $userStore), ...x }
|
||||
return { canWrite: canWrite('f/' + x.name, x.extra_perms ?? {}, $userStore), ...x }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
|
||||
let shareModal: ShareModal
|
||||
|
||||
|
||||
$: loading = !script
|
||||
$: if ($workspaceStore) {
|
||||
loadScript($page.params.hash)
|
||||
@@ -89,6 +88,21 @@
|
||||
loadScript(hash)
|
||||
}
|
||||
|
||||
async function unarchiveScript(hash: string): Promise<void> {
|
||||
const r = await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash })
|
||||
const ns = await ScriptService.createScript({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
...r,
|
||||
parent_hash: hash,
|
||||
lock: r.lock?.split('\n')
|
||||
}
|
||||
})
|
||||
sendUserToast(`Unarchived script`)
|
||||
loadScript(ns)
|
||||
goto(`/scripts/get/${ns}`)
|
||||
}
|
||||
|
||||
async function syncer(): Promise<void> {
|
||||
if (script?.hash) {
|
||||
const status = await ScriptService.getScriptDeploymentStatus({
|
||||
@@ -119,7 +133,9 @@
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
}).catch((_) => console.error('this script has no non-archived version'))
|
||||
topHash = script_by_path?.hash
|
||||
if (script_by_path?.hash != script.hash) {
|
||||
topHash = script_by_path?.hash
|
||||
}
|
||||
} else {
|
||||
topHash = undefined
|
||||
}
|
||||
@@ -346,6 +362,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if topHash}
|
||||
<div class="mt-2" />
|
||||
<Alert type="warning" title="Not HEAD">
|
||||
This hash is not HEAD (latest non-archived version at this path) :
|
||||
<a href="/scripts/get/{topHash}?workspace={$workspaceStore}"
|
||||
@@ -354,7 +371,7 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if script.archived && !topHash}
|
||||
<Alert type="error" title="Archived">This version was archived</Alert>
|
||||
<Alert type="error" title="Archived">This path was archived</Alert>
|
||||
{/if}
|
||||
{#if script.deleted}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4" role="alert">
|
||||
@@ -493,14 +510,16 @@
|
||||
</div>
|
||||
</TabContent>
|
||||
{/each}
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
|
||||
>
|
||||
See example curl command
|
||||
</Button>
|
||||
<div class="flex">
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewWebhookCommand ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewWebhookCommand = !viewWebhookCommand)}
|
||||
>
|
||||
See example curl command
|
||||
</Button>
|
||||
</div>
|
||||
{#if viewWebhookCommand}
|
||||
<div transition:slide|local class="px-4">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
@@ -534,17 +553,31 @@
|
||||
</Button>
|
||||
<span slot="text">require to be admin</span>
|
||||
</Popover>
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
script?.hash && archiveScript(script.hash)
|
||||
}}
|
||||
color="red"
|
||||
variant="border"
|
||||
startIcon={{ icon: faArchive }}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{#if script.archived}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
script?.hash && unarchiveScript(script.hash)
|
||||
}}
|
||||
color="red"
|
||||
variant="border"
|
||||
startIcon={{ icon: faArchive }}
|
||||
>
|
||||
Unarchive
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
script?.hash && archiveScript(script.hash)
|
||||
}}
|
||||
color="red"
|
||||
variant="border"
|
||||
startIcon={{ icon: faArchive }}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
emptySchema,
|
||||
emptyString,
|
||||
getModifierKey,
|
||||
sendUserToast,
|
||||
truncateHash
|
||||
sendUserToast
|
||||
} from '$lib/utils'
|
||||
import { faEye, faPen, faPlay } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
@@ -48,7 +47,9 @@
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
}).catch((_) => console.error('this script has no non-archived version'))
|
||||
topHash = script_by_path?.hash
|
||||
if (script_by_path?.hash != script.hash) {
|
||||
topHash = script_by_path?.hash
|
||||
}
|
||||
} else {
|
||||
topHash = undefined
|
||||
}
|
||||
@@ -120,10 +121,15 @@
|
||||
{#if script}
|
||||
<div class="flex flex-col justify-between gap-4 mb-6">
|
||||
{#if topHash}
|
||||
<div class="mt-2" />
|
||||
<Alert type="warning" title="Not HEAD">
|
||||
This hash is not HEAD (latest non-archived version at this path) :
|
||||
<a href="/scripts/run/{topHash}">Go to the HEAD of this path</a>
|
||||
</Alert>
|
||||
{:else if script.archived}
|
||||
<div class="mt-2" />
|
||||
|
||||
<Alert type="error" title="Archived">This path was archived</Alert>
|
||||
{/if}
|
||||
<div class="w-full">
|
||||
<div class="flex flex-col mt-6 mb-2 w-full">
|
||||
@@ -167,20 +173,17 @@
|
||||
{defaultIfEmptyString(script.summary, script.path)}
|
||||
</h1>
|
||||
{#if !emptyString(script.summary)}
|
||||
<h2 class="font-bold pb-4">{script.path}</h2>
|
||||
<h2 class="font-normal text-gray-500 pb-2">{script.path}</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">
|
||||
<span class="text-xs text-gray-500">
|
||||
{#if script}
|
||||
Edited {displayDaysAgo(script.created_at || '')} by {script.created_by ||
|
||||
'unknown'}
|
||||
{/if}
|
||||
</span>
|
||||
<Badge color="dark-gray">
|
||||
{truncateHash(script?.hash ?? '')}
|
||||
</Badge>
|
||||
{#if script?.is_template}
|
||||
<Badge color="blue">Template</Badge>
|
||||
{/if}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { ContextualVariable, ListableVariable } from '$lib/gen'
|
||||
import { OauthService, VariableService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { canWrite, sendUserToast, truncate } from '$lib/utils'
|
||||
import { canWrite, isOwner, sendUserToast, truncate } from '$lib/utils'
|
||||
import {
|
||||
faChain,
|
||||
faCircle,
|
||||
@@ -280,55 +280,58 @@
|
||||
<td>
|
||||
<Dropdown
|
||||
placement="bottom-end"
|
||||
dropdownItems={[
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
action: () => variableEditor.editVariable(path),
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteVariable(path, account)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
dropdownItems={() => {
|
||||
let owner = isOwner(path, $userStore, $workspaceStore)
|
||||
return [
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
action: () => variableEditor.editVariable(path),
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: faTrash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteVariable(path, account)
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled: !canWrite
|
||||
},
|
||||
{
|
||||
displayName: canWrite ? 'Share' : 'See Permissions',
|
||||
action: () => {
|
||||
shareModal.openDrawer(path, 'variable')
|
||||
},
|
||||
icon: faShare
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: faRefresh,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadVariables()
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteVariable(path, account)
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
},
|
||||
disabled: !owner
|
||||
},
|
||||
{
|
||||
displayName: owner ? 'Share' : 'See Permissions',
|
||||
action: () => {
|
||||
shareModal.openDrawer(path, 'variable')
|
||||
},
|
||||
icon: faShare
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: faRefresh,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadVariables()
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "esnext",
|
||||
"lib": ["esnext", "DOM"],
|
||||
"lib": ["es2021", "DOM"],
|
||||
"target": "esnext",
|
||||
"preserveValueImports": true,
|
||||
"outDir": "build",
|
||||
|
||||
Reference in New Issue
Block a user