feat: Approval step optionally require logged-in user (#2462)

* backend auth user validation

* Approval page suggesting login

* BE impl to support groups

* Add FE for setting suspend on flow step

* handle admin as a specific case

* restrict feature to entperprise only

* group names as static input transforms

* Suspend settings with tabs

* Update enterprise edition check

* cleanup

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guillaume Bouvignies
2023-10-20 00:20:20 +02:00
committed by GitHub
co-authored by Ruben Fiszel
parent 3ed29ae2dd
commit 5f5604a2aa
12 changed files with 437 additions and 192 deletions
+6 -1
View File
@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
version: 1.183.0
version: 1.184.0
title: Windmill API
contact:
name: Windmill Team
@@ -3495,6 +3495,11 @@ paths:
properties:
schema:
type: object
user_auth_required:
type: boolean
user_groups_required:
oneOf: *ref_23
discriminator: *ref_24
retry:
type: object
properties: &ref_173
+183 -75
View File
@@ -6,6 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
use serde_json::value::RawValue;
use std::collections::HashMap;
use crate::db::ApiAuthed;
@@ -40,7 +41,7 @@ use windmill_common::{
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flows::FlowValue,
flows::{FlowValue, InputTransform},
jobs::{script_path_to_payload, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
scripts::{Script, ScriptHash, ScriptLang},
@@ -342,19 +343,24 @@ async fn get_job(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let cjob_option = sqlx::query("SELECT
let job = get_job_internal(&db, w_id.as_str(), id).await?;
Ok(Json(job).into_response())
}
async fn get_job_internal(db: &DB, workspace_id: &str, job_id: Uuid) -> error::Result<Job> {
let cjob_maybe = sqlx::query_as::<_, CompletedJob>("SELECT
id, workspace_id, parent_job, created_by, created_at, duration_ms, success, script_hash, script_path,
CASE WHEN pg_column_size(args) < 2000000 THEN args ELSE '{\"reason\": \"WINDMILL_TOO_BIG\"}'::jsonb END as args, CASE WHEN pg_column_size(result) < 2000000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result, logs, deleted, raw_code, canceled, canceled_by, canceled_reason, job_kind, env_id,
schedule_path, permissioned_as, flow_status, raw_flow, is_flow_step, language, started_at, is_skipped,
raw_lock, email, visible_to_owner, mem_peak, tag
FROM completed_job WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(&w_id)
.fetch_optional(&db)
.await?;
if let Some(job) = cjob_option {
let job = Job::CompletedJob(CompletedJob::from_row(&job)?);
Ok(Json(job).into_response())
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
.await?
.map(Job::CompletedJob);
if let Some(cjob) = cjob_maybe {
Ok(cjob)
} else {
let job_o = sqlx::query_as::<_, QueuedJob>(
"SELECT id, workspace_id, parent_job, created_by, created_at, started_at, scheduled_for, running,
@@ -364,13 +370,13 @@ async fn get_job(
root_job, leaf_jobs, tag, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id, cache_ttl
FROM queue WHERE id = $1 AND workspace_id = $2",
)
.bind(id)
.bind(&w_id)
.fetch_optional(&db)
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
.await?
.map(Job::QueuedJob);
let job: Job<'_> = not_found_if_none(job_o, "Job", id.to_string())?;
Ok(Json(job).into_response())
let job: Job = not_found_if_none(job_o, "Job", job_id.to_string())?;
Ok(job)
}
}
@@ -391,7 +397,7 @@ async fn get_job_logs(
}
#[derive(Debug, sqlx::FromRow, Serialize)]
pub struct CompletedJob<'rows> {
pub struct CompletedJob {
pub workspace_id: String,
pub id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -405,9 +411,9 @@ pub struct CompletedJob<'rows> {
pub script_hash: Option<ScriptHash>,
#[serde(skip_serializing_if = "Option::is_none")]
pub script_path: Option<String>,
pub args: Option<&'rows JsonRawValue>,
#[serde(skip_serializing_if = "Option::is_none", borrow)]
pub result: Option<&'rows JsonRawValue>,
pub args: Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logs: Option<String>,
pub deleted: bool,
@@ -423,9 +429,9 @@ pub struct CompletedJob<'rows> {
pub schedule_path: Option<String>,
pub permissioned_as: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_status: Option<serde_json::Value>,
pub flow_status: Option<sqlx::types::Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_flow: Option<serde_json::Value>,
pub raw_flow: Option<sqlx::types::Json<Box<RawValue>>>,
pub is_flow_step: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<ScriptLang>,
@@ -437,7 +443,7 @@ pub struct CompletedJob<'rows> {
pub tag: String,
}
impl<'row> CompletedJob<'row> {
impl CompletedJob {
pub fn json_result(&self) -> Option<serde_json::Value> {
self.result
.as_ref()
@@ -489,7 +495,7 @@ pub struct ListableCompletedJob {
pub tag: String,
}
impl<'a> IntoResponse for CompletedJob<'a> {
impl<'a> IntoResponse for CompletedJob {
fn into_response(self) -> Response {
Json(self).into_response()
}
@@ -735,7 +741,7 @@ async fn list_jobs(
Path(w_id): Path<String>,
Query(pagination): Query<Pagination>,
Query(lq): Query<ListCompletedQuery>,
) -> error::JsonResult<Vec<Job<'static>>> {
) -> error::JsonResult<Vec<Job>> {
check_scopes(&authed, || format!("listjobs"))?;
let (per_page, offset) = paginate(pagination);
@@ -892,7 +898,7 @@ pub async fn resume_suspended_flow_as_owner(
}
pub async fn resume_suspended_job(
/* unauthed */
authed: Option<ApiAuthed>,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
@@ -909,7 +915,16 @@ pub async fn resume_suspended_job(
}
mac.verify_slice(hex::decode(secret)?.as_ref())
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
let flow = get_suspended_parent_flow_info(job_id, &mut tx).await?;
let parent_flow_info = get_suspended_parent_flow_info(job_id, &mut tx).await?;
let parent_flow = get_job_internal(&db, w_id.as_str(), parent_flow_info.id).await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let flow_value = parent_flow
.raw_flow()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
conditionally_require_authed_user(authed, job_id, flow_status, flow_value)?;
let exists = sqlx::query_scalar!(
r#"
@@ -925,9 +940,17 @@ pub async fn resume_suspended_job(
return Err(anyhow::anyhow!("resume request already sent").into());
}
insert_resume_job(resume_id, job_id, &flow, value, approver.approver, &mut tx).await?;
insert_resume_job(
resume_id,
job_id,
&parent_flow_info,
value,
approver.approver,
&mut tx,
)
.await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
resume_immediately_if_relevant(parent_flow_info, job_id, &mut tx).await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
@@ -1053,7 +1076,7 @@ async fn get_suspended_flow_info<'c>(
}
pub async fn cancel_suspended_job(
/* unauthed */
authed: Option<ApiAuthed>,
Extension(db): Extension<DB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
@@ -1071,12 +1094,22 @@ pub async fn cancel_suspended_job(
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
let whom = approver.approver.unwrap_or_else(|| "unknown".to_string());
let parent_flow = get_suspended_parent_flow_info(job, &mut tx).await?.id;
let parent_flow_id = get_suspended_parent_flow_info(job, &mut tx).await?.id;
let parent_flow = get_job_internal(&db, w_id.as_str(), parent_flow_id).await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let flow_value = parent_flow
.raw_flow()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
conditionally_require_authed_user(authed, job, flow_status, flow_value)?;
let (mut tx, cjob) = windmill_queue::cancel_job(
&whom,
Some("approval request disapproved".to_string()),
parent_flow,
parent_flow_id,
&w_id,
tx,
&db,
@@ -1091,23 +1124,23 @@ pub async fn cancel_suspended_job(
"jobs.disapproval",
ActionKind::Delete,
&w_id,
Some(&parent_flow.to_string()),
Some(&parent_flow_id.to_string()),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Flow {parent_flow} of job {job} cancelled"))
Ok(format!("Flow {parent_flow_id} of job {job} cancelled"))
} else {
Ok(format!(
"Flow {parent_flow} of job {job} was not cancellable"
"Flow {parent_flow_id} of job {job} was not cancellable"
))
}
}
#[derive(Serialize)]
pub struct SuspendedJobFlow<'a> {
pub job: Job<'a>,
pub struct SuspendedJobFlow {
pub job: Job,
pub approvers: Vec<Approval>,
}
@@ -1117,7 +1150,7 @@ pub struct QueryApprover {
}
pub async fn get_suspended_job_flow(
/* unauthed */
authed: Option<ApiAuthed>,
Extension(db): Extension<DB>,
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
@@ -1132,6 +1165,7 @@ pub async fn get_suspended_job_flow(
}
mac.verify_slice(hex::decode(secret)?.as_ref())
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
let flow_id = sqlx::query_scalar!(
r#"
SELECT parent_job
@@ -1149,40 +1183,24 @@ pub async fn get_suspended_job_flow(
.await?
.flatten()
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
let cjob_option =
sqlx::query("SELECT * FROM completed_job WHERE id = $1 AND workspace_id = $2")
.bind(flow_id)
.bind(&w_id)
.fetch_optional(&db)
.await?;
let mut _rows = None;
let flow_o = if let Some(job) = cjob_option {
_rows = Some(job);
Some(Job::CompletedJob(CompletedJob::from_row(
_rows.as_ref().unwrap(),
)?))
} else {
sqlx::query_as::<_, QueuedJob>(
"SELECT *
FROM queue WHERE id = $1 AND workspace_id = $2",
)
.bind(flow_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.map(Job::QueuedJob)
};
let flow = not_found_if_none(flow_o, "Parent Flow", job.to_string())?;
let flow = get_job_internal(&db, w_id.as_str(), flow_id).await?;
let flow_status = flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to deserialize the flow"))?;
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let flow_value = flow
.raw_flow()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow value in the flow job"))?;
let flow_module_status = flow_status
.modules
.iter()
.find(|p| p.job() == Some(job))
.ok_or_else(|| anyhow::anyhow!("unable to find the module"))?;
conditionally_require_authed_user(authed, job, flow_status.clone(), flow_value)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
_ => vec![],
@@ -1211,6 +1229,82 @@ pub async fn get_suspended_job_flow(
Ok(Json(SuspendedJobFlow { job: flow, approvers }).into_response())
}
fn conditionally_require_authed_user(
authed: Option<ApiAuthed>,
job: Uuid,
flow_status: FlowStatus,
flow_value: FlowValue,
) -> error::Result<()> {
let flow_module_status = flow_status
.modules
.iter()
.find(|p| p.job() == Some(job))
.ok_or_else(|| anyhow::anyhow!("unable to find the module"))?;
// Check if user is authed and fail if it's required by the flow
let raw_flow_module = flow_value
.modules
.iter()
.find(|m| m.id == flow_module_status.id())
.ok_or_else(|| anyhow::anyhow!("unable to find the module in the raw flow"))?;
let user_auth_required_for_approval = raw_flow_module
.suspend
.as_ref()
.map(|s| s.user_auth_required)
.flatten()
.unwrap_or(false);
if user_auth_required_for_approval {
#[cfg(not(feature = "enterprise"))]
return Err(Error::BadRequest(
"Approvals for logged in users is an enterprise only feature".to_string(),
));
#[cfg(feature = "enterprise")]
if authed.is_none() {
return Err(Error::NotAuthorized(
"Only logged in users can approve this flow step".to_string(),
));
}
}
if authed.is_some() && !authed.as_ref().unwrap().username.eq("admin") {
let required_groups = raw_flow_module
.suspend
.as_ref()
.map(|s| s.user_groups_required.clone())
.flatten();
if required_groups.is_none() {
return Ok(());
}
match required_groups.unwrap() {
InputTransform::Static { value } => {
let required_groups_or_empty = serde_json::from_value::<Vec<String>>(value)
.expect("Unable to deserialize group names");
if !required_groups_or_empty.is_empty() {
#[cfg(not(feature = "enterprise"))]
return Err(Error::BadRequest(
"Approvals for users in certain user groups is an enterprise only feature"
.to_string(),
));
#[cfg(feature = "enterprise")]
if true {
for required_group in required_groups_or_empty.iter() {
if authed.as_ref().unwrap().groups.contains(&required_group) {
return Ok(());
}
}
let error_msg = format!("Only users from one of the following groups are allowed to approve this workflow: {}",
required_groups_or_empty.join(", "));
return Err(Error::PermissionDenied(error_msg));
}
}
}
InputTransform::Javascript { expr: _ } => {
return Err(Error::InternalErr("Not yet implemented".to_string()))
}
}
}
Ok(())
}
pub async fn create_job_signature(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -1289,25 +1383,39 @@ pub async fn get_resume_urls(
#[derive(Serialize, Debug)]
#[serde(tag = "type")]
pub enum Job<'a> {
pub enum Job {
QueuedJob(QueuedJob),
CompletedJob(CompletedJob<'a>),
CompletedJob(CompletedJob),
}
impl<'a> Job<'a> {
impl Job {
pub fn raw_flow(&self) -> Option<FlowValue> {
let value = match self {
Job::QueuedJob(job) => job.raw_flow.clone(),
Job::CompletedJob(job) => job.raw_flow.clone(),
};
value.map(|v| serde_json::from_value(v).ok()).flatten()
match self {
Job::QueuedJob(job) => job
.raw_flow
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
Job::CompletedJob(job) => job
.raw_flow
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
}
}
pub fn flow_status(&self) -> Option<FlowStatus> {
let value = match self {
Job::QueuedJob(job) => job.flow_status.clone(),
Job::CompletedJob(job) => job.flow_status.clone(),
};
value.map(|v| serde_json::from_value(v).ok()).flatten()
match self {
Job::QueuedJob(job) => job
.flow_status
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
Job::CompletedJob(job) => job
.flow_status
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
}
}
}
@@ -1344,7 +1452,7 @@ struct UnifiedJob {
concurrency_time_window_s: Option<i32>,
}
impl<'a> From<UnifiedJob> for Job<'a> {
impl<'a> From<UnifiedJob> for Job {
fn from(uj: UnifiedJob) -> Self {
match uj.typ.as_ref() {
"CompletedJob" => Job::CompletedJob(CompletedJob {
+2
View File
@@ -37,6 +37,8 @@ pub enum Error {
NotFound(String),
#[error("Not authorized: {0}")]
NotAuthorized(String),
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error("Require Admin privileges for {0}")]
RequireAdmin(String),
#[error("{0}")]
+1 -1
View File
@@ -24,7 +24,7 @@ pub fn is_retry_default(v: &RetryStatus) -> bool {
v.fail_count == 0 && v.failed_jobs.is_empty()
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FlowStatus {
pub step: i32,
pub modules: Vec<FlowStatusModule>,
+5
View File
@@ -67,6 +67,7 @@ pub struct NewFlow {
pub schema: Option<Schema>,
pub draft_only: Option<bool>,
pub tag: Option<String>,
pub ws_error_handler_muted: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
@@ -169,6 +170,10 @@ pub struct Suspend {
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resume_form: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_auth_required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_groups_required: Option<InputTransform>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
+4 -4
View File
@@ -65,9 +65,9 @@ pub struct QueuedJob {
pub schedule_path: Option<String>,
pub permissioned_as: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_status: Option<serde_json::Value>,
pub flow_status: Option<Json<Box<RawValue>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_flow: Option<serde_json::Value>,
pub raw_flow: Option<Json<Box<RawValue>>>,
pub is_flow_step: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<ScriptLang>,
@@ -127,13 +127,13 @@ impl QueuedJob {
pub fn parse_raw_flow(&self) -> Option<FlowValue> {
self.raw_flow
.as_ref()
.and_then(|v| serde_json::from_value::<FlowValue>(v.clone()).ok())
.and_then(|v| serde_json::from_str::<FlowValue>((**v).get()).ok())
}
pub fn parse_flow_status(&self) -> Option<FlowStatus> {
self.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|v| serde_json::from_str::<FlowStatus>((**v).get()).ok())
}
}
+2 -2
View File
@@ -343,8 +343,8 @@ pub async fn add_completed_job<
queued_job.job_kind.clone() as JobKind,
queued_job.schedule_path,
queued_job.permissioned_as,
queued_job.flow_status,
queued_job.raw_flow,
&queued_job.flow_status as &Option<Json<Box<RawValue>>>,
&queued_job.raw_flow as &Option<Json<Box<RawValue>>>,
queued_job.is_flow_step,
skipped,
queued_job.language.clone() as Option<ScriptLang>,
+1 -1
View File
@@ -2365,7 +2365,7 @@ async fn handle_flow_dependency_job(
"Flow Dependency requires raw flow".to_owned(),
))
})?;
let mut flow = serde_json::from_value::<FlowValue>(raw_flow).map_err(to_anyhow)?;
let mut flow = serde_json::from_str::<FlowValue>((*raw_flow.0).get()).map_err(to_anyhow)?;
flow.modules = lock_modules(
flow.modules,
+5 -4
View File
@@ -990,11 +990,12 @@ pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
.as_ref()
.ok_or_else(|| Error::InternalErr(format!("requiring a raw flow value")))?
.to_owned();
let flow = serde_json::from_value::<FlowValue>(value)?;
let flow = serde_json::from_str::<FlowValue>((*value.0).get())?;
let status: FlowStatus =
serde_json::from_value::<FlowStatus>(flow_job.flow_status.clone().unwrap_or_default())
.with_context(|| format!("parse flow status {}", flow_job.id))?;
let status: FlowStatus = serde_json::from_str::<FlowStatus>(
(*flow_job.flow_status.clone().unwrap_or_default().0).get(),
)
.with_context(|| format!("parse flow status {}", flow_job.id))?;
tracing::debug!("handle_flow: {:#?}", flow_job.flow_status);
push_next_flow_job(
@@ -4,13 +4,42 @@
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { FlowModule } from '$lib/gen'
import { emptySchema } from '$lib/utils'
import { Alert, Tab, Tabs } from '$lib/components/common'
import { GroupService, type FlowModule } from '$lib/gen'
import { emptySchema, emptyString } from '$lib/utils'
import { enterpriseLicense, workspaceStore } from '$lib/stores.js'
import { SecondsInput } from '../../common'
import Multiselect from 'svelte-multiselect'
export let flowModule: FlowModule
export let allUserGroups: string[] = []
let selectedUserGroups: string[] | undefined
let suspendTabSelected: 'core' | 'form' | 'permissions' = 'core'
$: isSuspendEnabled = Boolean(flowModule.suspend)
async function loadGroups(): Promise<void> {
allUserGroups = await GroupService.listGroupNames({ workspace: $workspaceStore! })
}
$: {
if ($workspaceStore) {
loadGroups()
}
switch (flowModule.suspend?.user_groups_required?.type) {
case 'static':
if (flowModule.suspend?.user_groups_required.value === undefined) {
selectedUserGroups = []
} else {
selectedUserGroups = flowModule.suspend?.user_groups_required.value
}
break
case 'javascript':
console.warn('javascript input transform not supported yet')
break
}
}
</script>
<h2 class="pb-4">
@@ -21,6 +50,7 @@
used flexibly for other purpose.
</Tooltip>
</h2>
<Toggle
checked={isSuspendEnabled}
on:change={() => {
@@ -37,25 +67,93 @@
right: 'Suspend flow execution until events/approvals received'
}}
/>
<div class="mb-4">
<span class="text-xs font-bold">Number of approvals/events required for resuming flow</span>
{#if flowModule.suspend}
<input bind:value={flowModule.suspend.required_events} type="number" min="1" placeholder="1" />
{:else}
<input type="number" disabled />
{/if}
<div class="overflow-x-auto scrollbar-hidden">
<Tabs bind:selected={suspendTabSelected}>
<Tab size="xs" value="core" disabled={!isSuspendEnabled}>
<div class="flex gap-2 items-center my-1">Core</div>
</Tab>
<Tab size="xs" value="form" disabled={!isSuspendEnabled}>
<div class="flex gap-2 items-center my-1">Form</div>
</Tab>
<Tab size="xs" value="permissions" disabled={!isSuspendEnabled}>
<div class="flex gap-2 items-center my-1">Permissions</div>
</Tab>
</Tabs>
</div>
<span class="text-xs font-bold">Timeout</span>
{#if suspendTabSelected === 'core'}
<div class="flex flex-col mt-4 gap-4">
<span class="text-xs font-bold">Number of approvals/events required for resuming flow</span>
{#if flowModule.suspend}
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
{:else}
<SecondsInput disabled />
{/if}
{#if flowModule.suspend}
<div class="mt-4" />
<div class="flex gap-4">
{#if flowModule.suspend}
<input
bind:value={flowModule.suspend.required_events}
type="number"
min="1"
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
<span class="text-xs font-bold">Timeout</span>
{#if flowModule.suspend}
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
{:else}
<SecondsInput disabled />
{/if}
</div>
{:else if suspendTabSelected === 'permissions'}
<div class="flex flex-col mt-4 gap-4">
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Editing permissions is only available in enterprise version" />
{/if}
{#if flowModule.suspend}
<div class="flex flex-col gap-2">
<Toggle
disabled={emptyString($enterpriseLicense)}
checked={Boolean(flowModule.suspend.user_auth_required)}
options={{
right: 'Require approvers to be logged in'
}}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.user_auth_required = e.detail
}
}}
/>
<div class="mb-4" />
<span class="text-xs font-bold"
>Require approvers to be members of one of the following user groups (leave empty for any)
</span>
{#if allUserGroups.length !== 0}
<Multiselect
disabled={emptyString($enterpriseLicense) || !flowModule.suspend.user_auth_required}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.user_groups_required = {
value: selectedUserGroups,
type: 'static'
}
}
}}
bind:selected={selectedUserGroups}
options={allUserGroups}
selectedOptionsDraggable={false}
placeholder="Authorized user groups"
ulOptionsClass={'!bg-surface-secondary'}
/>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex flex-col mt-4 gap-4">
{#if flowModule.suspend}
<Toggle
checked={Boolean(flowModule.suspend.resume_form)}
options={{
@@ -89,12 +187,10 @@
></pre
>
</Slider></div
></div
>
<div class="mt-2" />
{/if}
{#if flowModule.suspend?.resume_form}
<SchemaEditor bind:schema={flowModule.suspend.resume_form.schema} />
{/if}
</div>
>
{/if}
{#if flowModule.suspend?.resume_form}
<SchemaEditor bind:schema={flowModule.suspend.resume_form.schema} />
{/if}
</div>
{/if}
@@ -11,8 +11,10 @@
import FlowGraph from '$lib/components/graph/FlowGraph.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { workspaceStore } from '$lib/stores'
import { LogIn, AlertTriangle } from 'lucide-svelte'
$workspaceStore = $page.params.workspace
let rd = $page.url.href.replace($page.url.origin, '')
let job: Job | undefined = undefined
let currentApprovers: { resume_id: number; approver: string }[] = []
@@ -114,91 +116,112 @@
<CenteredModal title="Approval for resuming of flow" disableLogo>
{#if error}
<p class="text-red-400 text-lg">{error}</p>
{/if}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
<div class="w-full">
<h2 class="mt-4">Current approvers</h2>
<p class="text-xs italic"
>Each approver can only approve once and cannot change his approver name set by the approval
sender</p
>
<div class="my-4">
{#if currentApprovers.length > 0}
<ul>
{#each currentApprovers as approver}
<li
><b
>{approver.approver}<Tooltip>Unique id of approval: {approver.resume_id}</Tooltip
></b
></li
>
{/each}
</ul>
{:else}
<p class="text-sm"
>No current approvers for this step (approval steps can require more than one approval)</p
>
<div class="space-y-6">
{#if error.startsWith('Not authorized:')}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle />
<p class="text-lg">Not Authorized</p>
</div>
<p class="text-sm">{error.replace(/^(Not authorized: )/, '')}</p>
<Button href={`/user/login?${rd ? 'rd=' + encodeURIComponent(rd) : ''}`}>
Sign in
<LogIn class="w-8" size={18} />
</Button>
{:else}
<div class="flex flex-row gap-4 justify-center">
<AlertTriangle class="" />
<p class="text-lg">Permission denied</p>
</div>
<p class="text-sm">{error.replace(/^(Permission denied: )/, '')}</p>
{/if}
</div>
{:else}
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
<div class="w-full">
<h2 class="mt-4">Current approvers</h2>
<p class="text-xs italic"
>Each approver can only approve once and cannot change his approver name set by the
approval sender</p
>
<div class="my-4">
{#if currentApprovers.length > 0}
<ul>
{#each currentApprovers as approver}
<li
><b
>{approver.approver}<Tooltip
>Unique id of approval: {approver.resume_id}</Tooltip
></b
></li
>
{/each}
</ul>
{:else}
<p class="text-sm"
>No current approvers for this step (approval steps can require more than one
approval)</p
>
{/if}
</div>
</div>
<div class="w-full">
{#if job && job.raw_flow}
<FlowMetadata {job} />
{/if}
</div>
</div>
<div class="w-full">
{#if job && job.raw_flow}
<FlowMetadata {job} />
<h2 class="mt-4 mb-2">Flow arguments</h2>
<JobArgs args={job?.args} />
<div class="mt-8">
{#if approver}
<p>Dis/approving as: <b>{approver}</b></p>
{/if}
</div>
</div>
<h2 class="mt-4 mb-2">Flow arguments</h2>
<JobArgs args={job?.args} />
<div class="mt-8">
{#if approver}
<p>Dis/approving as: <b>{approver}</b></p>
{#if completed}
<div class="my-2"
><p><b>The flow is not running anymore. You cannot cancel or resume it.</b></p></div
>
{:else if alreadyResumed}
<div class="my-2"><p><b>You have already approved this flow to be resumed</b></p></div>
{/if}
</div>
{#if completed}
<div class="my-2"
><p><b>The flow is not running anymore. You cannot cancel or resume it.</b></p></div
>
{:else if alreadyResumed}
<div class="my-2"><p><b>You have already approved this flow to be resumed</b></p></div>
{/if}
{#if schema}
<SchemaForm bind:isValid={valid} {schema} bind:args={payload} />
{/if}
{#if schema}
<SchemaForm bind:isValid={valid} {schema} bind:args={payload} />
{/if}
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap mt-2">
<Button
btnClasses="grow"
color="red"
on:click|once={cancel}
size="md"
disabled={completed || alreadyResumed}>Disapprove/Cancel</Button
>
<Button
btnClasses="grow"
color="green"
on:click|once={resume}
size="md"
disabled={completed || alreadyResumed || !valid}>Approve/Resume</Button
>
</div>
<div class="mt-4 flex flex-row flex-wrap justify-between"
><a href="https://windmill.dev">Learn more about Windmill</a>
<a target="_blank" rel="noreferrer" href="/run/{job?.id}?workspace={job?.workspace_id}"
>Flow run details (require auth)</a
>
</div>
{#if job && job.raw_flow}
<h2 class="mt-10">Flow details</h2>
<div class="border border-gray-700">
<FlowGraph
modules={job.raw_flow?.modules}
failureModule={job.raw_flow?.failure_module}
notSelectable
/>
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap mt-2">
<Button
btnClasses="grow"
color="red"
on:click|once={cancel}
size="md"
disabled={completed || alreadyResumed}>Disapprove/Cancel</Button
>
<Button
btnClasses="grow"
color="green"
on:click|once={resume}
size="md"
disabled={completed || alreadyResumed || !valid}>Approve/Resume</Button
>
</div>
<div class="mt-4 flex flex-row flex-wrap justify-between"
><a href="https://windmill.dev">Learn more about Windmill</a>
<a target="_blank" rel="noreferrer" href="/run/{job?.id}?workspace={job?.workspace_id}"
>Flow run details (require auth)</a
>
</div>
{#if job && job.raw_flow}
<h2 class="mt-10">Flow details</h2>
<div class="border border-gray-700">
<FlowGraph
modules={job.raw_flow?.modules}
failureModule={job.raw_flow?.failure_module}
notSelectable
/>
</div>
{/if}
{/if}
</CenteredModal>
+5
View File
@@ -119,6 +119,11 @@ components:
properties:
schema:
type: object
user_auth_required:
type: boolean
user_groups_required:
$ref: "#/components/schemas/InputTransform"
retry:
$ref: "#/components/schemas/Retry"
required: