Merge branch 'di/better-ws-storage-settings' into di/playwright

This commit is contained in:
Diego Imbert
2026-01-09 14:37:17 +01:00
67 changed files with 3164 additions and 36080 deletions
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# Resolve _ee.rs symlinks to actual files so Claude can read them
# This script runs before each user prompt is processed
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Find all _ee.rs symlinks and store their targets
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
target=$(readlink -f "$symlink" 2>/dev/null) || continue
# Only process if target file exists
if [[ -f "$target" ]]; then
# Store symlink path and target in manifest
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
# Replace symlink with actual file content
rm "$symlink"
cp "$target" "$symlink"
fi
done
# Atomically replace manifest
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
fi
exit 0
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Restore _ee.rs symlinks after Claude finishes processing
# This script runs when Claude stops
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Check if manifest exists
if [[ ! -f "$MANIFEST_FILE" ]]; then
exit 0
fi
# Read manifest and restore symlinks
while IFS='|' read -r symlink target; do
if [[ -n "$symlink" && -n "$target" ]]; then
# If the file exists (not a symlink) and target exists, copy changes back
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
# Copy the potentially modified file back to the target
cp "$symlink" "$target"
fi
# Remove the regular file (which was a copy)
rm -f "$symlink" 2>/dev/null || true
# Recreate the symlink
ln -s "$target" "$symlink" 2>/dev/null || true
fi
done < "$MANIFEST_FILE"
# Clean up manifest
rm -f "$MANIFEST_FILE"
exit 0
+41 -6
View File
@@ -1,7 +1,41 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
]
},
"permissions": {
"allow": [
"Read(**/*.rs)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(cat:*)",
@@ -56,10 +90,11 @@
"Bash(git checkout:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)"
],
"additionalDirectories": [
"../windmill-ee-private/"
]
]
},
"enableAllProjectMcpServers": true
"enableAllProjectMcpServers": true,
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true
}
}
+14 -15
View File
@@ -49,9 +49,9 @@ jobs:
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-8
permissions:
contents: read
pull-requests: read
issues: read
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
@@ -60,19 +60,18 @@ jobs:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@beta
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
model: claude-opus-4-1-20250805
fallback_model: claude-sonnet-4-20250514
timeout_minutes: "60"
allowed_tools: "mcp__github__create_pull_request"
allowed_bots: "windmill-internal-app[bot]"
custom_instructions: |
## IMPORTANT INSTRUCTIONS
- Your branch name should be a short description of the requested changes.
- Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a draft PR from that branch to main.
## Available Tools
- mcp__github__create_pull_request: Create PRs from branches
trigger_phrase: "/ai-fast"
plugins: "rust-analyzer-lsp@claude-plugins-official,typescript-lsp@claude-plugins-official"
settings: |
{
"env": {
"SQLX_OFFLINE": "true"
}
}
claude_args: |
--allowedTools "Bash,WebFetch,WebSearch"
--model opus
+6 -5
View File
@@ -50,9 +50,9 @@ jobs:
runs-on: ubicloud-standard-8
timeout-minutes: 60
permissions:
contents: read
pull-requests: read
issues: read
contents: write
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
@@ -95,8 +95,9 @@ jobs:
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: 'windmill-internal-app[bot]'
trigger_phrase: '/ai'
allowed_bots: "windmill-internal-app[bot]"
trigger_phrase: "/ai"
plugins: "rust-analyzer-lsp@claude-plugins-official,typescript-lsp@claude-plugins-official"
settings: |
{
"env": {
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.603.0](https://github.com/windmill-labs/windmill/compare/v1.602.0...v1.603.0) (2026-01-09)
### Features
* add password reset flow using configured SMTP settings ([#7525](https://github.com/windmill-labs/windmill/issues/7525)) ([6f7cf2f](https://github.com/windmill-labs/windmill/commit/6f7cf2fb1645bb784af3a68760abc44013bd81f8))
## [1.602.0](https://github.com/windmill-labs/windmill/compare/v1.601.1...v1.602.0) (2026-01-08)
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "31922d7aaaaf17f389d489b9a746295d6c3ad8ac6750782bd9ab35a9b432ca6b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM magic_link WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "97a83839e5d9269e9389b9c7604814cc245cc8d4ae653cfce0f2ccca4ee630cb"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a61d53c8400864a7bc06894c08ec70e45242075bd17b37ea2c0c6b6eec11eb40"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c1b6a2c3605cf5385664c5f988b96297fe6b8971e388ea036d5355e0c0937006"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "fd500c52e64983a5559da8bcd0d5ed43a9f2eba45a7eec6b64ab38ea02d6b6c9"
}
+258 -220
View File
File diff suppressed because it is too large Load Diff
+12 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.602.0"
version = "1.603.0"
authors.workspace = true
edition.workspace = true
@@ -11,11 +11,13 @@ members = [
"./windmill-queue",
"./windmill-worker",
"./windmill-common",
"./windmill-mcp",
"./windmill-audit",
"./windmill-git-sync",
"./windmill-autoscaling",
"./windmill-indexer",
"./windmill-macros",
"./windmill-oauth",
"./parsers/windmill-parser",
"./parsers/windmill-parser-ts",
"./parsers/windmill-parser-go",
@@ -33,7 +35,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.602.0"
version = "1.603.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -56,7 +58,6 @@ enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmi
enterprise_saml = ["windmill-api/enterprise_saml", "oauth2"]
stripe = ["windmill-api/stripe"]
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark"]
loki = ["windmill-common/loki"]
embedding = ["windmill-api/embedding"]
parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-worker/parquet", "dep:object_store"]
prometheus = ["windmill-common/prometheus", "windmill-api/prometheus", "windmill-worker/prometheus", "windmill-queue/prometheus", "dep:prometheus"]
@@ -75,7 +76,7 @@ dind = ["windmill-worker/dind"]
websocket = ["windmill-api/websocket"]
http_trigger = ["windmill-api/http_trigger"]
postgres_trigger = ["windmill-api/postgres_trigger"]
mcp = ["windmill-api/mcp"]
mcp = ["windmill-api/mcp", "windmill-worker/mcp"]
mqtt_trigger = ["windmill-api/mqtt_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
gcp_trigger = ["windmill-api/gcp_trigger"]
@@ -102,7 +103,7 @@ ruby = ["windmill-worker/ruby"]
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java", "ruby"]
# For windows we have another set of languages enabled
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java"]
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "loki", "embedding", "parquet", "prometheus", "flow_testing",
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embedding", "parquet", "prometheus", "flow_testing",
"openidconnect", "cloud", "jemalloc", "tantivy", "sqlx", "kafka", "nats", "otel", "dind", "websocket", "http_trigger",
"postgres_trigger", "mcp", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "smtp", "stripe",
"license", "oauth2", "zip", "static_frontend", "scoped_cache", "agent_worker_server"]
@@ -190,6 +191,8 @@ windmill-audit = { path = "./windmill-audit" }
windmill-git-sync = { path = "./windmill-git-sync" }
windmill-autoscaling = { path = "./windmill-autoscaling" }
windmill-indexer = {path = "./windmill-indexer"}
windmill-mcp = {path = "./windmill-mcp"}
windmill-oauth = {path = "./windmill-oauth"}
windmill-macros = {path = "./windmill-macros"}
windmill-parser = { path = "./parsers/windmill-parser" }
windmill-parser-ts = { path = "./parsers/windmill-parser-ts" }
@@ -256,13 +259,13 @@ mail-send = { version = "0.4.0", features = ["builder"], default-features=false
urlencoding = "^2"
url = { version = "^2" , features = ["serde"]}
async-oauth2 = "0.5.1"
reqwest = { version = "=0.12.24", features = ["json", "stream", "gzip", "multipart"] }
reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] }
eventsource-stream = "0.2.3"
time = "^0"
serde_urlencoded = "^0"
astral-tokio-tar = "^0.5.6"
tempfile = "^3"
tokio-util = { version = "^0", features = ["io"] }
tokio-util = { version = "=0.7.17", features = ["io"] }
json-pointer = "^0"
itertools = "^0.14.0"
regex = "^1"
@@ -379,7 +382,8 @@ async-nats = "0.38.0"
nkeys = "0.4.4"
nu-parser = { version = "0.101.0", default-features = false }
globset = "0.4.16"
croner = "2.2.0"
rmcp = { version = "^0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
process-wrap = { version = "8.2.1", features = ["tokio1"] }
datafusion = "47.0.0"
+1 -1
View File
@@ -1 +1 @@
cf96b45aa1183f15b3cc1b971035de5e37a68849
c8e8a6df19203acc2cef1aebd1bd4157f2439cbf
-5
View File
@@ -2080,7 +2080,6 @@ async fn test_flow_lock_all(db: Pool<Postgres>) -> anyhow::Result<()> {
.get_flow_by_path("test-workspace", "g/all/flow_lock_all", None)
.await
.unwrap()
.into_inner()
.open_flow
.value
.modules;
@@ -2363,7 +2362,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()>
no_flow_overlap: None,
summary: None,
tag: None,
paused_until: None,
cron_version: None,
description: None,
};
@@ -2434,7 +2432,6 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()>
summary: None,
no_flow_overlap: None,
tag: None,
paused_until: None,
cron_version: None,
description: None,
},
@@ -2520,7 +2517,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()> {
no_flow_overlap: None,
summary: None,
tag: None,
paused_until: None,
cron_version: None,
description: None,
};
@@ -2592,7 +2588,6 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) -> anyhow::Result<()> {
summary: None,
no_flow_overlap: None,
tag: None,
paused_until: None,
cron_version: None,
description: None,
},
+2 -8
View File
@@ -10,13 +10,7 @@ path = "./src/lib.rs"
[dependencies]
progenitor-client = { git = "https://github.com/oxidecomputer/progenitor", rev = "3d96016ae8d422e90513b2d34fb5b63eeab30b01" }
reqwest = { version = "0.11", features = ["json", "stream"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
chrono.workspace = true
uuid.workspace = true
serde_json.workspace = true
rand.workspace = true
base64.workspace = true
openapiv3 = "=1.0.2"
urlencoding = "2"
File diff suppressed because it is too large Load Diff
@@ -1,18 +0,0 @@
[package]
name = "windmill-api-client-build"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "windmill_api_client_build"
path = "./main.rs"
[dependencies]
prettyplease = "0.1.25"
progenitor = { git = "https://github.com/oxidecomputer/progenitor", rev = "3d96016ae8d422e90513b2d34fb5b63eeab30b01" }
serde_json = "1.0"
syn = "1.0"
openapiv3 = "=1.0.2"
[workspace]
@@ -1,3 +0,0 @@
#!/bin/sh
npx swagger-cli bundle ../../windmill-api/openapi.yaml > bundled.json
@@ -1,32 +0,0 @@
use std::{
fs::{self, File},
path::Path,
process::Command,
};
fn main() {
Command::new("sh").args(&["./bundle.sh"]).status().unwrap();
let file = File::open("./bundled.json").unwrap();
let mut spec: openapiv3::OpenAPI = serde_json::from_reader(file).unwrap();
spec.paths.paths.retain(|key, _| {
[
"/w/{workspace}/flows/create",
"/w/{workspace}/flows/get/{path}",
"/w/{workspace}/scripts/create",
"/workspaces/list",
"/w/{workspace}/schedules/create",
"/w/{workspace}/schedules/update/{path}",
]
.contains(&key.as_str())
});
let mut generator = progenitor::Generator::default();
let tokens = generator.generate_tokens(&spec).unwrap();
let ast = syn::parse2(tokens).unwrap();
let content = prettyplease::unparse(&ast);
let mut out_file = Path::new("../src").to_path_buf();
out_file.push("codegen.rs");
fs::write(out_file, content).unwrap();
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+744 -5
View File
@@ -1,14 +1,753 @@
include!("./codegen.rs");
//! Minimal Windmill API client for tests
//!
//! This is a handwritten minimal client that provides just enough functionality
//! for the integration tests. It replaces the auto-generated progenitor client.
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Client for Windmill API
#[derive(Clone)]
pub struct Client {
pub baseurl: String,
pub client: reqwest::Client,
}
impl Client {
/// Create a new client with an existing reqwest::Client
pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self {
Self {
baseurl: baseurl.to_string(),
client,
}
}
/// Get the base URL
pub fn baseurl(&self) -> &String {
&self.baseurl
}
/// Get the internal reqwest::Client
pub fn client(&self) -> &reqwest::Client {
&self.client
}
/// Create a script
pub async fn create_script(
&self,
workspace: &str,
body: &types::NewScript,
) -> Result<String, Error> {
let url = format!(
"{}/w/{}/scripts/create",
self.baseurl,
urlencoding::encode(workspace)
);
let response = self.client.post(&url).json(body).send().await?;
if response.status().is_success() {
Ok(response.text().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
/// Create a flow
pub async fn create_flow(
&self,
workspace: &str,
body: &types::CreateFlowBody,
) -> Result<String, Error> {
let url = format!(
"{}/w/{}/flows/create",
self.baseurl,
urlencoding::encode(workspace)
);
let response = self.client.post(&url).json(body).send().await?;
if response.status().is_success() {
Ok(response.text().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
/// Get flow by path
pub async fn get_flow_by_path(
&self,
workspace: &str,
path: &str,
with_starred_info: Option<bool>,
) -> Result<types::Flow, Error> {
let url = format!(
"{}/w/{}/flows/get/{}",
self.baseurl,
urlencoding::encode(workspace),
urlencoding::encode(path)
);
let mut request = self.client.get(&url);
if let Some(starred) = with_starred_info {
request = request.query(&[("with_starred_info", starred.to_string())]);
}
let response = request.send().await?;
if response.status().is_success() {
Ok(response.json().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
/// Create a schedule
pub async fn create_schedule(
&self,
workspace: &str,
body: &types::NewSchedule,
) -> Result<String, Error> {
let url = format!(
"{}/w/{}/schedules/create",
self.baseurl,
urlencoding::encode(workspace)
);
let response = self.client.post(&url).json(body).send().await?;
if response.status().is_success() {
Ok(response.text().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
/// Update a schedule
pub async fn update_schedule(
&self,
workspace: &str,
path: &str,
body: &types::EditSchedule,
) -> Result<String, Error> {
let url = format!(
"{}/w/{}/schedules/update/{}",
self.baseurl,
urlencoding::encode(workspace),
urlencoding::encode(path)
);
let response = self.client.post(&url).json(body).send().await?;
if response.status().is_success() {
Ok(response.text().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
/// List workspaces
pub async fn list_workspaces(&self) -> Result<Vec<types::Workspace>, Error> {
let url = format!("{}/workspaces/list", self.baseurl);
let response = self.client.get(&url).send().await?;
if response.status().is_success() {
Ok(response.json().await?)
} else {
Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default()))
}
}
}
/// Create a client with bearer token authentication
pub fn create_client(base_url: &str, token: String) -> Client {
let mut val = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.expect("header creation");
let mut val = HeaderValue::from_str(&format!("Bearer {token}")).expect("header creation");
val.set_sensitive(true);
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(reqwest::header::AUTHORIZATION, val);
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, val);
let client = reqwest::ClientBuilder::new()
.default_headers(headers)
.build()
.expect("client build");
Client::new_with_client(&format!("{}/api", base_url.trim_end_matches('/')), client)
}
/// Error type for API client
#[derive(Debug)]
pub enum Error {
/// Request error
Request(reqwest::Error),
/// Unexpected response status
UnexpectedResponse(u16, String),
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
Error::Request(err)
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Request(e) => write!(f, "Request error: {}", e),
Error::UnexpectedResponse(status, body) => {
write!(f, "Unexpected response ({}): {}", status, body)
}
}
}
}
impl std::error::Error for Error {}
/// API types
pub mod types {
use super::*;
/// Script language
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub enum ScriptLang {
#[serde(rename = "python3")]
Python3,
#[serde(rename = "deno")]
Deno,
#[serde(rename = "go")]
Go,
#[serde(rename = "bash")]
Bash,
#[serde(rename = "powershell")]
Powershell,
#[serde(rename = "postgresql")]
Postgresql,
#[serde(rename = "mysql")]
Mysql,
#[serde(rename = "bigquery")]
Bigquery,
#[serde(rename = "snowflake")]
Snowflake,
#[serde(rename = "mssql")]
Mssql,
#[serde(rename = "oracledb")]
Oracledb,
#[serde(rename = "graphql")]
Graphql,
#[serde(rename = "nativets")]
Nativets,
#[serde(rename = "bun")]
Bun,
#[serde(rename = "php")]
Php,
#[serde(rename = "rust")]
Rust,
#[serde(rename = "ansible")]
Ansible,
#[serde(rename = "csharp")]
Csharp,
#[serde(rename = "nu")]
Nu,
#[serde(rename = "java")]
Java,
#[serde(rename = "ruby")]
Ruby,
#[serde(rename = "duckdb")]
Duckdb,
}
impl std::str::FromStr for ScriptLang {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"python3" => Ok(Self::Python3),
"deno" => Ok(Self::Deno),
"go" => Ok(Self::Go),
"bash" => Ok(Self::Bash),
"powershell" => Ok(Self::Powershell),
"postgresql" => Ok(Self::Postgresql),
"mysql" => Ok(Self::Mysql),
"bigquery" => Ok(Self::Bigquery),
"snowflake" => Ok(Self::Snowflake),
"mssql" => Ok(Self::Mssql),
"oracledb" => Ok(Self::Oracledb),
"graphql" => Ok(Self::Graphql),
"nativets" => Ok(Self::Nativets),
"bun" => Ok(Self::Bun),
"php" => Ok(Self::Php),
"rust" => Ok(Self::Rust),
"ansible" => Ok(Self::Ansible),
"csharp" => Ok(Self::Csharp),
"nu" => Ok(Self::Nu),
"java" => Ok(Self::Java),
"ruby" => Ok(Self::Ruby),
"duckdb" => Ok(Self::Duckdb),
_ => Err("invalid script language"),
}
}
}
/// Raw script language (for flow modules)
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
pub enum RawScriptLanguage {
#[serde(rename = "python3")]
Python3,
#[serde(rename = "deno")]
Deno,
#[serde(rename = "go")]
Go,
#[serde(rename = "bash")]
Bash,
#[serde(rename = "powershell")]
Powershell,
#[serde(rename = "postgresql")]
Postgresql,
#[serde(rename = "mysql")]
Mysql,
#[serde(rename = "bigquery")]
Bigquery,
#[serde(rename = "snowflake")]
Snowflake,
#[serde(rename = "mssql")]
Mssql,
#[serde(rename = "oracledb")]
Oracledb,
#[serde(rename = "graphql")]
Graphql,
#[serde(rename = "nativets")]
Nativets,
#[serde(rename = "bun")]
Bun,
#[serde(rename = "php")]
Php,
#[serde(rename = "rust")]
Rust,
#[serde(rename = "ansible")]
Ansible,
#[serde(rename = "csharp")]
Csharp,
#[serde(rename = "nu")]
Nu,
#[serde(rename = "java")]
Java,
#[serde(rename = "ruby")]
Ruby,
#[serde(rename = "duckdb")]
Duckdb,
}
/// New script request body
#[derive(Clone, Debug, Serialize)]
pub struct NewScript {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codebase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<i64>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_after_use: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_message: Option<String>,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub envs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_preprocessor: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_template: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
pub language: ScriptLang,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_main_func: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_hash: Option<String>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_unless_cancelled: Option<bool>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub schema: HashMap<String, serde_json::Value>,
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
}
/// Script arguments (used in schedules)
pub type ScriptArgs = HashMap<String, serde_json::Value>;
/// New schedule request body
#[derive(Clone, Debug, Serialize)]
pub struct NewSchedule {
pub args: ScriptArgs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cron_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
pub is_flow: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_flow_overlap: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_exact: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_extra_args: Option<ScriptArgs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_times: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery_extra_args: Option<ScriptArgs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery_times: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_success: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_success_extra_args: Option<ScriptArgs>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<serde_json::Value>,
pub schedule: String,
pub script_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
pub timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
}
/// Edit schedule request body
#[derive(Clone, Debug, Serialize)]
pub struct EditSchedule {
pub args: ScriptArgs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cron_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_flow_overlap: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_exact: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_extra_args: Option<ScriptArgs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_failure_times: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery_extra_args: Option<ScriptArgs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_recovery_times: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_success: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_success_extra_args: Option<ScriptArgs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<serde_json::Value>,
pub schedule: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
pub timezone: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
}
/// Open flow definition
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OpenFlow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub schema: HashMap<String, serde_json::Value>,
pub summary: String,
pub value: FlowValue,
}
/// Flow value containing modules
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FlowValue {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub early_return: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_module: Option<FlowModule>,
pub modules: Vec<FlowModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preprocessor_module: Option<FlowModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub same_worker: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_expr: Option<String>,
}
/// Flow module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FlowModule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_ttl: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continue_on_error: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete_after_use: Option<bool>,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mock: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_if: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sleep: Option<InputTransform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_after_all_iters_if: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_after_if: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suspend: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<InputTransform>,
pub value: FlowModuleValue,
}
/// Input transform
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum InputTransform {
Static {
#[serde(rename = "type")]
type_: String,
value: serde_json::Value
},
Javascript {
#[serde(rename = "type")]
type_: String,
expr: String
},
}
/// Flow module value (the actual module content)
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum FlowModuleValue {
RawScript(RawScript),
Script(ScriptModule),
Flow(FlowModule2),
ForLoop(ForLoopModule),
WhileLoop(WhileLoopModule),
BranchOne(BranchOneModule),
BranchAll(BranchAllModule),
Identity(IdentityModule),
Other(serde_json::Value),
}
/// Raw script module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RawScript {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency_time_window_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrent_limit: Option<f64>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_concurrency_key: Option<String>,
pub input_transforms: HashMap<String, InputTransform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_trigger: Option<bool>,
pub language: RawScriptLanguage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(rename = "type")]
pub type_: String,
}
impl RawScript {
pub fn new(content: String, language: RawScriptLanguage) -> Self {
Self {
assets: vec![],
concurrency_time_window_s: None,
concurrent_limit: None,
content,
custom_concurrency_key: None,
input_transforms: HashMap::new(),
is_trigger: None,
language,
lock: None,
path: None,
tag: None,
type_: "rawscript".to_string(),
}
}
}
/// Script module reference
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScriptModule {
#[serde(rename = "type")]
pub type_: String,
pub path: String,
pub input_transforms: HashMap<String, InputTransform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
}
/// Flow module reference
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FlowModule2 {
#[serde(rename = "type")]
pub type_: String,
pub path: String,
pub input_transforms: HashMap<String, InputTransform>,
}
/// For loop module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ForLoopModule {
#[serde(rename = "type")]
pub type_: String,
pub iterator: InputTransform,
pub modules: Vec<FlowModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallelism: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_failures: Option<bool>,
}
/// While loop module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct WhileLoopModule {
#[serde(rename = "type")]
pub type_: String,
pub modules: Vec<FlowModule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_failures: Option<bool>,
}
/// Branch one module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BranchOneModule {
#[serde(rename = "type")]
pub type_: String,
pub branches: Vec<serde_json::Value>,
pub default: Vec<FlowModule>,
}
/// Branch all module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BranchAllModule {
#[serde(rename = "type")]
pub type_: String,
pub branches: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallel: Option<bool>,
}
/// Identity module
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct IdentityModule {
#[serde(rename = "type")]
pub type_: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow: Option<bool>,
}
/// Open flow with path
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OpenFlowWPath {
#[serde(flatten)]
pub open_flow: OpenFlow,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dedicated_worker: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_to_runner_only: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ws_error_handler_muted: Option<bool>,
}
/// Create flow request body
#[derive(Clone, Debug, Serialize)]
pub struct CreateFlowBody {
#[serde(flatten)]
pub open_flow_w_path: OpenFlowWPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployment_message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft_only: Option<bool>,
}
/// Flow response type
#[derive(Clone, Debug, Deserialize)]
pub struct Flow {
pub path: String,
#[serde(flatten)]
pub open_flow: OpenFlow,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
/// Workspace
#[derive(Clone, Debug, Deserialize)]
pub struct Workspace {
pub id: String,
pub name: String,
#[serde(default)]
pub owner: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
}
+4 -4
View File
@@ -27,7 +27,7 @@ websocket = ["dep:tokio-tungstenite"]
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"]
license = ["dep:rsa"]
zip = ["dep:async_zip"]
oauth2 = ["dep:async-oauth2"]
oauth2 = ["dep:windmill-oauth"]
http_trigger = ["dep:matchit", "dep:thiserror", "dep:sha1", "dep:constant_time_eq"]
static_frontend = ["dep:rust-embed"]
postgres_trigger = ["dep:rust-postgres", "dep:pg_escape", "dep:byteorder", "dep:thiserror", "dep:rust_decimal", "dep:rust-postgres-native-tls"]
@@ -36,11 +36,11 @@ sqs_trigger = ["dep:aws-sdk-sqs", "dep:aws-sdk-sts", "dep:aws-sdk-sso", "dep:aws
deno_core = ["dep:deno_core", "dep:deno_error"]
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
cloud = ["windmill-common/cloud"]
mcp = ["dep:rmcp"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server"]
python = []
[dependencies]
rmcp = { version = "0.12.0", features=["transport-streamable-http-server", "transport-streamable-http-server-session", "transport-worker"], optional = true }
windmill-mcp = { workspace = true, optional = true }
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-audit.workspace = true
@@ -67,7 +67,7 @@ itertools.workspace = true
reqwest.workspace = true
serde.workspace = true
sqlx.workspace = true
async-oauth2 = { workspace = true, optional = true }
windmill-oauth = { workspace = true, optional = true }
tracing.workspace = true
sql-builder.workspace = true
serde_json.workspace = true
+86 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.602.0
version: 1.603.0
title: Windmill API
contact:
@@ -230,6 +230,83 @@ paths:
schema:
type: string
/auth/is_smtp_configured:
get:
security: []
summary: check if SMTP is configured for password reset
operationId: isSmtpConfigured
tags:
- user
responses:
"200":
description: returns true if SMTP is configured
content:
application/json:
schema:
type: boolean
/auth/request_password_reset:
post:
security: []
summary: request password reset email
operationId: requestPasswordReset
tags:
- user
requestBody:
description: email to send password reset link to
required: true
content:
application/json:
schema:
type: object
required:
- email
properties:
email:
type: string
format: email
responses:
"200":
description: password reset email sent (if user exists)
content:
application/json:
schema:
$ref: "#/components/schemas/PasswordResetResponse"
"400":
description: SMTP not configured
/auth/reset_password:
post:
security: []
summary: reset password using token
operationId: resetPassword
tags:
- user
requestBody:
description: token and new password
required: true
content:
application/json:
schema:
type: object
required:
- token
- new_password
properties:
token:
type: string
new_password:
type: string
responses:
"200":
description: password reset successfully
content:
application/json:
schema:
$ref: "#/components/schemas/PasswordResetResponse"
"400":
description: invalid or expired token
/w/{workspace}/users/get/{username}:
get:
summary: get user (require admin privilege)
@@ -17457,6 +17534,14 @@ components:
- email
- password
PasswordResetResponse:
type: object
properties:
message:
type: string
required:
- message
EditWorkspaceUser:
type: object
properties:
+8 -10
View File
@@ -9,19 +9,20 @@ use std::sync::Arc;
use std::{borrow::Cow, time::Duration};
use axum::body::to_bytes;
use rmcp::{
handler::server::ServerHandler,
model::*,
service::{RequestContext, RoleServer},
transport::StreamableHttpServerConfig,
ErrorData,
};
use serde_json::Value;
use tokio::try_join;
use tokio_util::sync::CancellationToken;
use windmill_common::db::UserDB;
use windmill_common::worker::to_raw_value;
use windmill_common::{utils::StripPath, DB};
use windmill_mcp::server::{
Annotated, CallToolRequestParam, CallToolResult, Content, ErrorData, Implementation,
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
ListResourcesResult, ListToolsResult, LocalSessionManager, PaginatedRequestParam,
ProtocolVersion, RawContent, RawTextContent, RequestContext, RoleServer, ServerCapabilities,
ServerHandler, ServerInfo, StreamableHttpServerConfig, StreamableHttpService, Tool,
ToolAnnotations,
};
use crate::db::ApiAuthed;
use crate::jobs::{
@@ -47,9 +48,6 @@ use super::utils::{
use axum::{
extract::Path, http::Request, middleware::Next, response::Response, routing::get, Json, Router,
};
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpService,
};
use windmill_common::error::JsonResult;
/// MCP Server Runner - implements the core MCP protocol handlers
@@ -4,10 +4,10 @@
//! them to MCP tools and handling HTTP calls to Windmill API endpoints.
use crate::db::ApiAuthed;
use rmcp::{model::Tool, ErrorData};
use std::sync::Arc;
use windmill_common::db::Authed;
use windmill_common::{auth::create_jwt_token, BASE_INTERNAL_URL};
use windmill_mcp::server::{ErrorData, Tool, ToolAnnotations};
// Import the auto-generated tools
use super::auto_generated_endpoints;
@@ -66,7 +66,7 @@ pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool {
}
/// Create appropriate annotations for endpoint tools based on HTTP method
fn create_endpoint_annotations(tool: &EndpointTool) -> rmcp::model::ToolAnnotations {
fn create_endpoint_annotations(tool: &EndpointTool) -> ToolAnnotations {
let method = tool.method.as_ref();
// Determine characteristics based on HTTP method
@@ -79,7 +79,7 @@ fn create_endpoint_annotations(tool: &EndpointTool) -> rmcp::model::ToolAnnotati
_ => (false, true, false, true), // Default: assume can modify and be destructive
};
rmcp::model::ToolAnnotations {
ToolAnnotations {
title: Some(format!("{} {}", method, tool.path)),
read_only_hint: Some(read_only),
destructive_hint: Some(destructive),
@@ -3,7 +3,7 @@
//! Contains all database query functions and database-related utilities
//! used by the MCP server implementation.
use rmcp::ErrorData;
use windmill_mcp::server::ErrorData;
use sql_builder::prelude::*;
use windmill_common::db::UserDB;
use windmill_common::scripts::{get_full_hub_script_by_path, Schema};
+1 -1
View File
@@ -3,7 +3,7 @@
//! Contains functions for transforming Windmill schemas into MCP-compatible formats,
//! including resource enrichment and schema conversion utilities.
use rmcp::ErrorData;
use windmill_mcp::server::ErrorData;
use serde_json::Value;
use std::collections::HashMap;
use windmill_common::db::UserDB;
@@ -3,7 +3,7 @@
//! Contains utilities for parsing and matching MCP token scopes to determine
//! which scripts, flows, and endpoints a token has access to.
use rmcp::ErrorData;
use windmill_mcp::server::ErrorData;
/// Configuration for MCP scopes parsed from token scopes
#[derive(Debug, Clone, Default)]
+1 -1
View File
@@ -21,7 +21,7 @@ use hmac::Mac;
#[cfg(all(feature = "oauth2", not(feature = "private")))]
use itertools::Itertools;
#[cfg(all(feature = "oauth2", not(feature = "private")))]
use oauth2::{Client as OClient, *};
use windmill_oauth::{OClient, AccessToken, RefreshToken, Scope, helpers};
#[cfg(not(feature = "private"))]
use serde::{Deserialize, Serialize};
#[cfg(not(feature = "private"))]
+10 -5
View File
@@ -48,7 +48,7 @@ use windmill_common::{
};
pub fn workspaced_service() -> Router {
Router::new()
let router = Router::new()
.route("/list", get(list_resources))
.route("/list_search", get(list_search_resources))
.route("/list_names/:type", get(list_names))
@@ -75,8 +75,12 @@ pub fn workspaced_service() -> Router {
"/file_resource_type_to_file_ext_map",
get(file_resource_ext_to_resource_type),
)
.route("/type/create", post(create_resource_type))
.route("/mcp_tools/*path", get(get_mcp_tools))
.route("/type/create", post(create_resource_type));
#[cfg(feature = "mcp")]
let router = router.route("/mcp_tools/*path", get(get_mcp_tools));
router
}
pub fn public_service() -> Router {
@@ -1400,6 +1404,7 @@ where
}
/// Get list of tools from an MCP resource
#[cfg(feature = "mcp")]
async fn get_mcp_tools(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1431,11 +1436,11 @@ async fn get_mcp_tools(
// Parse MCP resource
let mcp_resource =
serde_json::from_str::<windmill_common::mcp_client::McpResource>(resource_value.0.get())
serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get())
.map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?;
// Create MCP client connection
let client = windmill_common::mcp_client::McpClient::from_resource(mcp_resource, &db, &w_id)
let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id)
.await
.map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?;
+198
View File
@@ -53,6 +53,7 @@ use windmill_common::users::COOKIE_NAME;
use windmill_common::users::{truncate_token, username_to_permissioned_as};
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::BASE_URL;
use windmill_common::{
auth::{get_folders_for_user, get_groups_for_user},
db::UserDB,
@@ -125,6 +126,9 @@ pub fn make_unauthed_service() -> Router {
.route("/login", post(login))
.route("/logout", post(logout).get(logout))
.route("/is_first_time_setup", get(is_first_time_setup))
.route("/request_password_reset", post(request_password_reset))
.route("/reset_password", post(reset_password))
.route("/is_smtp_configured", get(is_smtp_configured))
}
pub async fn maybe_refresh_folders(
@@ -3081,3 +3085,197 @@ async fn update_username_in_workpsace<'c>(
Ok(())
}
// Password Reset Types
#[derive(Deserialize)]
pub struct RequestPasswordReset {
pub email: String,
}
#[derive(Deserialize)]
pub struct ResetPassword {
pub token: String,
pub new_password: String,
}
#[derive(Serialize)]
pub struct PasswordResetResponse {
pub message: String,
}
// Password Reset Functions
/// Check if SMTP is configured
async fn is_smtp_configured(Extension(db): Extension<DB>) -> JsonResult<bool> {
let smtp = windmill_common::server::load_smtp_config(&db).await?;
Ok(Json(smtp.is_some()))
}
/// Request a password reset email
async fn request_password_reset(
Extension(db): Extension<DB>,
Json(req): Json<RequestPasswordReset>,
) -> Result<Json<PasswordResetResponse>> {
let email = req.email.to_lowercase();
// Check if SMTP is configured
let smtp = windmill_common::server::load_smtp_config(&db).await?;
let smtp = smtp.ok_or_else(|| {
Error::BadRequest("SMTP is not configured. Password reset is not available.".to_string())
})?;
// Check if user exists with password login type
let user_exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM password WHERE email = $1 AND login_type = 'password')",
&email
)
.fetch_one(&db)
.await?
.unwrap_or(false);
// Always return success to prevent email enumeration
// But only send email if user exists
if user_exists {
// Generate a secure token
let token = rd_string(32);
// Delete any existing tokens for this email
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
.execute(&db)
.await?;
// Insert new token with 1 hour expiration
sqlx::query!(
"INSERT INTO magic_link (email, token, expiration) VALUES ($1, $2, NOW() + INTERVAL '1 hour')",
&email,
&token
)
.execute(&db)
.await?;
// Get the base URL for the reset link
let base_url = BASE_URL.read().await.clone();
let base_url = if base_url.is_empty() {
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost".to_string())
} else {
base_url
};
let reset_link = format!("{}/user/reset-password?token={}", base_url, token);
// Send the email
let subject = "Windmill Password Reset";
let content = format!(
"You have requested a password reset for your Windmill account.\n\n\
Click the link below to reset your password:\n\
{}\n\n\
This link will expire in 1 hour.\n\n\
If you did not request this password reset, you can safely ignore this email.",
reset_link
);
// Send the email - don't fail the request if email fails
if let Err(e) = windmill_common::email_oss::send_email_plain_text(
subject,
&content,
vec![email.clone()],
smtp,
Some(Duration::from_secs(10)),
)
.await
{
tracing::error!("Failed to send password reset email to {}: {:?}", email, e);
}
}
// Always return success to prevent email enumeration
Ok(Json(PasswordResetResponse {
message: "If an account with that email exists, a password reset link has been sent."
.to_string(),
}))
}
/// Reset password using a token
async fn reset_password(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(req): Json<ResetPassword>,
) -> Result<Json<PasswordResetResponse>> {
let mut tx = db.begin().await?;
// Find the token and verify it's not expired
let magic_link = sqlx::query!(
"SELECT email FROM magic_link WHERE token = $1 AND expiration > NOW()",
&req.token
)
.fetch_optional(&mut *tx)
.await?;
let email = match magic_link {
Some(link) => link.email,
None => {
return Err(Error::BadRequest(
"Invalid or expired password reset token".to_string(),
))
}
};
// Hash the new password
let password_hash = crate::users_oss::hash_password(argon2, req.new_password)?;
// Update the password
let rows_updated = sqlx::query!(
"UPDATE password SET password_hash = $1 WHERE email = $2 AND login_type = 'password'",
&password_hash,
&email
)
.execute(&mut *tx)
.await?
.rows_affected();
if rows_updated == 0 {
return Err(Error::BadRequest(
"Unable to update password. User may not exist or may use a different login method."
.to_string(),
));
}
// Delete the used token and any other tokens for this email
sqlx::query!("DELETE FROM magic_link WHERE email = $1", &email)
.execute(&mut *tx)
.await?;
// Invalidate all existing sessions for this user
sqlx::query!(
"DELETE FROM token WHERE email = $1 AND label = 'session'",
&email
)
.execute(&mut *tx)
.await?;
// Audit log
let audit_author = AuditAuthor {
email: email.clone(),
username: email.clone(),
username_override: None,
token_prefix: None,
};
audit_log(
&mut *tx,
&audit_author,
"users.password_reset",
ActionKind::Update,
"global",
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(Json(PasswordResetResponse {
message: "Password has been reset successfully. You can now log in with your new password."
.to_string(),
}))
}
+8 -2
View File
@@ -55,6 +55,13 @@ pub async fn set_password(
))
}
#[cfg(not(feature = "private"))]
pub fn hash_password(_argon2: Arc<Argon2<'_>>, _password: String) -> Result<String> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
#[cfg(not(feature = "private"))]
pub fn send_email_if_possible(_subject: &str, _content: &str, _to: &str) {
tracing::warn!(
@@ -70,7 +77,6 @@ pub struct OnboardingData {
pub use_case: String,
}
#[cfg(not(feature = "private"))]
pub async fn submit_onboarding_data(
_authed: ApiAuthed,
@@ -80,4 +86,4 @@ pub async fn submit_onboarding_data(
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
}
+1 -4
View File
@@ -11,7 +11,6 @@ private = ["dep:aws-sdk-rds"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
prometheus = ["dep:prometheus"]
loki = ["dep:tracing-loki"]
benchmark = []
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
aws_auth = ["dep:aws-sdk-sts"]
@@ -59,7 +58,6 @@ itertools.workspace = true
regex.workspace = true
git-version.workspace = true
cron.workspace = true
tracing-loki = { version = "^0", optional = true }
magic-crypt.workspace = true
object_store = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
@@ -100,7 +98,7 @@ async-recursion.workspace = true
pep440_rs.workspace = true
semver.workspace = true
croner = "2.2.0"
croner.workspace = true
quick_cache.workspace = true
pin-project-lite.workspace = true
futures.workspace = true
@@ -108,7 +106,6 @@ tempfile.workspace = true
systemstat.workspace = true
size.workspace = true
globset.workspace = true
rmcp = { version = "0.12.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
opentelemetry-semantic-conventions = { workspace = true, optional = true }
opentelemetry-otlp = { workspace = true, optional = true }
-1
View File
@@ -64,7 +64,6 @@ pub mod git_sync_ee;
pub mod git_sync_oss;
pub mod jobs;
pub mod jwt;
pub mod mcp_client;
pub mod more_serde;
pub mod oauth2;
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "windmill-mcp"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_mcp"
path = "src/lib.rs"
[features]
default = []
server = ["rmcp/transport-streamable-http-server", "rmcp/transport-streamable-http-server-session", "rmcp/transport-worker"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
anyhow.workspace = true
reqwest = { version = "=0.12", features = ["json", "stream", "gzip"] }
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
rmcp.workspace = true
@@ -1,10 +1,19 @@
use crate::variables::get_secret_value_as_admin;
use crate::DB;
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use anyhow::{Context, Result};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::{json, Value};
use std::str::FromStr;
use windmill_common::variables::get_secret_value_as_admin;
use windmill_common::DB;
// Re-export rmcp types for client usage
pub use rmcp::model::Tool as McpTool;
use rmcp::{
model::{
@@ -18,6 +27,26 @@ use rmcp::{
RoleClient, ServiceExt,
};
// Re-export rmcp server types when server feature is enabled
#[cfg(feature = "server")]
pub mod server {
//! Re-exports of rmcp server types for MCP server implementations
pub use rmcp::handler::server::ServerHandler;
pub use rmcp::model::{
Annotated, CallToolRequestParam, CallToolResult, Content, Implementation,
InitializeRequestParam, InitializeResult, ListPromptsResult, ListResourceTemplatesResult,
ListResourcesResult, ListToolsResult, PaginatedRequestParam, ProtocolVersion, RawContent,
RawTextContent, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
};
pub use rmcp::service::{RequestContext, RoleServer};
pub use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpService,
};
pub use rmcp::transport::StreamableHttpServerConfig;
pub use rmcp::ErrorData;
}
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "windmill-oauth"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_oauth"
path = "src/lib.rs"
[features]
default = []
[dependencies]
windmill-common = { workspace = true, default-features = false }
async-oauth2.workspace = true
axum.workspace = true
tower-cookies.workspace = true
# Note: We use reqwest 0.12 via async-oauth2, not the workspace reqwest 0.13
reqwest = { version = "0.12", features = ["json"] }
sqlx.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
hmac.workspace = true
sha2.workspace = true
base64.workspace = true
hex.workspace = true
chrono.workspace = true
itertools.workspace = true
anyhow.workspace = true
lazy_static.workspace = true
tracing.workspace = true
+856
View File
@@ -0,0 +1,856 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! OAuth2 client and token management for Windmill.
//!
//! This crate provides OAuth2 functionality including:
//! - OAuth2 client configuration and building
//! - Token exchange and refresh
//! - Slack OAuth integration
//! - Client credentials flow support
use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc;
use anyhow::anyhow;
use base64::Engine;
use hmac::Mac;
use itertools::Itertools;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sqlx::{Postgres, Transaction};
use tokio::sync::RwLock;
use tower_cookies::{Cookie, Cookies};
use windmill_common::error::{self, to_anyhow, Error};
use windmill_common::more_serde::maybe_number_opt;
use windmill_common::oauth2::*;
use windmill_common::utils::now_from_db;
use windmill_common::variables::{build_crypt, encrypt};
pub type DB = sqlx::Pool<sqlx::Postgres>;
// Re-export oauth2 types that consumers need (also used internally)
pub use oauth2::{
AccessToken, AuthType, Client as OClient, RefreshToken, Scope, State, Url,
helpers,
};
// Re-export reqwest Client (version 0.12 compatible with async-oauth2)
pub use reqwest::Client as HttpClient;
lazy_static::lazy_static! {
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
pub static ref IS_SECURE: Arc<RwLock<bool>> = Arc::new(RwLock::new(false));
pub static ref COOKIE_DOMAIN: Option<String> = std::env::var("COOKIE_DOMAIN").ok();
/// HTTP client for OAuth operations (reqwest 0.12, compatible with async-oauth2)
pub static ref OAUTH_HTTP_CLIENT: reqwest::Client = reqwest::ClientBuilder::new()
.user_agent("windmill/oauth")
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to create OAuth HTTP client");
}
/// OAuth client with associated scopes and configuration
#[derive(Debug, Clone)]
pub struct ClientWithScopes {
pub display_name: Option<String>,
pub client: OClient,
pub scopes: Vec<String>,
pub extra_params: Option<HashMap<String, String>>,
pub extra_params_callback: Option<HashMap<String, String>>,
pub allowed_domains: Option<Vec<String>>,
pub userinfo_url: Option<String>,
pub grant_types: Vec<String>,
}
/// Map of OAuth client names to their configurations
pub type BasicClientsMap = HashMap<String, ClientWithScopes>;
/// OAuth provider configuration
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OAuthConfig {
#[serde(default = "empty_auth")]
pub auth_url: String,
#[serde(default = "empty_string")]
pub token_url: String,
pub userinfo_url: Option<String>,
pub scopes: Option<Vec<String>>,
pub extra_params: Option<HashMap<String, String>>,
pub extra_params_callback: Option<HashMap<String, String>>,
pub req_body_auth: Option<bool>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
}
/// OAuth client credentials
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OAuthClient {
#[serde(default = "empty_string")]
pub id: String,
#[serde(default = "empty_string")]
pub secret: String,
#[serde(default, deserialize_with = "windmill_common::utils::empty_as_none")]
pub display_name: Option<String>,
pub allowed_domains: Option<Vec<String>>,
pub connect_config: Option<OAuthConfig>,
pub login_config: Option<OAuthConfig>,
pub tenant: Option<String>,
#[serde(default = "default_grant_types")]
pub grant_types: Vec<String>,
}
fn empty_string() -> String {
"".to_string()
}
fn empty_auth() -> String {
"https://missing-auth-url".to_string()
}
fn default_grant_types() -> Vec<String> {
vec!["authorization_code".to_string()]
}
/// Container for all OAuth clients (login, connect, and slack)
#[derive(Debug)]
pub struct AllClients {
pub logins: BasicClientsMap,
pub connects: BasicClientsMap,
pub slack: Option<OClient>,
}
/// Slack token response from OAuth flow
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SlackTokenResponse {
pub access_token: AccessToken,
pub team_id: String,
pub team_name: String,
#[serde(rename = "scope")]
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub scopes: Option<Vec<Scope>>,
pub bot: SlackBotToken,
}
/// Standard OAuth token response
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TokenResponse {
pub access_token: AccessToken,
#[serde(deserialize_with = "maybe_number_opt")]
#[serde(default)]
pub expires_in: Option<u64>,
pub refresh_token: Option<RefreshToken>,
#[serde(deserialize_with = "helpers::deserialize_space_delimited_vec")]
#[serde(serialize_with = "helpers::serialize_space_delimited_vec")]
#[serde(default)]
pub scope: Option<Vec<Scope>>,
}
/// Slack bot token from OAuth response
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SlackBotToken {
pub bot_access_token: String,
}
/// OAuth callback parameters
#[derive(Deserialize)]
pub struct OAuthCallback {
pub code: String,
pub state: String,
}
/// Build all OAuth clients from configuration
pub async fn build_oauth_clients(
base_url: &str,
oauths_from_config: Option<HashMap<String, OAuthClient>>,
connect_configs_json: &str,
login_configs_json: &str,
) -> anyhow::Result<AllClients> {
let connect_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)?;
let login_configs = serde_json::from_str::<HashMap<String, OAuthConfig>>(login_configs_json)?;
let oauths = if let Some(oauths) = oauths_from_config {
tracing::info!("Using OAuth clients from config: {oauths:?}");
oauths
} else {
let path = "./oauth.json";
let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") {
std::str::from_utf8(
&base64::engine::general_purpose::STANDARD
.decode(e)
.map_err(to_anyhow)?,
)?
.to_string()
} else if std::path::Path::new(path).exists() {
std::fs::read_to_string(path).map_err(to_anyhow)?
} else {
tracing::warn!("oauth.json not found, no OAuth clients loaded");
return Ok(AllClients {
logins: HashMap::new(),
connects: HashMap::new(),
slack: None,
});
};
if content.is_empty() {
tracing::warn!("oauth.json is empty, no OAuth clients loaded");
return Ok(AllClients {
logins: HashMap::new(),
connects: HashMap::new(),
slack: None,
});
};
match serde_json::from_str::<HashMap<String, OAuthClient>>(&content) {
Ok(clients) => clients,
Err(e) => {
tracing::error!("deserializing oauth.json: {e}");
HashMap::new()
}
}
.into_iter()
.collect()
};
tracing::info!(
"OAuth loaded clients: {}",
oauths.keys().join(", ")
);
let logins = login_configs
.into_iter()
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
.chain(oauths.iter().filter_map(|x| {
x.1.login_config
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
client_params.clone(),
true,
base_url,
None,
);
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: client_params.allowed_domains.clone(),
userinfo_url: config.userinfo_url,
display_name: client_params.display_name.clone(),
grant_types: client_params.grant_types.clone(),
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
let connects = connect_configs
.into_iter()
.filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1))))
.chain(oauths.iter().filter_map(|x| {
x.1.connect_config
.as_ref()
.map(|c| (x.0.clone(), (x.1, c.clone())))
}))
.filter_map(|(k, (client_params, config))| {
let named_client = build_basic_client(
k.clone(),
config.clone(),
client_params.clone(),
false,
base_url,
if k == "supabase_wizard" {
Some(format!("{base_url}/oauth/callback_supabase"))
} else {
None
},
);
named_client
.map(|named_client| {
(
named_client.0,
ClientWithScopes {
client: named_client.1,
scopes: config.scopes.unwrap_or(vec![]),
extra_params: config.extra_params,
extra_params_callback: config.extra_params_callback,
allowed_domains: None,
userinfo_url: None,
display_name: client_params.display_name.clone(),
grant_types: client_params.grant_types.clone(),
},
)
})
.map_err(|e| {
tracing::error!("Error building oauth client {k}: {e}");
e
})
.ok()
})
.collect();
let slack = oauths
.get("slack")
.map(|v| {
build_basic_client(
"slack".to_string(),
OAuthConfig {
auth_url: "https://slack.com/oauth/authorize".to_string(),
token_url: "https://slack.com/api/oauth.access".to_string(),
userinfo_url: None,
scopes: None,
extra_params: None,
extra_params_callback: None,
req_body_auth: None,
grant_types: vec!["authorization_code".to_string()],
},
v.clone(),
false,
base_url,
Some(format!("{base_url}/oauth/callback_slack")),
)
.map(|x| x.1)
.map_err(|e| {
tracing::error!("Error building oauth slack client: {e}");
e
})
.ok()
})
.flatten();
let all_clients = AllClients { logins, connects, slack };
tracing::debug!("Final oauth config: {all_clients:#?}");
Ok(all_clients)
}
/// Build a basic OAuth client from configuration
pub fn build_basic_client(
name: String,
config: OAuthConfig,
client_params: OAuthClient,
login: bool,
base_url: &str,
override_callback: Option<String>,
) -> error::Result<(String, OClient)> {
let auth_url = Url::parse(&config.auth_url)
.map_err(|e| anyhow!("Invalid authorization endpoint URL: {e}"))?;
let token_url =
Url::parse(&config.token_url).map_err(|e| anyhow!("Invalid token endpoint URL: {e}"))?;
let redirect_url = if login {
format!("{base_url}/user/login_callback/{name}")
} else if let Some(callback) = override_callback {
callback
} else {
format!("{base_url}/oauth/callback/{name}")
};
let mut client = OClient::new(client_params.id, auth_url, token_url);
if config.req_body_auth.unwrap_or(false) {
client.set_auth_type(AuthType::RequestBody);
}
client.set_client_secret(client_params.secret.clone());
client.set_redirect_url(
Url::parse(&redirect_url).map_err(|e| anyhow!("Invalid redirect URL: {e}"))?,
);
Ok((name.to_string(), client))
}
/// Build a Slack OAuth client with custom credentials
pub async fn build_slack_client(
client_id: &str,
client_secret: &str,
_workspace_id: &str,
) -> error::Result<OClient> {
let auth_url = Url::parse("https://slack.com/oauth/authorize")
.map_err(|e| anyhow!("Invalid Slack authorization URL: {e}"))?;
let token_url = Url::parse("https://slack.com/api/oauth.access")
.map_err(|e| anyhow!("Invalid Slack token URL: {e}"))?;
let base_url = BASE_URL.read().await.clone();
let redirect_url = format!("{}/oauth/callback_slack", base_url);
let mut client = OClient::new(client_id.to_string(), auth_url, token_url);
client.set_client_secret(client_secret.to_string());
client.set_redirect_url(
Url::parse(&redirect_url).map_err(|e| anyhow!("Invalid redirect URL: {e}"))?,
);
Ok(client)
}
/// Build OAuth client for client credentials flow with resource-level credentials
pub async fn build_client_credentials_oauth_client(
db: &DB,
client_name: &str,
client_id: &str,
client_secret: &str,
cc_token_url_override: Option<&str>,
connect_configs_json: &str,
) -> error::Result<(OClient, OAuthClient)> {
use windmill_common::global_settings::{load_value_from_global_settings, OAUTH_SETTING};
let oauths = load_value_from_global_settings(db, OAUTH_SETTING).await?;
let oauths = oauths.unwrap_or_default();
let oauth_config = oauths
.get(client_name)
.ok_or_else(|| error::Error::BadRequest("OAuth configuration not found".to_string()))?;
let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone())
.map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?;
let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config {
if !config.auth_url.is_empty() && !config.token_url.is_empty() {
config.clone()
} else {
let static_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json)
.map_err(|e| {
error::Error::InternalErr(format!(
"Failed to parse oauth_connect.json: {}",
e
))
})?;
static_configs.get(client_name).cloned().ok_or_else(|| {
error::Error::BadRequest(format!(
"OAuth configuration not found for '{}' in either global settings or static config",
client_name
))
})?
}
} else {
let static_configs =
serde_json::from_str::<HashMap<String, OAuthConfig>>(connect_configs_json).map_err(
|e| {
error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e))
},
)?;
static_configs.get(client_name).cloned().ok_or_else(|| {
error::Error::BadRequest(format!(
"OAuth configuration not found for '{}' in either global settings or static config",
client_name
))
})?
};
if let Some(override_url) = cc_token_url_override {
connect_config.token_url = override_url.to_string();
}
let resource_oauth_client = OAuthClient {
id: client_id.to_string(),
secret: client_secret.to_string(),
allowed_domains: oauth_client_config.allowed_domains.clone(),
connect_config: Some(connect_config.clone()),
login_config: oauth_client_config.login_config.clone(),
display_name: oauth_client_config.display_name.clone(),
grant_types: oauth_client_config.grant_types.clone(),
tenant: oauth_client_config.tenant.clone(),
};
let base_url = BASE_URL.read().await.clone();
let (_, client) = build_basic_client(
client_name.to_string(),
connect_config,
resource_oauth_client,
false,
&base_url,
None,
)?;
Ok((client, oauth_client_config))
}
/// Exchange authorization code for tokens
pub async fn exchange_code<T: DeserializeOwned>(
callback: OAuthCallback,
cookies: &Cookies,
client: OClient,
extra_params: Option<HashMap<String, String>>,
http_client: &reqwest::Client,
) -> error::Result<T> {
let name = if COOKIE_DOMAIN.is_some() {
"csrf_domain"
} else {
"csrf"
};
let csrf_state = cookies
.get(name)
.map(|x| x.value().to_string())
.unwrap_or("".to_string());
if callback.state != csrf_state {
return Err(error::Error::BadRequest("csrf did not match".to_string()));
}
let mut token_url = client.exchange_code(callback.code);
if let Some(extra_params) = extra_params {
for (key, value) in extra_params {
token_url = token_url.param(key, value)
}
}
token_url
.with_client(http_client)
.execute::<T>()
.await
.map_err(|e| error::Error::InternalErr(format!("{:?}", e)))
}
/// Internal token exchange implementation
pub async fn exchange_token(
client: OClient,
refresh_token: &str,
grant_type: &str,
oauth_client_info: Option<&ClientWithScopes>,
http_client: &reqwest::Client,
) -> Result<TokenResponse, Error> {
let token_json = match grant_type {
"authorization_code" => {
client
.exchange_refresh_token(&RefreshToken::from(refresh_token))
.with_client(http_client)
.execute::<serde_json::Value>()
.await
.map_err(to_anyhow)?
}
"client_credentials" => {
let mut token_request = client.exchange_client_credentials();
if let Some(oauth_info) = oauth_client_info {
if let Some(extra_params) = oauth_info.extra_params_callback.as_ref() {
for (key, value) in extra_params.iter() {
token_request = token_request.param(key.clone(), value.clone());
}
}
}
token_request
.with_client(http_client)
.execute::<serde_json::Value>()
.await
.map_err(to_anyhow)?
}
"" | _ if grant_type.is_empty() => {
client
.exchange_refresh_token(&RefreshToken::from(refresh_token))
.with_client(http_client)
.execute::<serde_json::Value>()
.await
.map_err(to_anyhow)?
}
_ => {
return Err(Error::BadRequest(format!(
"Unsupported grant type: {}",
grant_type
)))
}
};
let token = serde_json::from_value::<TokenResponse>(token_json.clone()).map_err(|e| {
Error::BadConfig(format!(
"Error deserializing response as a new token: {e}\nresponse:{token_json}"
))
})?;
Ok(token)
}
/// Refresh an OAuth token and update the database
pub async fn refresh_token<'c>(
mut tx: Transaction<'c, Postgres>,
path: &str,
w_id: &str,
id: i32,
db: &DB,
oauth_clients: &AllClients,
http_client: &reqwest::Client,
connect_configs_json: &str,
) -> error::Result<String> {
let account = sqlx::query!(
"SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2",
w_id,
id,
)
.fetch_optional(&mut *tx)
.await?;
let account = windmill_common::utils::not_found_if_none(account, "Account", &id.to_string())?;
let oauth_client_info = oauth_clients
.connects
.get(&account.client)
.ok_or_else(|| error::Error::BadRequest("invalid client".to_string()))?
.clone();
let mut client = if account.grant_type == "client_credentials" {
match (&account.cc_client_id, &account.cc_client_secret) {
(Some(client_id), Some(client_secret)) => {
let (client, _) = build_client_credentials_oauth_client(
db,
&account.client,
client_id,
client_secret,
account.cc_token_url.as_deref(),
connect_configs_json,
)
.await?;
client
}
_ => {
return Err(error::Error::BadRequest(
"client_credentials flow requires cc_client_id and cc_client_secret to be stored in account".to_string()
));
}
}
} else {
oauth_client_info.client.to_owned()
};
if account.grant_type == "client_credentials" {
for scope in oauth_client_info.scopes.iter() {
client.add_scope(scope);
}
}
tracing::info!(
grant_type = %account.grant_type,
client = %account.client,
workspace_id = %w_id,
account_id = %id,
"Refreshing OAuth token"
);
let token = exchange_token(
client,
&account.refresh_token,
&account.grant_type,
Some(&oauth_client_info),
http_client,
)
.await;
if let Err(token_err) = token {
sqlx::query!(
"UPDATE account SET refresh_error = $1 WHERE workspace_id = $2 AND id = $3",
token_err.alt(),
w_id,
id,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
return Err(error::Error::BadRequest(format!(
"Error refreshing token: {}",
token_err.alt()
)));
};
let token = token.unwrap();
let expires_at = now_from_db(&mut *tx).await?
+ chrono::Duration::try_seconds(
token
.expires_in
.ok_or_else(|| Error::InternalErr("expires_in expected and not found".to_string()))?
.try_into()
.unwrap(),
)
.unwrap_or_default();
sqlx::query!(
"UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL WHERE workspace_id = $3 AND id = $4",
token
.refresh_token
.map(|x| x.to_string())
.unwrap_or(account.refresh_token),
expires_at,
w_id,
id,
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
let token_str = token.access_token.to_string();
let mc = build_crypt(db, w_id).await?;
let encrypted_token = encrypt(&mc, token_str.as_str());
sqlx::query!(
"UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3",
encrypted_token,
w_id,
path
)
.execute(db)
.await?;
tracing::info!(
grant_type = %account.grant_type,
client = %account.client,
workspace_id = %w_id,
account_id = %id,
"OAuth token refreshed successfully"
);
Ok(token_str)
}
/// Generate OAuth redirect URL with CSRF protection
pub fn oauth_redirect(
clients: &HashMap<String, ClientWithScopes>,
client_name: String,
cookies: Cookies,
scopes: Option<Vec<String>>,
extra_params: Option<HashMap<String, String>>,
is_secure: bool,
) -> error::Result<axum::response::Redirect> {
let client_w_scopes = clients
.get(&client_name)
.ok_or_else(|| error::Error::BadRequest("client not found".to_string()))?;
let state = State::new_random();
let mut client = client_w_scopes.client.clone();
let scopes_iter = if let Some(scopes) = scopes {
scopes
} else {
client_w_scopes.scopes.clone()
};
for scope in scopes_iter.iter() {
client.add_scope(scope);
}
let mut auth_url = client.authorize_url(&state);
if let Some(extra_params) = extra_params {
let mut query_string = auth_url.query_pairs_mut();
for (key, value) in extra_params {
query_string.append_pair(&key, &value);
}
}
set_csrf_cookie(&state, cookies, is_secure);
Ok(axum::response::Redirect::to(auth_url.as_str()))
}
/// Set CSRF cookie for OAuth state verification
pub fn set_csrf_cookie(state: &State, cookies: Cookies, is_secure: bool) {
let csrf = state.to_base64();
let name = if COOKIE_DOMAIN.is_some() {
"csrf_domain".to_string()
} else {
"csrf".to_string()
};
let mut cookie = Cookie::new(name, csrf);
cookie.set_secure(is_secure);
cookie.set_same_site(Some(tower_cookies::cookie::SameSite::Lax));
cookie.set_http_only(true);
cookie.set_path("/");
if COOKIE_DOMAIN.is_some() {
cookie.set_domain(COOKIE_DOMAIN.clone().unwrap());
}
cookies.add(cookie);
}
/// Slack signature verifier for webhook authentication
#[derive(Clone, Debug)]
pub struct SlackVerifier {
mac: HmacSha256,
}
impl SlackVerifier {
pub fn new<S: AsRef<[u8]>>(secret: S) -> anyhow::Result<SlackVerifier> {
HmacSha256::new_from_slice(secret.as_ref())
.map(|mac| SlackVerifier { mac })
.map_err(|_| anyhow::anyhow!("invalid secret"))
}
pub fn verify(&self, ts: &str, body: &str, exp_sig: &str) -> anyhow::Result<()> {
let basestring = format!("v0:{}:{}", ts, body);
let mut mac = self.mac.clone();
mac.update(basestring.as_bytes());
let sig = format!("v0={}", hex::encode(mac.finalize().into_bytes()));
if sig != exp_sig {
Err(anyhow::anyhow!("signature mismatch"))?;
}
Ok(())
}
}
/// Fetch user info from OAuth provider
pub async fn http_get_user_info<T: DeserializeOwned>(
http_client: &reqwest::Client,
url: &str,
token: &str,
) -> error::Result<T> {
let res = http_client
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(to_anyhow)
.map_err(|e| error::Error::InternalErr(format!("failed to fetch user info: {}", e)))?;
if !res.status().is_success() {
tracing::debug!(
"The bearer token of the failed oauth user info exchange is: {}",
token
);
return Err(error::Error::BadConfig(format!(
"The user info endpoint responded with non 200: {}\n{}\n{}",
res.status(),
res.headers()
.iter()
.map(|x| format!("{}: {}", x.0.as_str(), x.1.to_str().unwrap_or_default()))
.collect::<Vec<_>>()
.join("\n"),
res.text().await.unwrap_or_default(),
)));
}
Ok(res
.json::<T>()
.await
.map_err(to_anyhow)
.map_err(|e| error::Error::InternalErr(format!("failed to decode json from user info: {}", e)))?)
}
/// GitHub email info response
#[derive(Deserialize)]
pub struct GHEmailInfo {
pub email: String,
pub verified: bool,
pub primary: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slack_verifier() {
let verifier = SlackVerifier::new("test_secret").unwrap();
assert!(verifier.verify("123", "body", "wrong_sig").is_err());
}
}
+2
View File
@@ -11,6 +11,7 @@ path = "src/lib.rs"
[features]
default = []
private = []
mcp = ["dep:windmill-mcp"]
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util"]
mssql = ["dep:tiberius"]
@@ -40,6 +41,7 @@ duckdb = ["dep:libloading"]
windmill-queue.workspace = true
windmill-audit.workspace = true # there isn't really a reason for audit-worth actions to happen in the worker.
windmill-common = { workspace = true, default-features = false }
windmill-mcp = { workspace = true, optional = true }
windmill-macros.workspace = true
windmill-parser.workspace = true
windmill-parser-ts.workspace = true
+10 -1
View File
@@ -22,7 +22,16 @@ use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
use windmill_common::flows::InputTransform;
use windmill_common::jobs::JobPayload;
use windmill_common::mcp_client::{McpClient, McpToolSource};
use crate::ai::types::McpToolSource;
#[cfg(feature = "mcp")]
use windmill_mcp::McpClient;
#[cfg(not(feature = "mcp"))]
pub struct McpClientStub;
#[cfg(not(feature = "mcp"))]
type McpClient = McpClientStub;
use windmill_common::{
client::AuthedClient,
db::DB,
+11 -1
View File
@@ -3,7 +3,17 @@ use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::collections::HashMap;
use uuid::Uuid;
use windmill_common::mcp_client::McpToolSource;
#[cfg(feature = "mcp")]
pub use windmill_mcp::McpToolSource;
/// Stub type when mcp feature is not enabled
#[cfg(not(feature = "mcp"))]
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct McpToolSource {
pub name: String,
pub tool_name: String,
pub resource_path: String,
}
use windmill_common::{
ai_providers::AIProvider, db::DB, error::Error, flow_status::AgentAction, flows::FlowModule,
s3_helpers::S3Object,
+44 -5
View File
@@ -1,4 +1,5 @@
use crate::ai::types::{ToolDef, ToolDefFunction};
pub use crate::ai::types::McpToolSource;
use crate::ai::types::ToolDef;
use anyhow::Context;
use serde_json::value::RawValue;
use sqlx::types::Json;
@@ -7,6 +8,7 @@ use std::{
sync::Arc,
};
use uuid::Uuid;
use windmill_common::flows::FlowModuleValue;
use windmill_common::{
ai_providers::AIProvider,
db::DB,
@@ -18,10 +20,8 @@ use windmill_common::{
scripts::{ScriptHash, ScriptLang},
worker::to_raw_value,
};
use windmill_common::{
flows::FlowModuleValue,
mcp_client::{McpClient, McpResource, McpTool, McpToolSource},
};
#[cfg(feature = "mcp")]
use windmill_mcp::{McpClient, McpResource, McpTool};
use windmill_queue::{flow_status::get_step_of_flow_status, MiniPulledJob};
use crate::{ai::types::*, parse_sig_of_lang};
@@ -322,6 +322,7 @@ pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) ->
}
/// Cleanup MCP clients by gracefully shutting down connections
#[cfg(feature = "mcp")]
pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
if mcp_clients.is_empty() {
return;
@@ -351,6 +352,7 @@ pub async fn cleanup_mcp_clients(mcp_clients: HashMap<String, Arc<McpClient>>) {
}
/// Convert raw MCP tools to Windmill Tool format with source tracking
#[cfg(feature = "mcp")]
fn convert_mcp_tools_to_windmill_tools(
mcp_tools: &[McpTool],
resource_name: &str,
@@ -396,6 +398,7 @@ fn convert_mcp_tools_to_windmill_tools(
}
/// Configuration for loading tools from an MCP server resource
#[cfg(feature = "mcp")]
#[derive(Debug, Clone)]
pub struct McpResourceConfig {
pub resource_path: String,
@@ -408,6 +411,7 @@ pub struct McpResourceConfig {
/// - If include_tools is Some and non-empty: whitelist approach (keep only listed tools)
/// - Else if exclude_tools is Some and non-empty: blacklist approach (remove listed tools)
/// - Otherwise: no filtering (keep all tools)
#[cfg(feature = "mcp")]
fn apply_tool_filters(
tools: Vec<Tool>,
include_tools: &Option<Vec<String>>,
@@ -449,6 +453,7 @@ fn apply_tool_filters(
/// Load tools from MCP servers and return both the clients and tools
/// Returns a map of resource name -> client, and a vector of tools
#[cfg(feature = "mcp")]
pub async fn load_mcp_tools(
db: &DB,
workspace_id: &str,
@@ -517,6 +522,7 @@ pub async fn load_mcp_tools(
}
/// Execute an MCP tool by routing the call to the appropriate MCP client
#[cfg(feature = "mcp")]
pub async fn execute_mcp_tool(
mcp_clients: &HashMap<String, Arc<McpClient>>,
mcp_source: &McpToolSource,
@@ -539,6 +545,39 @@ pub async fn execute_mcp_tool(
Ok(result)
}
// Stub implementations when mcp feature is not enabled
#[cfg(not(feature = "mcp"))]
pub struct McpResourceConfig {}
/// Stub for cleanup_mcp_clients when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn cleanup_mcp_clients<T>(_mcp_clients: HashMap<String, Arc<T>>) {
// No-op when MCP is disabled
}
/// Stub for load_mcp_tools when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn load_mcp_tools<T>(
_db: &DB,
_workspace_id: &str,
_mcp_configs: Vec<McpResourceConfig>,
) -> Result<(HashMap<String, Arc<T>>, Vec<Tool>), Error> {
Ok((HashMap::new(), Vec::new()))
}
/// Stub for execute_mcp_tool when mcp is not enabled
#[cfg(not(feature = "mcp"))]
pub async fn execute_mcp_tool<T>(
_mcp_clients: &HashMap<String, Arc<T>>,
mcp_source: &McpToolSource,
_arguments_str: &str,
) -> Result<serde_json::Value, Error> {
Err(Error::internal_err(format!(
"MCP support is not enabled. Cannot execute MCP tool: {}",
mcp_source.tool_name
)))
}
/// Check if any tool's input transforms reference previous_result
pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
tools.iter().any(|tool| {
+28 -13
View File
@@ -13,7 +13,11 @@ use regex::Regex;
use serde_json::value::RawValue;
use std::{collections::HashMap, sync::Arc};
use uuid::Uuid;
use windmill_common::mcp_client::McpClient;
#[cfg(feature = "mcp")]
use windmill_mcp::McpClient;
#[cfg(not(feature = "mcp"))]
use crate::ai::tools::McpClientStub as McpClient;
use windmill_common::{
ai_providers::AIProvider,
cache,
@@ -149,24 +153,34 @@ pub async fn handle_ai_agent_job(
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
let mut windmill_modules: Vec<FlowModule> = Vec::new();
#[allow(unused_mut)]
let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new();
let mut has_websearch = false;
for tool in tools {
match &tool.value {
#[allow(unused_variables)]
ToolValue::Mcp(mcp_config) => {
// This is an MCP tool - extract config
tracing::debug!(
"MCP server module: path={}, include={:?}, exclude={:?}",
mcp_config.resource_path,
mcp_config.include_tools,
mcp_config.exclude_tools
);
mcp_configs.push(crate::ai::utils::McpResourceConfig {
resource_path: mcp_config.resource_path.clone(),
include_tools: Some(mcp_config.include_tools.clone()),
exclude_tools: Some(mcp_config.exclude_tools.clone()),
});
#[cfg(feature = "mcp")]
{
// This is an MCP tool - extract config
tracing::debug!(
"MCP server module: path={}, include={:?}, exclude={:?}",
mcp_config.resource_path,
mcp_config.include_tools,
mcp_config.exclude_tools
);
mcp_configs.push(crate::ai::utils::McpResourceConfig {
resource_path: mcp_config.resource_path.clone(),
include_tools: Some(mcp_config.include_tools.clone()),
exclude_tools: Some(mcp_config.exclude_tools.clone()),
});
}
#[cfg(not(feature = "mcp"))]
{
tracing::warn!("MCP tool detected but MCP feature is not enabled");
}
}
ToolValue::FlowModule(_) => {
// Regular Windmill flow module (script, flow, etc.) - convert to FlowModule
@@ -299,6 +313,7 @@ pub async fn handle_ai_agent_job(
// Load MCP tools if configured
let mut tools = tools;
let mcp_clients = if !mcp_configs.is_empty() {
let (clients, mcp_tools) = load_mcp_tools(db, &job.workspace_id, mcp_configs).await?;
tools.extend(mcp_tools);
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.602.0";
export const VERSION = "v1.603.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -70,7 +70,7 @@ export {
// }
// });
export const VERSION = "1.602.0";
export const VERSION = "1.603.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
+2
View File
@@ -68,6 +68,8 @@ services:
- DATABASE_URL=${DATABASE_URL}
- MODE=worker
- WORKER_GROUP=default
# If running with non-root/non-windmill UID (e.g., user: "1001:1001"),
# add: - HOME=/tmp
# Uncomment to enable PID namespace isolation (requires privileged: true above)
# - ENABLE_UNSHARE_PID=true
depends_on:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.602.0",
"version": "1.603.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.602.0",
"version": "1.603.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.602.0",
"version": "1.603.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+22
View File
@@ -84,6 +84,7 @@
let showPassword = $state(false)
let logins: OAuthLogin[] | undefined = $state(undefined)
let saml: string | undefined = $state(undefined)
let smtpConfigured: boolean | undefined = $state(undefined)
type OAuthLogin = {
type: string
@@ -194,6 +195,17 @@
loadLogins()
async function checkSmtpConfigured() {
try {
smtpConfigured = await UserService.isSmtpConfigured()
} catch (err) {
console.error('Could not check if SMTP is configured', err)
smtpConfigured = false
}
}
checkSmtpConfigured()
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
@@ -372,6 +384,16 @@
autocomplete="current-password"
/>
</div>
{#if smtpConfigured}
<div class="text-right pt-1">
<a
href="{base}/user/forgot-password"
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
Forgot password?
</a>
</div>
{/if}
</div>
<div class="pt-2">
@@ -34,7 +34,7 @@
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
import MenuButton, { sidebarClasses } from './MenuButton.svelte'
import MenuLink from './MenuLink.svelte'
import { onDestroy } from 'svelte'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
let darkMode: boolean = $state(false)
interface Props {
@@ -78,7 +78,13 @@
)
)
let secondMenuLinks = $derived(
type SecondMenuLink = { label: string; id: string; href: string }
function filterLink(link: SecondMenuLink) {
if (!$userWorkspaces || !$workspaceStore) return false
let userWorkspace = $userWorkspaces.find((_) => _.id === $workspaceStore)
return userWorkspace?.operator_settings?.[link.id] === true
}
let secondMenuLinks: SecondMenuLink[] = $derived(
[
{
label: 'Resources',
@@ -95,6 +101,25 @@
id: 'assets',
href: `${base}/assets`
},
{
label: 'Groups',
id: 'groups',
href: `${base}/groups`
},
{
label: 'Folders',
id: 'folders',
href: `${base}/folders`
},
{
label: 'Workers',
id: 'workers',
href: `${base}/workers`
}
].filter(filterLink)
)
let secondMenuTriggerLinks = $derived(
[
{
label: 'Custom HTTP routes',
id: 'triggers',
@@ -144,52 +169,15 @@
label: 'Audit logs',
id: 'audit_logs',
href: `${base}/audit_logs`
},
{
label: 'Groups',
id: 'groups',
href: `${base}/groups`
},
{
label: 'Folders',
id: 'folders',
href: `${base}/folders`
},
{
label: 'Workers',
id: 'workers',
href: `${base}/workers`
}
].filter((link) => {
if (!$userWorkspaces || !$workspaceStore) return false
return (
$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.[link.id] === true
)
})
].filter(filterLink)
)
let moreOpen = $state(false)
let moreOpenTimeout: number | undefined = $state()
function debouncedSetMoreOpen(value: boolean) {
if (moreOpenTimeout) {
clearTimeout(moreOpenTimeout)
}
moreOpenTimeout = setTimeout(() => {
moreOpen = value
}, 150) // 150ms debounce
}
onDestroy(() => {
if (moreOpenTimeout) {
clearTimeout(moreOpenTimeout)
}
})
let showMore = $state(false)
</script>
<Menubar>
{#snippet children({ createMenu })}
<Menu {createMenu} usePointerDownOutside>
<Menu {createMenu} usePointerDownOutside on:close={() => (showMore = false)}>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-xs"
@@ -305,8 +293,8 @@
onClick={() => logout()}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 w-full',
'text-secondary text-xs',
'hover:bg-surface-hover hover:text-primary cursor-pointer',
'text-primary text-xs',
'hover:bg-surface-hover cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
@@ -315,57 +303,42 @@
Sign out
</MenuItem>
</div>
<div
onmouseenter={() => debouncedSetMoreOpen(true)}
onmouseleave={() => debouncedSetMoreOpen(false)}
role="none"
>
<MenuItem
onFocusIn={() => debouncedSetMoreOpen(true)}
onFocusOut={() => debouncedSetMoreOpen(false)}
{item}
>
{#if !moreOpen || secondMenuLinks.length === 0}
<div class="px-2 py-2 text-primary text-2xs">More...</div>
{/if}
</MenuItem>
{#if moreOpen && secondMenuLinks.length > 0}
{#each secondMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<div>
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
onFocusIn={() => debouncedSetMoreOpen(true)}
onFocusOut={() => debouncedSetMoreOpen(false)}
>
{menuLink.label}
</MenuItem>
</div>
{/each}
<div onmouseleave={() => (showMore = false)} role="none">
{#if secondMenuLinks.length}
<ResizeTransitionWrapper vertical innerClass="w-full">
{#if !showMore}
<div onmouseenter={() => (showMore = true)} role="none">
<MenuItem {item}>
<div class="px-2 py-2 text-primary text-2xs">More...</div>
</MenuItem>
</div>
{:else}
{#snippet renderSecondMenuLinks(menuLinks: SecondMenuLink[])}
{#each menuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuItem
href={menuLink.href}
class={twMerge(
'flex flex-row gap-3.5 items-center px-2 py-2 text-secondary text-2xs hover:bg-surface-hover hover:text-primary cursor-pointer',
'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary'
)}
{item}
>
{menuLink.label}
</MenuItem>
{/each}
{/snippet}
<div class="divide-y">
<div>{@render renderSecondMenuLinks(secondMenuLinks)}</div>
<div>{@render renderSecondMenuLinks(secondMenuTriggerLinks)}</div>
</div>
{/if}
</ResizeTransitionWrapper>
{/if}
{#if $enterpriseLicense}
<MultiplayerMenu />
{/if}
</div>
</div>
{#if $enterpriseLicense}
<div
onmouseenter={() => {
if (moreOpenTimeout) {
setTimeout(() => {
clearTimeout(moreOpenTimeout)
}, 15)
}
}}
onmouseleave={() => {
debouncedSetMoreOpen(false)
}}
role="none"
>
<MultiplayerMenu />
</div>
{/if}
{/snippet}
</Menu>
{/snippet}
@@ -35,6 +35,7 @@
neverShowLoader?: boolean
loading?: boolean
loadingMore?: boolean
containerClass?: string
children?: import('svelte').Snippet
emptyMessage?: import('svelte').Snippet
}
@@ -59,6 +60,7 @@
neverShowLoader = false,
loading = false,
loadingMore = false,
containerClass = '',
children,
emptyMessage
}: Props = $props()
@@ -119,7 +121,8 @@
class={twMerge(
'h-full',
rounded ? 'rounded-md overflow-hidden' : '',
noBorder ? 'border-0' : 'border'
noBorder ? 'border-0' : 'border',
containerClass
)}
bind:clientHeight={tableHeight}
>
@@ -352,18 +352,12 @@
class="cursor-not-allowed"
>
<svelte:fragment slot="trigger">
<ExploreAssetButton
class="h-9"
asset={{ kind: 'ducklake', path: ducklake.name }}
{dbManagerDrawer}
disabled
/>
<ExploreAssetButton asset={{ kind: 'ducklake', path: '' }} disabled />
</svelte:fragment>
<svelte:fragment slot="content">Please save settings first</svelte:fragment>
</Popover>
{:else}
<ExploreAssetButton
class="h-9"
asset={{ kind: 'ducklake', path: ducklake.name }}
{dbManagerDrawer}
/>
@@ -1,11 +1,9 @@
<script lang="ts">
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { emptyString, sendUserToast } from '$lib/utils'
import { emptyString, pick, sendUserToast } from '$lib/utils'
import { ChevronDown, Plus, Shield } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
import Button from '../common/button/Button.svelte'
import Tab from '../common/tabs/Tab.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import Description from '../Description.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Toggle from '../Toggle.svelte'
@@ -25,11 +23,22 @@
import CloseButton from '../common/CloseButton.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Cell from '../table/Cell.svelte'
import Row from '../table/Row.svelte'
import { deepEqual } from 'fast-equals'
import ExploreAssetButton from '../ExploreAssetButton.svelte'
let {
s3ResourceSettings = $bindable(),
s3ResourceSavedSettings,
onSave = undefined
}: { s3ResourceSettings: S3ResourceSettings; onSave?: () => void } = $props()
}: {
s3ResourceSettings: S3ResourceSettings
s3ResourceSavedSettings: S3ResourceSettings
onSave?: () => void
} = $props()
let s3FileViewer: S3FilePicker | undefined = $state()
@@ -45,6 +54,43 @@
sendUserToast(`Large file storage settings changed`)
onSave?.()
}
let tableHeadNames = ['Name', 'Storage resource', '', ''] as const
let tableHeadTooltips: Partial<Record<(typeof tableHeadNames)[number], string | undefined>> = {
'Storage resource':
'Which resource the workspace storage will point to. Note that all users of the workspace will be able to access the workspace storage regardless of the resource visibility.'
}
let tableRows: [string | null, S3ResourceSettingsItem][] = $derived([
[null, s3ResourceSettings],
...(s3ResourceSettings.secondaryStorage ?? [])
])
let secondaryStorageIsDirty: Record<string, boolean> = $derived(
Object.fromEntries(
s3ResourceSettings.secondaryStorage?.map((d) => {
const saved = s3ResourceSavedSettings.secondaryStorage?.find((saved) => saved[0] === d[0])
return [d[0], !deepEqual(saved?.[1], d[1])] as const
}) ?? []
)
)
let primaryStorageIsDirty: boolean = $derived(
!deepEqual(
pick(s3ResourceSavedSettings, [
'resourcePath',
'resourceType',
'publicResource',
'advancedPermissions'
]),
pick(s3ResourceSettings, [
'resourcePath',
'resourceType',
'publicResource',
'advancedPermissions'
])
)
)
function isDirty(name: string | null): boolean {
return name === null ? primaryStorageIsDirty : secondaryStorageIsDirty[name]
}
</script>
<Portal name="workspace-settings">
@@ -82,140 +128,127 @@
</Alert>
{/if}
{#if s3ResourceSettings}
<div class="mt-5">
<div class="w-full">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<Tabs bind:selected={s3ResourceSettings.resourceType}>
<Tab exact label="S3" value="s3" />
<Tab value="azure_blob" label="Azure Blob" />
<Tab exact value="s3_aws_oidc" label="AWS OIDC" />
<Tab value="azure_workload_identity" label="Azure Workload Identity" />
<Tab exact value="gcloud_storage" label="Google Cloud Storage" />
</Tabs>
</div>
<div class="w-full flex gap-1 mt-4 whitespace-nowrap">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<ResourcePicker
resourceType={s3ResourceSettings.resourceType}
bind:value={s3ResourceSettings.resourcePath}
/>
{@render permissionBtn(s3ResourceSettings)}
<Button
size="sm"
variant="accent"
disabled={emptyString(s3ResourceSettings.resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.(undefined)
}
}}>Browse content (save first)</Button
>
</div>
</div>
<DataTable containerClass="mt-4">
<Head>
<tr>
{#each tableHeadNames as name, i}
<Cell head first={i == 0} last={i == tableHeadNames.length - 1}>
{name}
{#if tableHeadTooltips[name]}
<Tooltip>{@html tableHeadTooltips[name]}</Tooltip>
{/if}
</Cell>
{/each}
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each tableRows as tableRow, idx}
<Row>
<Cell first class="w-48 relative">
{#if tableRow[0] === null}
<TextInput inputProps={{ placeholder: 'Primary storage', disabled: true }} />
{:else}
<TextInput bind:value={tableRow[0]} inputProps={{ placeholder: 'Name' }} />
{/if}
</Cell>
<Cell>
<div class="flex gap-2">
<div class="relative">
<Select
items={[
{ value: 's3', label: 'S3' },
{ value: 'azure_blob', label: 'Azure Blob' },
{ value: 's3_aws_oidc', label: 'AWS OIDC' },
{ value: 'azure_workload_identity', label: 'Azure Workload Identity' },
{ value: 'gcloud_storage', label: 'Google Cloud Storage' }
]}
bind:value={tableRow[1].resourceType}
class="w-28"
/>
</div>
<div class="flex flex-1">
<ResourcePicker
class="flex-1"
bind:value={tableRow[1].resourcePath}
resourceType={tableRow[1].resourceType}
/>
</div>
</div>
</Cell>
<div class="mt-6">
<div class="flex mt-2 flex-col gap-y-4 max-w-5xl">
{#each s3ResourceSettings.secondaryStorage ?? [] as _, idx}
<div class="flex gap-1 relative whitespace-nowrap">
<TextInput
class="max-w-[200px]"
inputProps={{ type: 'text', placeholder: 'Storage name' }}
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[0] || '',
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][0] = v
}
}
}
/>
<Select
class="max-w-[125px]"
inputClass="h-full"
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3',
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][1].resourceType = v
}
}
}
items={[
{ value: 's3', label: 'S3' },
{ value: 'azure_blob', label: 'Azure Blob' },
{ value: 's3_aws_oidc', label: 'AWS OIDC' },
{ value: 'azure_workload_identity', label: 'Azure Workload Identity' },
{ value: 'gcloud_storage', label: 'Google Cloud Storage' }
]}
/>
<ResourcePicker
resourceType={s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3'}
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath || undefined,
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][1].resourcePath = v
}
}
}
/>
{@render permissionBtn(s3ResourceSettings.secondaryStorage![idx][1])}
<Button
size="sm"
variant="accent"
disabled={emptyString(s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.({
s3: '',
storage: s3ResourceSettings.secondaryStorage?.[idx]?.[0] || ''
})
}
}}>Browse content (save first)</Button
>
<CloseButton
class="my-auto"
small
on:close={() => {
if (s3ResourceSettings.secondaryStorage) {
s3ResourceSettings.secondaryStorage.splice(idx, 1)
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
}
}}
/>
</div>
<Cell class="w-12">
<div class="flex gap-2">
{@render permissionBtn(tableRow[1])}
{#if emptyString(tableRow[1].resourcePath) || isDirty(tableRow[0])}
<Popover
openOnHover
contentClasses="p-2 text-sm text-secondary italic"
class="cursor-not-allowed"
>
<svelte:fragment slot="trigger">
<ExploreAssetButton asset={{ kind: 's3object', path: '' }} disabled />
</svelte:fragment>
<svelte:fragment slot="content">Please save settings first</svelte:fragment>
</Popover>
{:else}
<ExploreAssetButton
asset={{ kind: 's3object', path: (tableRow[0] ?? '') + '/' }}
s3FilePicker={s3FileViewer}
/>
{/if}
</div>
</Cell>
<Cell class="w-12">
{#if tableRow[0] !== null}
<CloseButton
small
on:close={() => {
if (s3ResourceSettings.secondaryStorage) {
s3ResourceSettings.secondaryStorage.splice(idx - 1, 1)
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
}
}}
/>
{/if}
</Cell>
</Row>
{/each}
<div class="flex gap-1">
<Button
size="xs"
variant="default"
on:click={() => {
if (s3ResourceSettings.secondaryStorage === undefined) {
s3ResourceSettings.secondaryStorage = []
}
s3ResourceSettings.secondaryStorage.push([
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
{
resourcePath: '',
resourceType: 's3',
publicResource: false,
advancedPermissions: defaultS3AdvancedPermissions(!!$enterpriseLicense)
}
])
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
}}><Plus size={14} />Add secondary storage</Button
>
<Tooltip>
Secondary storage is a feature that allows you to read and write from storage that isn't
your main storage by specifying it in the s3 object as "secondary_storage" with the name
of it
</Tooltip>
</div>
</div>
</div>
<Row class="!border-0">
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
<div class="flex justify-center">
<Button
size="sm"
btnClasses="max-w-fit"
variant="default"
on:click={() => {
if (s3ResourceSettings.secondaryStorage === undefined) {
s3ResourceSettings.secondaryStorage = []
}
s3ResourceSettings.secondaryStorage.push([
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
{
resourcePath: '',
resourceType: 's3',
publicResource: false,
advancedPermissions: defaultS3AdvancedPermissions(!!$enterpriseLicense)
}
])
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
}}
>
<Plus /> Add secondary storage
<Tooltip>
Secondary storage is a feature that allows you to read and write from storage that
isn't your main storage by specifying it in the s3 object as "secondary_storage"
with the name of it
</Tooltip>
</Button>
</div>
</Cell>
</Row>
</tbody>
</DataTable>
<div class="flex mt-5 mb-5 gap-1">
<Button
variant="accent"
@@ -228,10 +261,10 @@
</div>
{/if}
{#snippet permissionBtn(storage: NonNullable<S3ResourceSettings['secondaryStorage']>[number][1])}
{#snippet permissionBtn(storage: S3ResourceSettingsItem)}
<Popover closeOnOtherPopoverOpen placement="left">
<svelte:fragment slot="trigger">
<Button variant="default" wrapperClasses="h-full" btnClasses="px-2.5" size="sm">
<Button variant="default" btnClasses="px-2.5" size="sm">
<Shield size={16} /> Permissions <ChevronDown size={14} />
</Button>
</svelte:fragment>
+25
View File
@@ -1960,3 +1960,28 @@ export function countChars(str: string, char: string): number {
export function onlyAlphaNumAndUnderscore(str: string): string {
return str.replace(/[^a-zA-Z0-9_]/g, '')
}
export function buildReactiveObj<T extends object>(fields: {
[name in keyof T]: [() => T[name], (v: T[name]) => void]
}): T {
const obj = {} as T
for (const key in fields) {
Object.defineProperty(obj, key, {
get: fields[key][0],
set: fields[key][1],
enumerable: true,
configurable: true
})
}
return obj
}
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>
for (const key of keys) {
if (key in obj) {
result[key] = obj[key]
}
}
return result
}
@@ -120,7 +120,7 @@
publicResource: undefined,
secondaryStorage: undefined
})
let initialS3ResourceSettings: S3ResourceSettings = $state({
let s3ResourceSavedSettings: S3ResourceSettings = $state({
resourceType: 's3',
resourcePath: undefined,
publicResource: undefined,
@@ -353,7 +353,7 @@
settings.large_file_storage,
!!$enterpriseLicense
)
initialS3ResourceSettings = clone(s3ResourceSettings)
s3ResourceSavedSettings = clone(s3ResourceSettings)
dataTableSettings = convertDataTableSettingsFromBackend(settings.datatable)
ducklakeSettings = convertDucklakeSettingsFromBackend(settings.ducklake)
ducklakeSavedSettings = clone(ducklakeSettings)
@@ -580,7 +580,7 @@
}
const savedValue = {
s3ResourceSettings: initialS3ResourceSettings,
s3ResourceSettings: s3ResourceSavedSettings,
ducklakeSettings: ducklakeSavedSettings
}
@@ -594,7 +594,7 @@
// Function to discard unsaved storage settings changes
function discardStorageSettingsChanges() {
s3ResourceSettings = clone(initialS3ResourceSettings)
s3ResourceSettings = clone(s3ResourceSavedSettings)
ducklakeSettings = clone(ducklakeSavedSettings)
}
@@ -1203,8 +1203,9 @@
{:else if tab == 'windmill_lfs'}
<StorageSettings
bind:s3ResourceSettings
{s3ResourceSavedSettings}
onSave={() => {
initialS3ResourceSettings = clone(s3ResourceSettings)
s3ResourceSavedSettings = clone(s3ResourceSettings)
}}
/>
<DucklakeSettings
@@ -0,0 +1,105 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
let email = $state('')
let loading = $state(false)
let submitted = $state(false)
async function requestPasswordReset() {
if (!email) {
sendUserToast('Please enter your email address', true)
return
}
loading = true
try {
await UserService.requestPasswordReset({ requestBody: { email } })
submitted = true
sendUserToast('If an account with that email exists, a password reset link has been sent.')
} catch (err: any) {
if (err?.body?.includes('SMTP is not configured')) {
sendUserToast('Password reset is not available. SMTP is not configured.', true)
} else {
sendUserToast('An error occurred. Please try again later.', true)
}
} finally {
loading = false
}
}
function handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
requestPasswordReset()
}
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
Reset password
</h2>
<p class="mt-2 text-center text-xs text-secondary">
Enter your email address and we'll send you a link to reset your password
</p>
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if submitted}
<div class="text-center space-y-4">
<p class="text-secondary">
If an account with that email exists, we've sent a password reset link.
</p>
<p class="text-secondary text-sm">
Please check your email and follow the instructions to reset your password.
</p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{:else}
<div class="space-y-6">
<div class="space-y-1">
<label for="email" class="block text-xs font-semibold text-emphasis">Email</label>
<div>
<input
type="email"
bind:value={email}
id="email"
autocomplete="email"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="pt-2 flex flex-col gap-2">
<Button on:click={requestPasswordReset} variant="accent" disabled={!email || loading}>
{loading ? 'Sending...' : 'Send reset link'}
</Button>
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,147 @@
<script lang="ts">
import { goto } from '$lib/navigation'
import { page } from '$app/stores'
import { WindmillIcon } from '$lib/components/icons'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { UserService } from '$lib/gen'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
const token = $page.url.searchParams.get('token') ?? ''
let newPassword = $state('')
let confirmPassword = $state('')
let loading = $state(false)
let success = $state(false)
async function resetPassword() {
if (!token) {
sendUserToast('Invalid or missing reset token', true)
return
}
if (!newPassword || !confirmPassword) {
sendUserToast('Please fill in both password fields', true)
return
}
if (newPassword !== confirmPassword) {
sendUserToast('Passwords do not match', true)
return
}
loading = true
try {
await UserService.resetPassword({
requestBody: {
token,
new_password: newPassword
}
})
success = true
sendUserToast('Password has been reset successfully!')
} catch (err: any) {
console.error('Could not reset password', err)
sendUserToast('Could not reset password: ' + err, true)
} finally {
loading = false
}
}
function handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
resetPassword()
}
}
</script>
<div
class="flex flex-col justify-center py-12 sm:px-6 lg:px-8 relative bg-surface-secondary h-screen"
>
<LoginPageHeader />
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<div class="mx-auto flex justify-center">
{#if !$enterpriseLicense || !$whitelabelNameStore}
<WindmillIcon height="80px" width="80px" spin="slow" />
{/if}
</div>
<h2 class="mt-6 text-center text-2xl font-semibold tracking-tight text-emphasis">
{success ? 'Password Reset' : 'Set New Password'}
</h2>
{#if !success}
<p class="mt-2 text-center text-xs text-secondary"> Enter your new password below </p>
{/if}
</div>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-xl mb-48">
<div class="flex justify-end">
<DarkModeToggle forcedDarkMode={false} />
</div>
<div class="bg-surface px-4 py-8 border sm:rounded-lg sm:px-10">
{#if !token}
<div class="text-center space-y-4">
<p class="text-red-500">Invalid or missing reset token.</p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/forgot-password')}>
Request New Reset Link
</Button>
</div>
</div>
{:else if success}
<div class="text-center space-y-4">
<p class="text-secondary"> Your password has been reset successfully. </p>
<p class="text-secondary text-sm"> You can now log in with your new password. </p>
<div class="pt-4">
<Button variant="accent" on:click={() => goto('/user/login')}>Go to login</Button>
</div>
</div>
{:else}
<div class="space-y-6">
<div class="space-y-1">
<label for="new-password" class="block text-xs font-semibold text-emphasis">
New Password
</label>
<div>
<input
type="password"
bind:value={newPassword}
id="new-password"
autocomplete="new-password"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="space-y-1">
<label for="confirm-password" class="block text-xs font-semibold text-emphasis">
Confirm Password
</label>
<div>
<input
type="password"
bind:value={confirmPassword}
id="confirm-password"
autocomplete="new-password"
onkeyup={handleKeyUp}
/>
</div>
</div>
<div class="pt-2 flex flex-col gap-2">
<Button
on:click={resetPassword}
variant="accent"
disabled={!newPassword || !confirmPassword || loading}
>
{loading ? 'Resetting...' : 'Reset password'}
</Button>
<Button variant="subtle" on:click={() => goto('/user/login')}>Back to login</Button>
</div>
</div>
{/if}
</div>
</div>
</div>
+2 -2
View File
@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.602.0"
wmill_pg = ">=1.602.0"
wmill = ">=1.603.0"
wmill_pg = ">=1.603.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.602.0
version: 1.603.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.602.0'
ModuleVersion = '1.603.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.602.0"
version = "1.603.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill-pg"
version = "1.602.0"
version = "1.603.0"
description = "An extension client for the wmill client library focused on pg"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.602.0",
"version": "1.603.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.602.0",
"version": "1.603.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"devDependencies": {
+1 -1
View File
@@ -1 +1 @@
1.602.0
1.603.0