mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 16:05:00 +00:00
feat: add a minimal skin for the approval page and slack/teams (#11061)
* feat: add an approval skin to the approval page and slack/teams messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: point ee-repo-ref at the teams approval skin commit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: resolve the approval skin from the step awaiting approval Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: shorten the slack approval message to fit the button value limit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: rename skins to detailed/minimal and keep long slack messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: title the minimal approval page from the step and flow summaries Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: let wait_for_approval set the description approvers see Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a finished workflow's approval description, still gated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * fix: keep a login-required approval locked after the run moves on Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * feat: hide the windmill version on the approval page Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KK2Ye4PizykReZVuMEm3m9 * chore: update ee-repo-ref to e92abc9d1fba3ba898640df0cfeb0af8a50849b4 This commit updates the EE repository reference after PR #786 was merged in windmill-ee-private. Previous ee-repo-ref: bf1766ff49458f62d3f11746f1a06893ae3c2325 New ee-repo-ref: e92abc9d1fba3ba898640df0cfeb0af8a50849b4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5
windmill-internal-app[bot]
parent
e62bfdcd8c
commit
63cb46d7bb
@@ -17,6 +17,7 @@ use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::cache;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::flows::{ApprovalSkin, Suspend};
|
||||
use windmill_common::jobs::JobKind;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
|
||||
@@ -94,6 +95,17 @@ pub struct ApprovalFormDetails {
|
||||
pub message_str: String,
|
||||
pub urls: ResumeUrls,
|
||||
pub schema: Option<ResumeFormRow>,
|
||||
pub skin: ApprovalSkin,
|
||||
}
|
||||
|
||||
/// The suspended step an approval message is about, and the flow run it belongs to.
|
||||
struct ApprovalStep {
|
||||
created_by: String,
|
||||
created_at: chrono::NaiveDateTime,
|
||||
script_path: Option<String>,
|
||||
parent_job_id: Option<Uuid>,
|
||||
args: Option<sqlx::types::Json<Box<RawValue>>>,
|
||||
suspend: Option<Suspend>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -205,6 +217,90 @@ pub async fn get_approval_form_details(
|
||||
|
||||
tracing::debug!("Job ID: {:?}", job_id);
|
||||
|
||||
let ApprovalStep { created_by, created_at, script_path, parent_job_id, args, suspend } =
|
||||
fetch_approval_step(&db, w_id, job_id, flow_step_id).await?;
|
||||
|
||||
let schema = suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
});
|
||||
let skin = suspend.and_then(|s| s.skin).unwrap_or_default();
|
||||
|
||||
let bold_format = match format {
|
||||
MessageFormat::Slack => "*{}*",
|
||||
MessageFormat::Teams => "**{}**",
|
||||
};
|
||||
|
||||
let message_str = match skin {
|
||||
ApprovalSkin::Detailed => {
|
||||
let args_str = args.map_or("None".to_string(), |a| {
|
||||
serde_json::from_str::<serde_json::Value>(a.get())
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| a.get().to_string())
|
||||
});
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}:\n```\n{args_str}\n```\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
bold_format.replace("{}", "Script path"),
|
||||
bold_format.replace("{}", "Args"),
|
||||
bold_format.replace("{}", "Flow ID")
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
message_str
|
||||
}
|
||||
ApprovalSkin::Minimal => format!(
|
||||
"{}\n\n{}: {created_by}",
|
||||
message.unwrap_or("Your approval is requested."),
|
||||
bold_format.replace("{}", "Requested by"),
|
||||
),
|
||||
};
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
Ok(ApprovalFormDetails { message_str, urls, schema, skin })
|
||||
}
|
||||
|
||||
/// The skin of the approval step `flow_step_id` of the flow running `job_id`. Falls back to
|
||||
/// the detailed skin when the step cannot be resolved, so a message is still sent.
|
||||
/// Reads through the unrestricted pool without an authorization check of its own: only the
|
||||
/// skin, which is not sensitive, leaves this function.
|
||||
pub(crate) async fn get_approval_step_skin(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
flow_step_id: &str,
|
||||
) -> ApprovalSkin {
|
||||
match fetch_approval_step(db, w_id, job_id, Some(flow_step_id)).await {
|
||||
Ok(step) => step.suspend.and_then(|s| s.skin).unwrap_or_default(),
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not resolve approval step {flow_step_id} of job {job_id}: {e}");
|
||||
ApprovalSkin::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_approval_step(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
flow_step_id: Option<&str>,
|
||||
) -> Result<ApprovalStep, Error> {
|
||||
// TODO: do we have a helper function for this?
|
||||
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
|
||||
"WITH job_info AS (
|
||||
@@ -240,17 +336,17 @@ pub async fn get_approval_form_details(
|
||||
job_id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?
|
||||
.ok_or_else(|| Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
|
||||
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
|
||||
|
||||
let flow_data = match cache::job::fetch_flow(&db, &job_kind, script_hash).await {
|
||||
let flow_data = match cache::job::fetch_flow(db, &job_kind, script_hash).await {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if let Some(parent_job_id) = parent_job_id.as_ref() {
|
||||
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
|
||||
cache::job::fetch_preview_flow(db, parent_job_id, raw_flow).await?
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
|
||||
@@ -265,49 +361,12 @@ pub async fn get_approval_form_details(
|
||||
|
||||
tracing::debug!("Module: {:#?}", module);
|
||||
|
||||
let schema = module.and_then(|module| {
|
||||
module.suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
})
|
||||
});
|
||||
|
||||
let args_str = args.map_or("None".to_string(), |a| {
|
||||
serde_json::from_str::<serde_json::Value>(a.get())
|
||||
.ok()
|
||||
.and_then(|v| serde_json::to_string_pretty(&v).ok())
|
||||
.unwrap_or_else(|| a.get().to_string())
|
||||
});
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let bold_format = match format {
|
||||
MessageFormat::Slack => "*{}*",
|
||||
MessageFormat::Teams => "**{}**",
|
||||
};
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}:\n```\n{args_str}\n```\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
bold_format.replace("{}", "Script path"),
|
||||
bold_format.replace("{}", "Args"),
|
||||
bold_format.replace("{}", "Flow ID")
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
Ok(ApprovalFormDetails { message_str, urls, schema })
|
||||
Ok(ApprovalStep {
|
||||
created_by,
|
||||
created_at,
|
||||
script_path,
|
||||
parent_job_id,
|
||||
args,
|
||||
suspend: module.and_then(|m| m.suspend.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,7 +107,10 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
flows::{
|
||||
add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue,
|
||||
Suspend,
|
||||
},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
query_builders,
|
||||
@@ -4858,6 +4861,11 @@ struct ApprovalInfo {
|
||||
user_auth_required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hide_cancel: Option<bool>,
|
||||
skin: ApprovalSkin,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
step_summary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
flow_summary: Option<String>,
|
||||
approvers: Vec<Approval>,
|
||||
/// Share-read-link token for the flow, minted only for callers allowed to view this
|
||||
/// approval. Lets an authenticated workspace-member approver open the run details of
|
||||
@@ -4911,6 +4919,48 @@ fn can_approve_step(
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest approval step the run has passed: a step before the current `step` that ran
|
||||
/// rather than being skipped. Steps from `step` on don't count, because while an approval is
|
||||
/// pending the step after it already holds the `WaitingForEvents` status.
|
||||
fn last_reached_approval_step<'a>(
|
||||
flow: &'a FlowValue,
|
||||
status: &FlowStatus,
|
||||
) -> Option<&'a FlowModule> {
|
||||
flow.modules
|
||||
.iter()
|
||||
.zip(status.modules.iter())
|
||||
.take(usize::try_from(status.step).unwrap_or(0))
|
||||
.rev()
|
||||
.filter(|(_, m)| matches!(m, FlowStatusModule::Success { skipped: false, .. }))
|
||||
.map(|(module, _)| module)
|
||||
.find(|module| module.suspend.is_some())
|
||||
}
|
||||
|
||||
/// The approval conditions a step's own settings give, as the worker records them when the step
|
||||
/// suspends. The worker drops them from the run once the step is approved, so a run that has
|
||||
/// moved on is gated by these. Groups computed by an expression can't be re-evaluated outside
|
||||
/// the run, so such a step falls back to any signed-in user.
|
||||
fn approval_conditions_from_settings(suspend: &Suspend) -> Option<ApprovalConditions> {
|
||||
let user_auth_required = suspend.user_auth_required.unwrap_or(false);
|
||||
let self_approval_disabled = suspend.self_approval_disabled.unwrap_or(false);
|
||||
if !user_auth_required && !self_approval_disabled {
|
||||
return None;
|
||||
}
|
||||
let user_groups_required = match &suspend.user_groups_required {
|
||||
Some(InputTransform::Static { value }) if user_auth_required => {
|
||||
serde_json::from_str(value.get()).unwrap_or_default()
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
Some(ApprovalConditions { user_auth_required, user_groups_required, self_approval_disabled })
|
||||
}
|
||||
|
||||
/// How the approval step presents itself on the approval page.
|
||||
struct ApprovalStepView {
|
||||
skin: ApprovalSkin,
|
||||
summary: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_approval_info(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -4939,13 +4989,33 @@ async fn get_approval_info(
|
||||
script_path: Option<String>,
|
||||
email: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
workflow_as_code_status: Option<serde_json::Value>,
|
||||
// `v2_job_status` only holds a run that hasn't finished, so the fields below also read
|
||||
// the completed run's status: a finished run's page keeps its skin and, for workflows as
|
||||
// code, its description, still gated by the approval conditions the run had.
|
||||
completed_flow_status: Option<serde_json::Value>,
|
||||
is_wac: bool,
|
||||
wac_approval: Option<serde_json::Value>,
|
||||
approval_conditions: Option<serde_json::Value>,
|
||||
flow_summary: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, ApprovalJobRow>(
|
||||
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
|
||||
s.flow_status, s.workflow_as_code_status
|
||||
s.flow_status,
|
||||
c.flow_status AS completed_flow_status,
|
||||
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status) IS NOT NULL
|
||||
AS is_wac,
|
||||
COALESCE(s.workflow_as_code_status, c.workflow_as_code_status)->'_approval'
|
||||
AS wac_approval,
|
||||
COALESCE(s.flow_status, c.flow_status)->'approval_conditions'
|
||||
AS approval_conditions,
|
||||
NULLIF(COALESCE(f.summary, sc.summary), '') AS flow_summary
|
||||
FROM v2_job j
|
||||
LEFT JOIN v2_job_status s ON s.id = j.id
|
||||
LEFT JOIN v2_job_completed c ON c.id = j.id
|
||||
LEFT JOIN flow f
|
||||
ON j.kind = 'flow' AND f.workspace_id = j.workspace_id AND f.path = j.runnable_path
|
||||
LEFT JOIN script sc
|
||||
ON j.kind = 'script' AND sc.workspace_id = j.workspace_id AND sc.hash = j.runnable_id
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
@@ -4954,31 +5024,31 @@ async fn get_approval_info(
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
let is_wac = row.is_wac;
|
||||
let run_ac = row
|
||||
.approval_conditions
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel) =
|
||||
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel, step) =
|
||||
if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let approval_meta = row.wac_approval.as_ref();
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let default_args = approval_meta.and_then(|m| m.get("default_args").cloned());
|
||||
let enums = approval_meta.and_then(|m| m.get("enums").cloned());
|
||||
let description = approval_meta.and_then(|m| m.get("description").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, description, default_args, enums, ac, None)
|
||||
let skin = approval_meta
|
||||
.and_then(|m| m.get("skin"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalSkin>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
let step = Some(ApprovalStepView { skin, summary: None });
|
||||
(form, description, default_args, enums, run_ac, None, step)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
@@ -5038,6 +5108,28 @@ async fn get_approval_info(
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
let completed_fs = row
|
||||
.completed_flow_status
|
||||
.as_ref()
|
||||
.filter(|_| fs.is_none())
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let approval_module = raw_flow
|
||||
.as_ref()
|
||||
.zip(fs.as_ref().or(completed_fs.as_ref()))
|
||||
.and_then(|(flow, status)| last_reached_approval_step(flow, status));
|
||||
let ac = run_ac.or_else(|| {
|
||||
approval_module
|
||||
.and_then(|module| module.suspend.as_ref())
|
||||
.and_then(approval_conditions_from_settings)
|
||||
});
|
||||
let step = approval_module.map(|module| ApprovalStepView {
|
||||
skin: module
|
||||
.suspend
|
||||
.as_ref()
|
||||
.and_then(|s| s.skin)
|
||||
.unwrap_or_default(),
|
||||
summary: module.summary.clone().filter(|s| !s.trim().is_empty()),
|
||||
});
|
||||
|
||||
// Fetch description, default_args, and enums from the step's completed job result
|
||||
let step_job_id = fs
|
||||
@@ -5061,9 +5153,12 @@ async fn get_approval_info(
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
(form, desc, default_args, enums, ac, hc)
|
||||
(form, desc, default_args, enums, ac, hc, step)
|
||||
};
|
||||
|
||||
let skin = step.as_ref().map(|s| s.skin).unwrap_or_default();
|
||||
let step_summary = step.and_then(|s| s.summary);
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
@@ -5093,6 +5188,9 @@ async fn get_approval_info(
|
||||
can_approve: false,
|
||||
user_auth_required,
|
||||
hide_cancel: None,
|
||||
skin,
|
||||
step_summary: None,
|
||||
flow_summary: None,
|
||||
approvers: vec![],
|
||||
view_token: None,
|
||||
}));
|
||||
@@ -5128,6 +5226,9 @@ async fn get_approval_info(
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
hide_cancel,
|
||||
skin,
|
||||
step_summary,
|
||||
flow_summary: row.flow_summary,
|
||||
approvers,
|
||||
view_token,
|
||||
}))
|
||||
@@ -11908,4 +12009,58 @@ mod approval_view_gate_tests {
|
||||
"trigger@example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_step_is_the_last_one_passed() {
|
||||
let flow: FlowValue = serde_json::from_value(serde_json::json!({ "modules": [
|
||||
{ "id": "a", "value": { "type": "identity" }, "suspend": {} },
|
||||
{ "id": "b", "value": { "type": "identity" }, "suspend": {} },
|
||||
{ "id": "c", "value": { "type": "identity" } }
|
||||
]}))
|
||||
.unwrap();
|
||||
let step_at = |step: i32, types: [(&str, bool); 3]| {
|
||||
let mut status = FlowStatus::new(&flow);
|
||||
status.step = step;
|
||||
status.modules = ["a", "b", "c"]
|
||||
.into_iter()
|
||||
.zip(types)
|
||||
.map(|(id, (kind, skipped))| {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": kind, "id": id, "job": Uuid::nil(), "count": 1,
|
||||
"failed_retries": [], "skipped": skipped
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
last_reached_approval_step(&flow, &status).map(|module| module.id.clone())
|
||||
};
|
||||
let waiting = ("WaitingForEvents", false);
|
||||
let pending = ("WaitingForPriorSteps", false);
|
||||
let ran = ("Success", false);
|
||||
let skipped = ("Success", true);
|
||||
// Awaiting a's approval: b, itself an approval step, already holds `WaitingForEvents`.
|
||||
assert_eq!(step_at(1, [ran, waiting, pending]).as_deref(), Some("a"));
|
||||
assert_eq!(step_at(2, [ran, ran, waiting]).as_deref(), Some("b"));
|
||||
assert_eq!(step_at(3, [ran, skipped, ran]).as_deref(), Some("a"));
|
||||
assert_eq!(step_at(0, [pending, pending, pending]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approved_step_stays_gated_by_its_settings() {
|
||||
let from_settings = |suspend: serde_json::Value| {
|
||||
approval_conditions_from_settings(&serde_json::from_value(suspend).unwrap())
|
||||
};
|
||||
let login = from_settings(serde_json::json!({
|
||||
"user_auth_required": true,
|
||||
"user_groups_required": { "type": "static", "value": ["approvers"] }
|
||||
}));
|
||||
assert!(!can_view(
|
||||
&None,
|
||||
&login,
|
||||
Some("f/team/flow"),
|
||||
"trigger@example.com"
|
||||
));
|
||||
assert_eq!(login.unwrap().user_groups_required, ["approvers"]);
|
||||
assert!(from_settings(serde_json::json!({})).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,25 @@ use sha2::Sha256;
|
||||
use sqlx::types::Uuid;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::{to_anyhow, Error};
|
||||
use windmill_common::flows::ApprovalSkin;
|
||||
use windmill_common::utils::truncate_with_ellipsis;
|
||||
use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key};
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::jobs::{QueryApprover, ResumeUrls};
|
||||
use crate::{
|
||||
approvals::{
|
||||
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
|
||||
MessageFormat, QueryButtonText, QueryDefaultArgsJson, QueryDynamicEnumJson,
|
||||
QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
||||
extract_w_id_from_resume_url, get_approval_step_skin, handle_resume_action,
|
||||
ApprovalFormDetails, FieldType, MessageFormat, QueryButtonText, QueryDefaultArgsJson,
|
||||
QueryDynamicEnumJson, QueryFlowStepId, QueryMessage, ResumeFormField, ResumeSchema,
|
||||
},
|
||||
auth::OptTokened,
|
||||
};
|
||||
|
||||
// Slack rejects a button value over 2000 characters, and with it the whole post. The button value
|
||||
// carries the message on to the modal, so the message is shortened to fit.
|
||||
const SLACK_BUTTON_VALUE_MAX_CHARS: usize = 2000;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SlackFormData {
|
||||
payload: String,
|
||||
@@ -127,6 +133,9 @@ struct PrivateMetadata {
|
||||
// HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is
|
||||
// built, required by `handle_submission` before the resource_path is decrypted.
|
||||
signature: Option<String>,
|
||||
// Only selects the wording of the updated channel message, so it is left unsigned.
|
||||
#[serde(default)]
|
||||
skin: ApprovalSkin,
|
||||
}
|
||||
|
||||
// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify
|
||||
@@ -432,6 +441,7 @@ async fn handle_submission(
|
||||
let container: Container = private_metadata.container;
|
||||
let hide_cancel = private_metadata.hide_cancel;
|
||||
let signature = private_metadata.signature;
|
||||
let skin = private_metadata.skin;
|
||||
|
||||
// If hide_cancel is true, we don't need to extract information from the private_metadata
|
||||
if hide_cancel.unwrap_or(false) && action == "cancel" {
|
||||
@@ -463,7 +473,7 @@ async fn handle_submission(
|
||||
tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}");
|
||||
Error::BadRequest("Invalid Slack callback request".to_string())
|
||||
})?;
|
||||
update_original_slack_message(action, slack_token, container).await?;
|
||||
update_original_slack_message(action, slack_token, container, skin).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -475,14 +485,19 @@ async fn transform_schemas(
|
||||
required: Option<Vec<String>>,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
skin: ApprovalSkin,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
tracing::debug!("Resume urls: {:#?}", urls);
|
||||
|
||||
let link_label = match skin {
|
||||
ApprovalSkin::Detailed => "Flow suspension details",
|
||||
ApprovalSkin::Minimal => "View in Windmill",
|
||||
};
|
||||
let mut blocks = vec![serde_json::json!({
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": format!("{}\n<{}|Flow suspension details>", text, urls.approvalPage),
|
||||
"text": format!("{}\n<{}|{link_label}>", text, urls.approvalPage),
|
||||
}
|
||||
})];
|
||||
|
||||
@@ -918,10 +933,6 @@ async fn send_slack_message(
|
||||
value["approver"] = serde_json::json!(approver);
|
||||
}
|
||||
|
||||
if let Some(message) = message {
|
||||
value["message"] = serde_json::json!(message);
|
||||
}
|
||||
|
||||
if let Some(default_args_json) = default_args_json {
|
||||
value["default_args_json"] = default_args_json.clone();
|
||||
}
|
||||
@@ -950,33 +961,8 @@ async fn send_slack_message(
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
value["signature"] = serde_json::json!(signature);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": "A flow has been suspended. Please approve or reject the flow.",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "A flow has been suspended. Please approve or reject the flow."
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View"
|
||||
},
|
||||
"action_id": "open_modal",
|
||||
"value": value.to_string()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
let skin = get_approval_step_skin(db, w_id, job_id, flow_step_id).await;
|
||||
let payload = channel_message_payload(channel_id, skin, message, value);
|
||||
|
||||
tracing::debug!("Payload: {:?}", payload);
|
||||
|
||||
@@ -1000,6 +986,88 @@ async fn send_slack_message(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// The channel post announcing the approval. Its button hands `button_value` to the modal, with
|
||||
/// `message` added, shortened to what Slack's button value limit leaves room for.
|
||||
fn channel_message_payload(
|
||||
channel_id: &str,
|
||||
skin: ApprovalSkin,
|
||||
message: Option<&str>,
|
||||
mut button_value: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let message = message.map(|m| message_fitting_button_value(&button_value, m));
|
||||
if let Some(message) = &message {
|
||||
button_value["message"] = serde_json::json!(message);
|
||||
}
|
||||
let (text, section, button_label) = match skin {
|
||||
ApprovalSkin::Detailed => {
|
||||
let text = "A flow has been suspended. Please approve or reject the flow.";
|
||||
(text, text.to_string(), "View")
|
||||
}
|
||||
ApprovalSkin::Minimal => {
|
||||
let mut section = "*Approval requested*".to_string();
|
||||
if let Some(message) = &message {
|
||||
section.push('\n');
|
||||
section.push_str(message);
|
||||
}
|
||||
("Approval requested", section, "Review")
|
||||
}
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": text,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": section
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": button_label
|
||||
},
|
||||
"action_id": "open_modal",
|
||||
"value": button_value.to_string()
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The longest prefix of `message` that keeps `button_value` carrying it within Slack's limit.
|
||||
fn message_fitting_button_value(button_value: &serde_json::Value, message: &str) -> String {
|
||||
let mut with_message = button_value.clone();
|
||||
let mut fits = |max_chars: usize| {
|
||||
let fitted = truncate_with_ellipsis(message, max_chars);
|
||||
with_message["message"] = serde_json::json!(fitted);
|
||||
(with_message.to_string().chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS).then_some(fitted)
|
||||
};
|
||||
if let Some(whole) = fits(usize::MAX) {
|
||||
return whole;
|
||||
}
|
||||
// Searched on the serialized length, which escaping makes longer than the raw prefix, and
|
||||
// which grows with every character kept.
|
||||
let (mut shortest, mut longest) =
|
||||
(0, message.chars().count().min(SLACK_BUTTON_VALUE_MAX_CHARS));
|
||||
while shortest < longest {
|
||||
let mid = (shortest + longest + 1) / 2;
|
||||
if fits(mid).is_some() {
|
||||
shortest = mid;
|
||||
} else {
|
||||
longest = mid - 1;
|
||||
}
|
||||
}
|
||||
fits(shortest).unwrap_or_else(|| truncate_with_ellipsis(message, 0))
|
||||
}
|
||||
|
||||
async fn get_modal_blocks(
|
||||
db: DB,
|
||||
w_id: &str,
|
||||
@@ -1034,7 +1102,7 @@ async fn get_modal_blocks(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ApprovalFormDetails { message_str, urls, schema } = approval_details;
|
||||
let ApprovalFormDetails { message_str, urls, schema, skin } = approval_details;
|
||||
|
||||
// Get the card content
|
||||
let card_content = transform_schemas(
|
||||
@@ -1063,6 +1131,7 @@ async fn get_modal_blocks(
|
||||
}),
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
skin,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1077,6 +1146,7 @@ async fn get_modal_blocks(
|
||||
resume_button_text,
|
||||
cancel_button_text,
|
||||
&private_metadata_signature,
|
||||
skin,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1090,27 +1160,32 @@ fn construct_payload(
|
||||
resume_button_text: Option<&str>,
|
||||
cancel_button_text: Option<&str>,
|
||||
signature: &str,
|
||||
skin: ApprovalSkin,
|
||||
) -> serde_json::Value {
|
||||
let (title, resume_label, cancel_label) = match skin {
|
||||
ApprovalSkin::Detailed => ("Workflow Suspended", "Resume Workflow", "Cancel Workflow"),
|
||||
ApprovalSkin::Minimal => ("Approval request", "Approve", "Reject"),
|
||||
};
|
||||
let mut view = serde_json::json!({
|
||||
"type": "modal",
|
||||
"callback_id": "submit_form",
|
||||
"notify_on_close": true,
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Workflow Suspended"
|
||||
"text": title
|
||||
},
|
||||
"blocks": blocks,
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": resume_button_text.unwrap_or("Resume Workflow")
|
||||
"text": resume_button_text.unwrap_or(resume_label)
|
||||
},
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature }).to_string(),
|
||||
"private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature, "skin": skin }).to_string(),
|
||||
});
|
||||
|
||||
if !hide_cancel {
|
||||
view["close"] = serde_json::json!({
|
||||
"type": "plain_text",
|
||||
"text": cancel_button_text.unwrap_or("Cancel Workflow")
|
||||
"text": cancel_button_text.unwrap_or(cancel_label)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1193,11 +1268,13 @@ async fn update_original_slack_message(
|
||||
action: &str,
|
||||
token: String,
|
||||
container: Container,
|
||||
skin: ApprovalSkin,
|
||||
) -> Result<(), Error> {
|
||||
let message = if action == "resume" {
|
||||
"\n\n*Workflow has been resumed!* :white_check_mark:"
|
||||
} else {
|
||||
"\n\n*Workflow has been canceled!* :x:"
|
||||
let message = match (skin, action == "resume") {
|
||||
(ApprovalSkin::Detailed, true) => "\n\n*Workflow has been resumed!* :white_check_mark:",
|
||||
(ApprovalSkin::Detailed, false) => "\n\n*Workflow has been canceled!* :x:",
|
||||
(ApprovalSkin::Minimal, true) => "*Approved* :white_check_mark:",
|
||||
(ApprovalSkin::Minimal, false) => "*Rejected* :x:",
|
||||
};
|
||||
|
||||
let final_blocks = vec![serde_json::json!({
|
||||
@@ -1242,3 +1319,66 @@ async fn update_original_slack_message(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn long_message_keeps_the_channel_post_within_slack_limits() {
|
||||
let button_value = serde_json::json!({
|
||||
"w_id": "demo",
|
||||
"job_id": Uuid::nil(),
|
||||
"path": "u/admin/slack",
|
||||
"channel": "C0123456789",
|
||||
"flow_step_id": "a",
|
||||
"signature": "f".repeat(64),
|
||||
});
|
||||
let carried = |skin, message: &str| {
|
||||
let payload = channel_message_payload("C1", skin, Some(message), button_value.clone());
|
||||
let button = payload["blocks"][1]["elements"][0]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(button.chars().count() <= SLACK_BUTTON_VALUE_MAX_CHARS);
|
||||
let section = payload["blocks"][0]["text"]["text"].as_str().unwrap();
|
||||
assert!(section.chars().count() <= 3000);
|
||||
serde_json::from_str::<ModalActionValue>(&button)
|
||||
.unwrap()
|
||||
.message
|
||||
.unwrap()
|
||||
};
|
||||
// Quotes and newlines each cost two characters once escaped into the button value.
|
||||
let message = "Expense \"offsite\" line\n".repeat(1000);
|
||||
for skin in [ApprovalSkin::Detailed, ApprovalSkin::Minimal] {
|
||||
let kept = carried(skin, &message);
|
||||
let kept = kept.strip_suffix("...").unwrap();
|
||||
assert!(message.starts_with(kept));
|
||||
assert!(kept.chars().count() > 1_000);
|
||||
assert_eq!(carried(skin, "Short message"), "Short message");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_skin_survives_the_modal_round_trip() {
|
||||
let container = Container { message_ts: "1".to_string(), channel_id: "C1".to_string() };
|
||||
let payload = construct_payload(
|
||||
serde_json::json!([]),
|
||||
false,
|
||||
"trigger",
|
||||
"https://example.com/resume",
|
||||
"u/admin/slack",
|
||||
container,
|
||||
None,
|
||||
None,
|
||||
"signature",
|
||||
ApprovalSkin::Minimal,
|
||||
);
|
||||
let view = &payload["view"];
|
||||
assert_eq!(view["submit"]["text"], "Approve");
|
||||
assert_eq!(view["close"]["text"], "Reject");
|
||||
let metadata: PrivateMetadata =
|
||||
serde_json::from_str(view["private_metadata"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(metadata.skin, ApprovalSkin::Minimal);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user